[
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"cargo\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "jobs:\n  build:\n    name: Build\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: dtolnay/rust-toolchain@stable\n      - uses: swatinem/rust-cache@v2\n      - run: cargo build\n\n  test:\n    name: Test\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: dtolnay/rust-toolchain@stable\n      - uses: swatinem/rust-cache@v2\n      - run: cargo test\n\n  lint:\n    name: Lint\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: dtolnay/rust-toolchain@stable\n      - uses: swatinem/rust-cache@v2\n      - run: cargo fmt --all -- --check\n      - run: cargo clippy -- -D warnings\n\n  msrv:\n    name: MSRV\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: dtolnay/rust-toolchain@stable\n      - uses: swatinem/rust-cache@v2\n      - run: cargo install cargo-msrv\n      - run: cargo msrv verify\n\non:\n  push:\n  pull_request:\n  workflow_dispatch:\n"
  },
  {
    "path": ".gitignore",
    "content": "/target\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"tzupdate\"\nversion = \"3.1.0\"\nedition = \"2021\"\nauthors = [\"Chris Down <chris@chrisdown.name>\"]\ndescription = \"Set the system timezone based on IP geolocation.\"\nrepository = \"https://github.com/cdown/tzupdate\"\nreadme = \"README.md\"\nkeywords = [\"timezone\", \"localtime\", \"geolocation\"]\ncategories = [\"command-line-utilities\"]\nlicense = \"MIT\"\nrust-version = \"1.74\"\n\n[dependencies]\nanyhow = \"1.0.99\"\nclap = { version = \"4.5.46\", default-features = false, features = [\"std\", \"derive\", \"help\"] }\nenv_logger = { version = \"0.11.8\", features = [\"humantime\"], default-features = false }\nlog = \"0.4.27\"\nserde_json = { version = \"1.0.143\", default-features = false }\ntempfile = \"3.21.0\"\nureq = { version = \"3.1.0\", default-features = false, features = [\"json\", \"rustls\"] }\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License\n\nCopyright (c) 2012-present Christopher Down\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# tzupdate | [![Tests](https://img.shields.io/github/actions/workflow/status/cdown/tzupdate/ci.yml?branch=master)](https://github.com/cdown/tzupdate/actions?query=branch%3Amaster)\n\ntzupdate is a fully automated utility to set the system time using geolocation.\n\n## Features\n\n- Small, easy to understand codebase\n- Queries multiple geolocation services in parallel and returns the first with\n  a successful result\n- Protects against directory traversal and invalid results when linking\n  /etc/localtime\n\n## Installation\n\n    cargo install tzupdate\n\n## Usage\n\n    # tzupdate\n    Set system timezone to Europe/London.\n\nInternally, this geolocates you, gets the timezone for that geolocation, and\nthen updates the system's local time zone.\n\nYou can see what tzupdate would do without actually doing it by passing `-p`,\nand specify an alternative IP address by using `-i`. This is not an exhaustive\nlist of options, see `tzupdate --help` for that.\n"
  },
  {
    "path": "src/file.rs",
    "content": "use anyhow::{bail, Context, Result};\nuse log::debug;\nuse std::fs;\nuse std::io::{self, Write};\nuse std::os::linux::fs::MetadataExt;\nuse std::os::unix::fs::{symlink, OpenOptionsExt};\nuse std::path::{Path, PathBuf};\n\n/// Canonicalise `path`, checking for directory traversal outside of `base`.\n///\n/// Since we are linking based upon the output of some data we retrieved over the internet, we\n/// should check that the output doesn't attempt to do something naughty with local path traversal.\n///\n/// This function checks that the base directory of the zoneinfo database shares a common prefix\n/// with the absolute path of the requested zoneinfo file.\nfn safe_canonicalise_path(base: PathBuf, path: PathBuf) -> io::Result<PathBuf> {\n    if path.canonicalize()?.starts_with(base.canonicalize()?) {\n        Ok(path)\n    } else {\n        Err(io::Error::other(format!(\n            \"Directory traversal detected, {} is outside of {}\",\n            path.display(),\n            base.display()\n        )))\n    }\n}\n\n/// Given a file that we eventually want to atomically rename to, give us a temporary path that we\n/// can use in the interim.\n///\n/// This must be in the same directory as `path` because we will later call `fs::rename` on it,\n/// which is only atomic when not cross device.\nfn get_tmp_path(path: &Path) -> Result<PathBuf> {\n    tempfile::Builder::new()\n        .tempfile_in(\n            path.parent()\n                .context(\"Refusing to create temp file in root\")?,\n        )?\n        .into_temp_path()\n        .canonicalize()\n        .map_err(anyhow::Error::from)\n}\n\n/// Atomically link to a timezone file from the zoneinfo db from `localtime_path`.\n///\n/// Since we may be retrieving the timezone file's relative path from an untrusted source, we also\n/// do checks to make sure that no directory traversal is going on. See `safe_canonicalise_path`\n/// for information about how that works.\npub fn link_localtime(\n    timezone: &str,\n    localtime_path: PathBuf,\n    zoneinfo_path: PathBuf,\n) -> Result<()> {\n    let localtime_tmp_path = get_tmp_path(&localtime_path)?;\n    let unsafe_tz_path = zoneinfo_path.join(timezone);\n    let tz_path = match safe_canonicalise_path(zoneinfo_path, unsafe_tz_path) {\n        Ok(path) => path,\n        Err(err) if err.kind() == io::ErrorKind::NotFound => {\n            bail!(\"Timezone \\\"{timezone}\\\" requested, but this timezone is not available on your operating system.\");\n        }\n        Err(err) => bail!(err),\n    };\n\n    if let Err(err) = fs::remove_file(&localtime_tmp_path) {\n        if err.kind() != io::ErrorKind::NotFound {\n            bail!(err);\n        }\n    }\n\n    debug!(\n        \"Symlinking {} to {}\",\n        localtime_tmp_path.display(),\n        tz_path.display()\n    );\n    symlink(tz_path, &localtime_tmp_path)?;\n\n    // We should seek to avoid avoid /etc/localtime disappearing, even briefly, to avoid\n    // applications being unhappy -- that's why we insist on atomic rename.\n    let tmp_dev = localtime_tmp_path.metadata()?.st_dev();\n    let target_dev = localtime_path\n        .parent()\n        .context(\"localtime path has no parent\")?\n        .metadata()?\n        .st_dev();\n\n    if tmp_dev != target_dev {\n        fs::remove_file(&localtime_tmp_path)?;\n        bail!(\n            \"Cannot atomically rename, {} and {} are not on the same device\",\n            localtime_tmp_path.display(),\n            localtime_path.display()\n        );\n    }\n\n    debug!(\n        \"Atomically renaming {} to {}\",\n        localtime_tmp_path.display(),\n        localtime_path.display()\n    );\n    fs::rename(localtime_tmp_path, localtime_path)?;\n\n    Ok(())\n}\n\n/// Debian and derivatives also have /etc/timezone, which is used for a human readable timezone.\n/// Without this, dpkg-reconfigure will nuke /etc/localtime on reconfigure.\n///\n/// If `always_write` is false, we will skip when /etc/timezone doesn't exist.\npub fn write_timezone(timezone: &str, filename: PathBuf, always_write: bool) -> io::Result<()> {\n    let mut file = match fs::OpenOptions::new()\n        .write(true)\n        .create(always_write)\n        .truncate(true)\n        .mode(0o644)\n        .open(&filename)\n    {\n        Ok(file) => file,\n        Err(err) if err.kind() == io::ErrorKind::NotFound => {\n            debug!(\"{} does not exist, not writing to it\", filename.display());\n            return Ok(());\n        }\n        Err(err) => return Err(err),\n    };\n    let data = format!(\"{timezone}\\n\");\n    file.write_all(data.as_bytes())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn canonicalise_normal() {\n        let base = PathBuf::from(\"/etc\");\n        let path = PathBuf::from(\"/etc/passwd\");\n        assert!(safe_canonicalise_path(base, path).is_ok());\n    }\n\n    #[test]\n    fn canonicalise_back_and_forth() {\n        let base = PathBuf::from(\"/etc\");\n        let path = PathBuf::from(\"/etc/../etc/passwd\");\n        assert!(safe_canonicalise_path(base, path).is_ok());\n    }\n\n    #[test]\n    fn canonicalise_failure() {\n        let base = PathBuf::from(\"/etc\");\n        let path = PathBuf::from(\"/etc/../passwd\");\n        assert!(safe_canonicalise_path(base, path).is_err());\n    }\n\n    #[test]\n    fn link_localtime_missing_target() {\n        let temp_dir = tempfile::TempDir::new().unwrap();\n        let temp_path = temp_dir.path();\n\n        let zoneinfo_path = temp_path.join(\"zoneinfo\");\n        fs::create_dir_all(&zoneinfo_path).unwrap();\n        let tz_file = zoneinfo_path.join(\"America\").join(\"Toronto\");\n        fs::create_dir_all(tz_file.parent().unwrap()).unwrap();\n        fs::write(&tz_file, \"fake timezone data\").unwrap();\n\n        let localtime_path = temp_path.join(\"localtime\");\n\n        let result = link_localtime(\"America/Toronto\", localtime_path.clone(), zoneinfo_path);\n        assert!(\n            result.is_ok(),\n            \"link_localtime should succeed when target doesn't exist: {:?}\",\n            result\n        );\n\n        assert!(localtime_path.exists());\n        assert!(localtime_path.is_symlink());\n    }\n}\n"
  },
  {
    "path": "src/http.rs",
    "content": "use anyhow::{anyhow, bail, Context, Result};\nuse log::debug;\nuse serde_json::Value;\nuse std::cmp::Reverse;\nuse std::collections::HashMap;\nuse std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender};\nuse std::thread;\nuse std::time::{Duration, Instant};\n\nstruct GeoIPService {\n    url: &'static str,\n    tz_keys: &'static [&'static str],\n}\n\nstruct Tally {\n    count: usize,\n    first_seen: usize,\n}\n\nstruct SpawnedRequests {\n    receiver: Receiver<String>,\n    svc_count: usize,\n}\n\n/// {ip} will be replaced with the IP. Services must be able to take {ip} being replaced with \"\" as\n/// meaning to use the source IP for the request.\nstatic SERVICES: &[GeoIPService] = &[\n    GeoIPService {\n        url: \"https://geoip.chrisdown.name/{ip}\",\n        tz_keys: &[\"location\", \"time_zone\"],\n    },\n    GeoIPService {\n        url: \"https://api.ipbase.com/v1/json/{ip}\",\n        tz_keys: &[\"time_zone\"],\n    },\n    GeoIPService {\n        url: \"https://ipapi.co/{ip}/json/\",\n        tz_keys: &[\"timezone\"],\n    },\n    GeoIPService {\n        url: \"https://worldtimeapi.org/api/ip/{ip}\",\n        tz_keys: &[\"timezone\"],\n    },\n    GeoIPService {\n        url: \"https://reallyfreegeoip.org/json/{ip}\",\n        tz_keys: &[\"time_zone\"],\n    },\n    GeoIPService {\n        url: \"https://ipwho.is/{ip}\",\n        tz_keys: &[\"timezone\", \"id\"],\n    },\n    GeoIPService {\n        url: \"https://ipinfo.io/{ip}/json\",\n        tz_keys: &[\"timezone\"],\n    },\n];\n\n/// Given &[\"foo\", \"bar\", \"baz\"], retrieve the value at data[\"foo\"][\"bar\"][\"baz\"].\nfn get_nested_value(mut data: Value, keys: &[&str]) -> Option<Value> {\n    for key in keys {\n        match data {\n            Value::Object(mut map) => {\n                data = map.remove(*key)?;\n            }\n            _ => return None,\n        }\n    }\n\n    Some(data)\n}\n\n/// A single service worker, racing with others as part of `get_timezone`.\nfn get_timezone_for_ip(url: &str, service: &GeoIPService, sender: Sender<String>) -> Result<()> {\n    let mut res = ureq::get(url).call()?;\n    let val = match get_nested_value(res.body_mut().read_json()?, service.tz_keys)\n        .with_context(|| format!(\"Invalid data for {url}\"))?\n    {\n        Value::String(s) => s,\n        _ => bail!(\"Timezone field for {url} is not a string\"),\n    };\n    debug!(\"Sending {val} back to main thread from {url}\");\n\n    // Only fails if receiver is disconnected, which just means we lost the race\n    let _ = sender.send(val);\n    Ok(())\n}\n\n/// Spawn workers for all SERVICES and return a handle for their responses.\nfn spawn_requests(ip_addr: &str) -> SpawnedRequests {\n    let (sender, receiver) = channel();\n\n    for (svc, sender) in SERVICES.iter().zip(std::iter::repeat(sender)) {\n        let url = svc.url.replace(\"{ip}\", ip_addr);\n        // For our small number of services, this makes more sense than using a full async runtime\n        thread::spawn(move || {\n            if let Err(err) = get_timezone_for_ip(&url, svc, sender) {\n                debug!(\"{url}: {err}\");\n            }\n        });\n    }\n\n    SpawnedRequests {\n        receiver,\n        svc_count: SERVICES.len(),\n    }\n}\n\n/// Spawn background HTTP requests, returning the first timezone that replies.\npub fn get_timezone_first(ip_addr: String, timeout: Duration) -> Result<String> {\n    let SpawnedRequests { receiver, .. } = spawn_requests(&ip_addr);\n\n    receiver.recv_timeout(timeout).map_err(|err| match err {\n        RecvTimeoutError::Disconnected => {\n            anyhow!(\"All APIs failed. Run with RUST_LOG=tzupdate=debug for more information.\")\n        }\n        RecvTimeoutError::Timeout => anyhow!(\"All APIs timed out, consider increasing --timeout.\"),\n    })\n}\n\n/// Spawn background HTTP requests, collecting responses and choosing by consensus.\n/// If a strict majority (> 50%) is reached before the timeout, return immediately.\n/// Otherwise, after the timeout or when all responses are in, return the most frequent\n/// timezone seen. Ties are broken by the earliest response among the tied values.\npub fn get_timezone_consensus(ip_addr: String, timeout: Duration) -> Result<String> {\n    let SpawnedRequests {\n        receiver,\n        svc_count,\n    } = spawn_requests(&ip_addr);\n\n    let deadline = Instant::now() + timeout;\n    let majority = svc_count / 2 + 1;\n\n    let mut tallies = HashMap::new();\n    let mut seen_idx = 0usize;\n    let mut timed_out = false;\n\n    loop {\n        let now = Instant::now();\n        if now >= deadline {\n            timed_out = true;\n            break;\n        }\n\n        match receiver.recv_timeout(deadline.saturating_duration_since(now)) {\n            Ok(tz) => {\n                seen_idx += 1;\n                let entry = tallies.entry(tz.clone()).or_insert(Tally {\n                    count: 0,\n                    first_seen: seen_idx,\n                });\n                entry.count += 1;\n\n                if entry.count >= majority {\n                    return Ok(tz);\n                }\n\n                if seen_idx == svc_count {\n                    break;\n                }\n            }\n            Err(RecvTimeoutError::Timeout) => {\n                timed_out = true;\n                break;\n            }\n            Err(RecvTimeoutError::Disconnected) => break,\n        }\n    }\n\n    if tallies.is_empty() {\n        if timed_out {\n            bail!(\"All APIs timed out, consider increasing --timeout.\");\n        }\n        bail!(\"All APIs failed. Run with RUST_LOG=tzupdate=debug for more information.\");\n    }\n\n    let (best_tz, _) = tallies\n        .into_iter()\n        .max_by_key(|(_, tally)| (tally.count, Reverse(tally.first_seen)))\n        .unwrap();\n\n    Ok(best_tz)\n}\n"
  },
  {
    "path": "src/main.rs",
    "content": "use anyhow::Result;\nuse clap::Parser;\nuse log::info;\nuse std::path::PathBuf;\nuse std::time::Duration;\n\nmod file;\nmod http;\n\n#[derive(Parser, Debug)]\n#[command(author, version, about, long_about = None)]\nstruct Config {\n    #[arg(\n        short,\n        long,\n        help = \"print the timezone, but don't update /etc/timezone or /etc/localtime\"\n    )]\n    print_only: bool,\n\n    #[arg(\n        short,\n        long,\n        help = \"use this IP instead of automatically detecting it\"\n    )]\n    ip: Option<String>,\n\n    #[arg(\n        short,\n        long,\n        help = \"use this timezone instead of automatically detecting it\"\n    )]\n    timezone: Option<String>,\n\n    #[arg(\n        short,\n        long,\n        help = \"use consensus to choose timezone instead of first response\"\n    )]\n    consensus: bool,\n\n    #[arg(\n        short,\n        long,\n        help = \"path to root of the zoneinfo database\",\n        default_value = \"/usr/share/zoneinfo\"\n    )]\n    zoneinfo_path: PathBuf,\n\n    #[arg(\n        short,\n        long,\n        help = \"path to localtime symlink\",\n        default_value = \"/etc/localtime\"\n    )]\n    localtime_path: PathBuf,\n\n    #[arg(\n        short,\n        long,\n        help = \"path to Debian timezone name file\",\n        default_value = \"/etc/timezone\"\n    )]\n    debian_timezone_path: PathBuf,\n\n    #[arg(long, help = \"create Debian timezone file even if it doesn't exist\")]\n    always_write_debian_timezone: bool,\n\n    #[arg(\n        short = 's',\n        long,\n        help = \"maximum number of seconds to wait for APIs to return\",\n        value_parser = parse_secs,\n        default_value = \"30\"\n    )]\n    timeout: Duration,\n}\n\nfn parse_secs(arg: &str) -> Result<Duration> {\n    Ok(Duration::from_secs(arg.parse()?))\n}\n\nfn main() -> Result<()> {\n    env_logger::init_from_env(env_logger::Env::default().default_filter_or(\"warn\"));\n\n    let cfg = Config::parse();\n    let tz = match cfg.timezone {\n        Some(tz) => tz,\n        None => {\n            if cfg.consensus {\n                http::get_timezone_consensus(cfg.ip.unwrap_or_default(), cfg.timeout)?\n            } else {\n                http::get_timezone_first(cfg.ip.unwrap_or_default(), cfg.timeout)?\n            }\n        }\n    };\n\n    if cfg.print_only {\n        println!(\"{tz}\");\n        return Ok(());\n    }\n\n    info!(\"Got timezone {tz}\");\n    file::link_localtime(&tz, cfg.localtime_path, cfg.zoneinfo_path)?;\n    file::write_timezone(\n        &tz,\n        cfg.debian_timezone_path,\n        cfg.always_write_debian_timezone,\n    )?;\n    println!(\"Set system timezone to {tz}.\");\n\n    Ok(())\n}\n"
  }
]