Repository: cdown/tzupdate
Branch: master
Commit: 91d65d861c4e
Files: 9
Total size: 17.7 KB
Directory structure:
gitextract_wmokg54h/
├── .github/
│ ├── dependabot.yml
│ └── workflows/
│ └── ci.yml
├── .gitignore
├── Cargo.toml
├── LICENSE
├── README.md
└── src/
├── file.rs
├── http.rs
└── main.rs
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
================================================
FILE: .github/workflows/ci.yml
================================================
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- uses: swatinem/rust-cache@v2
- run: cargo build
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- uses: swatinem/rust-cache@v2
- run: cargo test
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- uses: swatinem/rust-cache@v2
- run: cargo fmt --all -- --check
- run: cargo clippy -- -D warnings
msrv:
name: MSRV
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- uses: swatinem/rust-cache@v2
- run: cargo install cargo-msrv
- run: cargo msrv verify
on:
push:
pull_request:
workflow_dispatch:
================================================
FILE: .gitignore
================================================
/target
================================================
FILE: Cargo.toml
================================================
[package]
name = "tzupdate"
version = "3.1.0"
edition = "2021"
authors = ["Chris Down <chris@chrisdown.name>"]
description = "Set the system timezone based on IP geolocation."
repository = "https://github.com/cdown/tzupdate"
readme = "README.md"
keywords = ["timezone", "localtime", "geolocation"]
categories = ["command-line-utilities"]
license = "MIT"
rust-version = "1.74"
[dependencies]
anyhow = "1.0.99"
clap = { version = "4.5.46", default-features = false, features = ["std", "derive", "help"] }
env_logger = { version = "0.11.8", features = ["humantime"], default-features = false }
log = "0.4.27"
serde_json = { version = "1.0.143", default-features = false }
tempfile = "3.21.0"
ureq = { version = "3.1.0", default-features = false, features = ["json", "rustls"] }
================================================
FILE: LICENSE
================================================
The MIT License
Copyright (c) 2012-present Christopher Down
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================
FILE: README.md
================================================
# tzupdate | [](https://github.com/cdown/tzupdate/actions?query=branch%3Amaster)
tzupdate is a fully automated utility to set the system time using geolocation.
## Features
- Small, easy to understand codebase
- Queries multiple geolocation services in parallel and returns the first with
a successful result
- Protects against directory traversal and invalid results when linking
/etc/localtime
## Installation
cargo install tzupdate
## Usage
# tzupdate
Set system timezone to Europe/London.
Internally, this geolocates you, gets the timezone for that geolocation, and
then updates the system's local time zone.
You can see what tzupdate would do without actually doing it by passing `-p`,
and specify an alternative IP address by using `-i`. This is not an exhaustive
list of options, see `tzupdate --help` for that.
================================================
FILE: src/file.rs
================================================
use anyhow::{bail, Context, Result};
use log::debug;
use std::fs;
use std::io::{self, Write};
use std::os::linux::fs::MetadataExt;
use std::os::unix::fs::{symlink, OpenOptionsExt};
use std::path::{Path, PathBuf};
/// Canonicalise `path`, checking for directory traversal outside of `base`.
///
/// Since we are linking based upon the output of some data we retrieved over the internet, we
/// should check that the output doesn't attempt to do something naughty with local path traversal.
///
/// This function checks that the base directory of the zoneinfo database shares a common prefix
/// with the absolute path of the requested zoneinfo file.
fn safe_canonicalise_path(base: PathBuf, path: PathBuf) -> io::Result<PathBuf> {
if path.canonicalize()?.starts_with(base.canonicalize()?) {
Ok(path)
} else {
Err(io::Error::other(format!(
"Directory traversal detected, {} is outside of {}",
path.display(),
base.display()
)))
}
}
/// Given a file that we eventually want to atomically rename to, give us a temporary path that we
/// can use in the interim.
///
/// This must be in the same directory as `path` because we will later call `fs::rename` on it,
/// which is only atomic when not cross device.
fn get_tmp_path(path: &Path) -> Result<PathBuf> {
tempfile::Builder::new()
.tempfile_in(
path.parent()
.context("Refusing to create temp file in root")?,
)?
.into_temp_path()
.canonicalize()
.map_err(anyhow::Error::from)
}
/// Atomically link to a timezone file from the zoneinfo db from `localtime_path`.
///
/// Since we may be retrieving the timezone file's relative path from an untrusted source, we also
/// do checks to make sure that no directory traversal is going on. See `safe_canonicalise_path`
/// for information about how that works.
pub fn link_localtime(
timezone: &str,
localtime_path: PathBuf,
zoneinfo_path: PathBuf,
) -> Result<()> {
let localtime_tmp_path = get_tmp_path(&localtime_path)?;
let unsafe_tz_path = zoneinfo_path.join(timezone);
let tz_path = match safe_canonicalise_path(zoneinfo_path, unsafe_tz_path) {
Ok(path) => path,
Err(err) if err.kind() == io::ErrorKind::NotFound => {
bail!("Timezone \"{timezone}\" requested, but this timezone is not available on your operating system.");
}
Err(err) => bail!(err),
};
if let Err(err) = fs::remove_file(&localtime_tmp_path) {
if err.kind() != io::ErrorKind::NotFound {
bail!(err);
}
}
debug!(
"Symlinking {} to {}",
localtime_tmp_path.display(),
tz_path.display()
);
symlink(tz_path, &localtime_tmp_path)?;
// We should seek to avoid avoid /etc/localtime disappearing, even briefly, to avoid
// applications being unhappy -- that's why we insist on atomic rename.
let tmp_dev = localtime_tmp_path.metadata()?.st_dev();
let target_dev = localtime_path
.parent()
.context("localtime path has no parent")?
.metadata()?
.st_dev();
if tmp_dev != target_dev {
fs::remove_file(&localtime_tmp_path)?;
bail!(
"Cannot atomically rename, {} and {} are not on the same device",
localtime_tmp_path.display(),
localtime_path.display()
);
}
debug!(
"Atomically renaming {} to {}",
localtime_tmp_path.display(),
localtime_path.display()
);
fs::rename(localtime_tmp_path, localtime_path)?;
Ok(())
}
/// Debian and derivatives also have /etc/timezone, which is used for a human readable timezone.
/// Without this, dpkg-reconfigure will nuke /etc/localtime on reconfigure.
///
/// If `always_write` is false, we will skip when /etc/timezone doesn't exist.
pub fn write_timezone(timezone: &str, filename: PathBuf, always_write: bool) -> io::Result<()> {
let mut file = match fs::OpenOptions::new()
.write(true)
.create(always_write)
.truncate(true)
.mode(0o644)
.open(&filename)
{
Ok(file) => file,
Err(err) if err.kind() == io::ErrorKind::NotFound => {
debug!("{} does not exist, not writing to it", filename.display());
return Ok(());
}
Err(err) => return Err(err),
};
let data = format!("{timezone}\n");
file.write_all(data.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonicalise_normal() {
let base = PathBuf::from("/etc");
let path = PathBuf::from("/etc/passwd");
assert!(safe_canonicalise_path(base, path).is_ok());
}
#[test]
fn canonicalise_back_and_forth() {
let base = PathBuf::from("/etc");
let path = PathBuf::from("/etc/../etc/passwd");
assert!(safe_canonicalise_path(base, path).is_ok());
}
#[test]
fn canonicalise_failure() {
let base = PathBuf::from("/etc");
let path = PathBuf::from("/etc/../passwd");
assert!(safe_canonicalise_path(base, path).is_err());
}
#[test]
fn link_localtime_missing_target() {
let temp_dir = tempfile::TempDir::new().unwrap();
let temp_path = temp_dir.path();
let zoneinfo_path = temp_path.join("zoneinfo");
fs::create_dir_all(&zoneinfo_path).unwrap();
let tz_file = zoneinfo_path.join("America").join("Toronto");
fs::create_dir_all(tz_file.parent().unwrap()).unwrap();
fs::write(&tz_file, "fake timezone data").unwrap();
let localtime_path = temp_path.join("localtime");
let result = link_localtime("America/Toronto", localtime_path.clone(), zoneinfo_path);
assert!(
result.is_ok(),
"link_localtime should succeed when target doesn't exist: {:?}",
result
);
assert!(localtime_path.exists());
assert!(localtime_path.is_symlink());
}
}
================================================
FILE: src/http.rs
================================================
use anyhow::{anyhow, bail, Context, Result};
use log::debug;
use serde_json::Value;
use std::cmp::Reverse;
use std::collections::HashMap;
use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender};
use std::thread;
use std::time::{Duration, Instant};
struct GeoIPService {
url: &'static str,
tz_keys: &'static [&'static str],
}
struct Tally {
count: usize,
first_seen: usize,
}
struct SpawnedRequests {
receiver: Receiver<String>,
svc_count: usize,
}
/// {ip} will be replaced with the IP. Services must be able to take {ip} being replaced with "" as
/// meaning to use the source IP for the request.
static SERVICES: &[GeoIPService] = &[
GeoIPService {
url: "https://geoip.chrisdown.name/{ip}",
tz_keys: &["location", "time_zone"],
},
GeoIPService {
url: "https://api.ipbase.com/v1/json/{ip}",
tz_keys: &["time_zone"],
},
GeoIPService {
url: "https://ipapi.co/{ip}/json/",
tz_keys: &["timezone"],
},
GeoIPService {
url: "https://worldtimeapi.org/api/ip/{ip}",
tz_keys: &["timezone"],
},
GeoIPService {
url: "https://reallyfreegeoip.org/json/{ip}",
tz_keys: &["time_zone"],
},
GeoIPService {
url: "https://ipwho.is/{ip}",
tz_keys: &["timezone", "id"],
},
GeoIPService {
url: "https://ipinfo.io/{ip}/json",
tz_keys: &["timezone"],
},
];
/// Given &["foo", "bar", "baz"], retrieve the value at data["foo"]["bar"]["baz"].
fn get_nested_value(mut data: Value, keys: &[&str]) -> Option<Value> {
for key in keys {
match data {
Value::Object(mut map) => {
data = map.remove(*key)?;
}
_ => return None,
}
}
Some(data)
}
/// A single service worker, racing with others as part of `get_timezone`.
fn get_timezone_for_ip(url: &str, service: &GeoIPService, sender: Sender<String>) -> Result<()> {
let mut res = ureq::get(url).call()?;
let val = match get_nested_value(res.body_mut().read_json()?, service.tz_keys)
.with_context(|| format!("Invalid data for {url}"))?
{
Value::String(s) => s,
_ => bail!("Timezone field for {url} is not a string"),
};
debug!("Sending {val} back to main thread from {url}");
// Only fails if receiver is disconnected, which just means we lost the race
let _ = sender.send(val);
Ok(())
}
/// Spawn workers for all SERVICES and return a handle for their responses.
fn spawn_requests(ip_addr: &str) -> SpawnedRequests {
let (sender, receiver) = channel();
for (svc, sender) in SERVICES.iter().zip(std::iter::repeat(sender)) {
let url = svc.url.replace("{ip}", ip_addr);
// For our small number of services, this makes more sense than using a full async runtime
thread::spawn(move || {
if let Err(err) = get_timezone_for_ip(&url, svc, sender) {
debug!("{url}: {err}");
}
});
}
SpawnedRequests {
receiver,
svc_count: SERVICES.len(),
}
}
/// Spawn background HTTP requests, returning the first timezone that replies.
pub fn get_timezone_first(ip_addr: String, timeout: Duration) -> Result<String> {
let SpawnedRequests { receiver, .. } = spawn_requests(&ip_addr);
receiver.recv_timeout(timeout).map_err(|err| match err {
RecvTimeoutError::Disconnected => {
anyhow!("All APIs failed. Run with RUST_LOG=tzupdate=debug for more information.")
}
RecvTimeoutError::Timeout => anyhow!("All APIs timed out, consider increasing --timeout."),
})
}
/// Spawn background HTTP requests, collecting responses and choosing by consensus.
/// If a strict majority (> 50%) is reached before the timeout, return immediately.
/// Otherwise, after the timeout or when all responses are in, return the most frequent
/// timezone seen. Ties are broken by the earliest response among the tied values.
pub fn get_timezone_consensus(ip_addr: String, timeout: Duration) -> Result<String> {
let SpawnedRequests {
receiver,
svc_count,
} = spawn_requests(&ip_addr);
let deadline = Instant::now() + timeout;
let majority = svc_count / 2 + 1;
let mut tallies = HashMap::new();
let mut seen_idx = 0usize;
let mut timed_out = false;
loop {
let now = Instant::now();
if now >= deadline {
timed_out = true;
break;
}
match receiver.recv_timeout(deadline.saturating_duration_since(now)) {
Ok(tz) => {
seen_idx += 1;
let entry = tallies.entry(tz.clone()).or_insert(Tally {
count: 0,
first_seen: seen_idx,
});
entry.count += 1;
if entry.count >= majority {
return Ok(tz);
}
if seen_idx == svc_count {
break;
}
}
Err(RecvTimeoutError::Timeout) => {
timed_out = true;
break;
}
Err(RecvTimeoutError::Disconnected) => break,
}
}
if tallies.is_empty() {
if timed_out {
bail!("All APIs timed out, consider increasing --timeout.");
}
bail!("All APIs failed. Run with RUST_LOG=tzupdate=debug for more information.");
}
let (best_tz, _) = tallies
.into_iter()
.max_by_key(|(_, tally)| (tally.count, Reverse(tally.first_seen)))
.unwrap();
Ok(best_tz)
}
================================================
FILE: src/main.rs
================================================
use anyhow::Result;
use clap::Parser;
use log::info;
use std::path::PathBuf;
use std::time::Duration;
mod file;
mod http;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Config {
#[arg(
short,
long,
help = "print the timezone, but don't update /etc/timezone or /etc/localtime"
)]
print_only: bool,
#[arg(
short,
long,
help = "use this IP instead of automatically detecting it"
)]
ip: Option<String>,
#[arg(
short,
long,
help = "use this timezone instead of automatically detecting it"
)]
timezone: Option<String>,
#[arg(
short,
long,
help = "use consensus to choose timezone instead of first response"
)]
consensus: bool,
#[arg(
short,
long,
help = "path to root of the zoneinfo database",
default_value = "/usr/share/zoneinfo"
)]
zoneinfo_path: PathBuf,
#[arg(
short,
long,
help = "path to localtime symlink",
default_value = "/etc/localtime"
)]
localtime_path: PathBuf,
#[arg(
short,
long,
help = "path to Debian timezone name file",
default_value = "/etc/timezone"
)]
debian_timezone_path: PathBuf,
#[arg(long, help = "create Debian timezone file even if it doesn't exist")]
always_write_debian_timezone: bool,
#[arg(
short = 's',
long,
help = "maximum number of seconds to wait for APIs to return",
value_parser = parse_secs,
default_value = "30"
)]
timeout: Duration,
}
fn parse_secs(arg: &str) -> Result<Duration> {
Ok(Duration::from_secs(arg.parse()?))
}
fn main() -> Result<()> {
env_logger::init_from_env(env_logger::Env::default().default_filter_or("warn"));
let cfg = Config::parse();
let tz = match cfg.timezone {
Some(tz) => tz,
None => {
if cfg.consensus {
http::get_timezone_consensus(cfg.ip.unwrap_or_default(), cfg.timeout)?
} else {
http::get_timezone_first(cfg.ip.unwrap_or_default(), cfg.timeout)?
}
}
};
if cfg.print_only {
println!("{tz}");
return Ok(());
}
info!("Got timezone {tz}");
file::link_localtime(&tz, cfg.localtime_path, cfg.zoneinfo_path)?;
file::write_timezone(
&tz,
cfg.debian_timezone_path,
cfg.always_write_debian_timezone,
)?;
println!("Set system timezone to {tz}.");
Ok(())
}
gitextract_wmokg54h/
├── .github/
│ ├── dependabot.yml
│ └── workflows/
│ └── ci.yml
├── .gitignore
├── Cargo.toml
├── LICENSE
├── README.md
└── src/
├── file.rs
├── http.rs
└── main.rs
SYMBOL INDEX (19 symbols across 3 files)
FILE: src/file.rs
function safe_canonicalise_path (line 16) | fn safe_canonicalise_path(base: PathBuf, path: PathBuf) -> io::Result<Pa...
function get_tmp_path (line 33) | fn get_tmp_path(path: &Path) -> Result<PathBuf> {
function link_localtime (line 49) | pub fn link_localtime(
function write_timezone (line 109) | pub fn write_timezone(timezone: &str, filename: PathBuf, always_write: b...
function canonicalise_normal (line 133) | fn canonicalise_normal() {
function canonicalise_back_and_forth (line 140) | fn canonicalise_back_and_forth() {
function canonicalise_failure (line 147) | fn canonicalise_failure() {
function link_localtime_missing_target (line 154) | fn link_localtime_missing_target() {
FILE: src/http.rs
type GeoIPService (line 10) | struct GeoIPService {
type Tally (line 15) | struct Tally {
type SpawnedRequests (line 20) | struct SpawnedRequests {
function get_nested_value (line 59) | fn get_nested_value(mut data: Value, keys: &[&str]) -> Option<Value> {
function get_timezone_for_ip (line 73) | fn get_timezone_for_ip(url: &str, service: &GeoIPService, sender: Sender...
function spawn_requests (line 89) | fn spawn_requests(ip_addr: &str) -> SpawnedRequests {
function get_timezone_first (line 109) | pub fn get_timezone_first(ip_addr: String, timeout: Duration) -> Result<...
function get_timezone_consensus (line 124) | pub fn get_timezone_consensus(ip_addr: String, timeout: Duration) -> Res...
FILE: src/main.rs
type Config (line 12) | struct Config {
function parse_secs (line 78) | fn parse_secs(arg: &str) -> Result<Duration> {
function main (line 82) | fn main() -> Result<()> {
Condensed preview — 9 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (19K chars).
[
{
"path": ".github/dependabot.yml",
"chars": 108,
"preview": "version: 2\nupdates:\n - package-ecosystem: \"cargo\"\n directory: \"/\"\n schedule:\n interval: \"daily\"\n"
},
{
"path": ".github/workflows/ci.yml",
"chars": 964,
"preview": "jobs:\n build:\n name: Build\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v3\n - uses: dto"
},
{
"path": ".gitignore",
"chars": 8,
"preview": "/target\n"
},
{
"path": "Cargo.toml",
"chars": 776,
"preview": "[package]\nname = \"tzupdate\"\nversion = \"3.1.0\"\nedition = \"2021\"\nauthors = [\"Chris Down <chris@chrisdown.name>\"]\ndescripti"
},
{
"path": "LICENSE",
"chars": 1085,
"preview": "The MIT License\n\nCopyright (c) 2012-present Christopher Down\n\nPermission is hereby granted, free of charge, to any perso"
},
{
"path": "README.md",
"chars": 943,
"preview": "# tzupdate | [](https"
},
{
"path": "src/file.rs",
"chars": 6004,
"preview": "use anyhow::{bail, Context, Result};\nuse log::debug;\nuse std::fs;\nuse std::io::{self, Write};\nuse std::os::linux::fs::Me"
},
{
"path": "src/http.rs",
"chars": 5625,
"preview": "use anyhow::{anyhow, bail, Context, Result};\nuse log::debug;\nuse serde_json::Value;\nuse std::cmp::Reverse;\nuse std::coll"
},
{
"path": "src/main.rs",
"chars": 2591,
"preview": "use anyhow::Result;\nuse clap::Parser;\nuse log::info;\nuse std::path::PathBuf;\nuse std::time::Duration;\n\nmod file;\nmod htt"
}
]
About this extraction
This page contains the full source code of the cdown/tzupdate GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 9 files (17.7 KB), approximately 4.6k tokens, and a symbol index with 19 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.