| 1 | //! `g1t upgrade`: the CLI updates itself to the newest release, and says |
| 2 | //! now and then, in a terminal, that a newer one is out. |
| 3 | //! |
| 4 | //! Releases are at `https://g1t.sh/downloads/cli/` (`G1T_DOWNLOAD_URL` |
| 5 | //! names another, as for the install scripts): `latest.json`, the newest |
| 6 | //! release's manifest, and `latest.json.sig`, its Ed25519 signature with |
| 7 | //! g1t's release key (scripts/cli-release.mjs). The CLI trusts a manifest |
| 8 | //! only when the signature checks out against the key built into it |
| 9 | //! (`G1T_RUNNER_RELEASE_KEY` when it was built), and installs a download |
| 10 | //! only when its SHA-256 is the manifest's. A build without the key, such |
| 11 | //! as one built from source, cannot upgrade itself and says how to. |
| 12 | |
| 13 | use std::io::IsTerminal; |
| 14 | use std::path::PathBuf; |
| 15 | use std::sync::mpsc; |
| 16 | use std::time::{Duration, SystemTime, UNIX_EPOCH}; |
| 17 | |
| 18 | use base64::Engine; |
| 19 | use base64::engine::general_purpose::STANDARD; |
| 20 | use serde::{Deserialize, Serialize}; |
| 21 | use sha2::{Digest, Sha256}; |
| 22 | |
| 23 | use crate::cli::UpgradeArgs; |
| 24 | use crate::error::{Error, Result}; |
| 25 | |
| 26 | /// This build's version. |
| 27 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); |
| 28 | /// g1t's release key, as base64 of its 32 bytes: set when release builds |
| 29 | /// are made. |
| 30 | const RELEASE_KEY: Option<&str> = option_env!("G1T_RUNNER_RELEASE_KEY"); |
| 31 | /// The install scripts, for a build that cannot upgrade itself. |
| 32 | const INSTALL: &str = "curl -fsSL https://g1t.sh/install | sh (on Windows: irm https://g1t.sh/install.ps1 | iex)"; |
| 33 | /// How often the CLI looks for a newer release on its own. |
| 34 | const CHECK_EVERY: Duration = Duration::from_secs(24 * 60 * 60); |
| 35 | /// How long a command's end waits for that look before leaving it. |
| 36 | const NOTICE_WAIT: Duration = Duration::from_millis(400); |
| 37 | |
| 38 | #[derive(Clone, Debug, Deserialize)] |
| 39 | pub struct File { |
| 40 | pub name: String, |
| 41 | pub sha256: String, |
| 42 | } |
| 43 | |
| 44 | #[derive(Clone, Debug, Deserialize)] |
| 45 | pub struct Manifest { |
| 46 | pub version: String, |
| 47 | /// By platform: `linux-x64`, `macos-arm64`, `windows-x64`… |
| 48 | pub files: std::collections::BTreeMap<String, File>, |
| 49 | } |
| 50 | |
| 51 | /// Where the CLI's releases are. |
| 52 | fn downloads() -> String { |
| 53 | std::env::var("G1T_DOWNLOAD_URL") |
| 54 | .ok() |
| 55 | .filter(|url| !url.trim().is_empty()) |
| 56 | .unwrap_or_else(|| "https://g1t.sh/downloads/cli".to_owned()) |
| 57 | .trim_end_matches('/') |
| 58 | .to_owned() |
| 59 | } |
| 60 | |
| 61 | /// This machine's platform, as releases name it. Windows on ARM runs the x64 build. |
| 62 | pub fn platform() -> String { |
| 63 | let os = match std::env::consts::OS { |
| 64 | "macos" => "macos", |
| 65 | "windows" => "windows", |
| 66 | _ => "linux", |
| 67 | }; |
| 68 | let arch = if std::env::consts::ARCH == "aarch64" && os != "windows" { "arm64" } else { "x64" }; |
| 69 | format!("{os}-{arch}") |
| 70 | } |
| 71 | |
| 72 | /// `a.b.c` newer than `x.y.z`. |
| 73 | pub fn newer(candidate: &str, current: &str) -> bool { |
| 74 | let parts = |v: &str| -> Vec<u64> { v.trim_start_matches('v').split(['.', '-']).take(3).map(|p| p.parse().unwrap_or(0)).collect() }; |
| 75 | parts(candidate) > parts(current) |
| 76 | } |
| 77 | |
| 78 | pub fn sha256(bytes: &[u8]) -> String { |
| 79 | hex::encode(Sha256::digest(bytes)) |
| 80 | } |
| 81 | |
| 82 | /// Whether `signature` (base64) is the release key's over `message`. |
| 83 | pub fn verify(message: &[u8], signature: &str, key: &str) -> std::result::Result<(), String> { |
| 84 | let key: [u8; 32] = STANDARD |
| 85 | .decode(key.trim()) |
| 86 | .ok() |
| 87 | .and_then(|bytes| bytes.try_into().ok()) |
| 88 | .ok_or("the release key built into this g1t does not read")?; |
| 89 | let signature: [u8; 64] = STANDARD |
| 90 | .decode(signature.trim()) |
| 91 | .ok() |
| 92 | .and_then(|bytes| bytes.try_into().ok()) |
| 93 | .ok_or("the release's signature does not read")?; |
| 94 | let key = ed25519_dalek::VerifyingKey::from_bytes(&key).map_err(|_| "the release key is not a key")?; |
| 95 | key.verify_strict(message, &ed25519_dalek::Signature::from_bytes(&signature)) |
| 96 | .map_err(|_| "the release's signature is not g1t's".to_owned()) |
| 97 | } |
| 98 | |
| 99 | fn agent(timeout: Duration) -> ureq::Agent { |
| 100 | ureq::AgentBuilder::new().timeout(timeout).user_agent(&format!("g1t/{VERSION}")).build() |
| 101 | } |
| 102 | |
| 103 | fn download(agent: &ureq::Agent, url: &str) -> std::result::Result<Vec<u8>, String> { |
| 104 | let response = agent.get(url).call().map_err(|error| format!("could not download {url}: {error}"))?; |
| 105 | let mut bytes = Vec::new(); |
| 106 | std::io::Read::read_to_end(&mut response.into_reader(), &mut bytes).map_err(|error| format!("could not download {url}: {error}"))?; |
| 107 | Ok(bytes) |
| 108 | } |
| 109 | |
| 110 | /// The newest release, its signature checked with `key`. |
| 111 | fn latest(agent: &ureq::Agent, key: &str) -> std::result::Result<Manifest, String> { |
| 112 | let base = downloads(); |
| 113 | let manifest = download(agent, &format!("{base}/latest.json"))?; |
| 114 | let signature = String::from_utf8(download(agent, &format!("{base}/latest.json.sig"))?).map_err(|_| "the release's signature does not read")?; |
| 115 | verify(&manifest, &signature, key)?; |
| 116 | serde_json::from_slice(&manifest).map_err(|error| format!("the release's manifest does not read: {error}")) |
| 117 | } |
| 118 | |
| 119 | /// Puts `bytes` where this program is. The running file is moved aside |
| 120 | /// first: Windows will not overwrite a running program, but lets it be |
| 121 | /// renamed. The old one is removed next time, when it no longer runs. |
| 122 | fn replace_self(bytes: &[u8]) -> std::result::Result<PathBuf, String> { |
| 123 | let me = std::env::current_exe().map_err(|error| format!("could not find this program: {error}"))?; |
| 124 | // A link to it (Homebrew-style) is followed to the file; Windows has none, and |
| 125 | // canonicalizing there only adds a `\\?\` prefix. |
| 126 | #[cfg(unix)] |
| 127 | let me = std::fs::canonicalize(&me).unwrap_or(me); |
| 128 | let new = me.with_extension("new"); |
| 129 | let old = me.with_extension("old"); |
| 130 | std::fs::write(&new, bytes).map_err(|error| format!("could not write {}: {error}", new.display()))?; |
| 131 | #[cfg(unix)] |
| 132 | { |
| 133 | use std::os::unix::fs::PermissionsExt; |
| 134 | std::fs::set_permissions(&new, std::fs::Permissions::from_mode(0o755)).map_err(|error| error.to_string())?; |
| 135 | } |
| 136 | let _ = std::fs::remove_file(&old); |
| 137 | if let Err(error) = std::fs::rename(&me, &old) { |
| 138 | let _ = std::fs::remove_file(&new); |
| 139 | return Err(format!("could not move {} aside: {error}", me.display())); |
| 140 | } |
| 141 | if let Err(error) = std::fs::rename(&new, &me) { |
| 142 | let _ = std::fs::rename(&old, &me); |
| 143 | return Err(format!("could not put the new version at {}: {error}", me.display())); |
| 144 | } |
| 145 | Ok(me) |
| 146 | } |
| 147 | |
| 148 | /// What an earlier upgrade left: the version it replaced, once it no |
| 149 | /// longer runs. |
| 150 | fn tidy_old() { |
| 151 | if let Ok(me) = std::env::current_exe() { |
| 152 | let _ = std::fs::remove_file(me.with_extension("old")); |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | /// `g1t upgrade`. |
| 157 | pub fn run(args: UpgradeArgs) -> Result<()> { |
| 158 | let Some(key) = RELEASE_KEY else { |
| 159 | return Err(Error::failed(format!( |
| 160 | "this build of g1t ({VERSION}) was not made by a g1t release, so it cannot check releases. Install the newest one with: {INSTALL}" |
| 161 | ))); |
| 162 | }; |
| 163 | let agent = agent(Duration::from_secs(120)); |
| 164 | let latest = latest(&agent, key).map_err(Error::failed)?; |
| 165 | remember(&latest.version); |
| 166 | if !newer(&latest.version, VERSION) { |
| 167 | println!("g1t {VERSION} is the newest release."); |
| 168 | return Ok(()); |
| 169 | } |
| 170 | if args.check { |
| 171 | println!("g1t {} is out; this is {VERSION}. Run `g1t upgrade` to install it.", latest.version); |
| 172 | return Ok(()); |
| 173 | } |
| 174 | let file = latest |
| 175 | .files |
| 176 | .get(&platform()) |
| 177 | .ok_or_else(|| Error::failed(format!("g1t {} has no build for {}.", latest.version, platform())))?; |
| 178 | eprintln!("Downloading g1t {} ({})…", latest.version, platform()); |
| 179 | let bytes = download(&agent, &format!("{}/{}/{}", downloads(), latest.version, file.name)).map_err(Error::failed)?; |
| 180 | let have = sha256(&bytes); |
| 181 | if have != file.sha256 { |
| 182 | return Err(Error::failed(format!("the download's SHA-256 is {have}, not the release's {}. Nothing was changed.", file.sha256))); |
| 183 | } |
| 184 | let at = replace_self(&bytes).map_err(Error::failed)?; |
| 185 | println!("Upgraded g1t {VERSION} to {} at {}.", latest.version, at.display()); |
| 186 | Ok(()) |
| 187 | } |
| 188 | |
| 189 | // --- Saying a newer release is out ------------------------------------------- |
| 190 | |
| 191 | #[derive(Default, Serialize, Deserialize)] |
| 192 | struct Seen { |
| 193 | /// Seconds since the epoch. |
| 194 | checked_at: u64, |
| 195 | version: String, |
| 196 | } |
| 197 | |
| 198 | fn seen_path() -> Option<PathBuf> { |
| 199 | crate::config::dir().ok().map(|dir| dir.join("latest-release.json")) |
| 200 | } |
| 201 | |
| 202 | fn now() -> u64 { |
| 203 | SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) |
| 204 | } |
| 205 | |
| 206 | fn remember(version: &str) { |
| 207 | if let Some(path) = seen_path() { |
| 208 | let _ = std::fs::create_dir_all(path.parent().unwrap_or(&path)); |
| 209 | let seen = Seen { checked_at: now(), version: version.to_owned() }; |
| 210 | let _ = std::fs::write(path, serde_json::to_vec(&seen).unwrap_or_default()); |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | /// The line that says a newer release is out, if one is. |
| 215 | pub fn notice_for(newest: &str, current: &str) -> Option<String> { |
| 216 | newer(newest, current).then(|| format!("g1t {newest} is out; this is {current}. Run `g1t upgrade` to install it.")) |
| 217 | } |
| 218 | |
| 219 | /// Looks for a newer release at most once a day, in the background, for |
| 220 | /// people at a terminal; [`Notice::finish`] says so after the command. Off |
| 221 | /// in CI, with `G1T_NO_UPDATE_NOTIFIER=1`, for `g1t upgrade` itself, and |
| 222 | /// in a build that cannot upgrade itself. |
| 223 | pub struct Notice { |
| 224 | known: Option<String>, |
| 225 | looking: Option<mpsc::Receiver<String>>, |
| 226 | } |
| 227 | |
| 228 | impl Notice { |
| 229 | pub fn start(upgrading: bool) -> Notice { |
| 230 | tidy_old(); |
| 231 | let off = Notice { known: None, looking: None }; |
| 232 | let Some(key) = RELEASE_KEY else { return off }; |
| 233 | let quiet = |name: &str| std::env::var(name).is_ok_and(|value| !value.is_empty() && value != "0"); |
| 234 | if upgrading || quiet("G1T_NO_UPDATE_NOTIFIER") || quiet("CI") || !std::io::stderr().is_terminal() { |
| 235 | return off; |
| 236 | } |
| 237 | let seen: Seen = seen_path() |
| 238 | .and_then(|path| std::fs::read(path).ok()) |
| 239 | .and_then(|bytes| serde_json::from_slice(&bytes).ok()) |
| 240 | .unwrap_or_default(); |
| 241 | let known = (!seen.version.is_empty()).then(|| seen.version.clone()); |
| 242 | if now().saturating_sub(seen.checked_at) < CHECK_EVERY.as_secs() { |
| 243 | return Notice { known, looking: None }; |
| 244 | } |
| 245 | let (tell, looking) = mpsc::channel(); |
| 246 | std::thread::spawn(move || { |
| 247 | if let Ok(latest) = latest(&agent(Duration::from_secs(5)), key) { |
| 248 | remember(&latest.version); |
| 249 | let _ = tell.send(latest.version); |
| 250 | } |
| 251 | }); |
| 252 | Notice { known, looking: Some(looking) } |
| 253 | } |
| 254 | |
| 255 | /// After the command: says so when a newer release is out. |
| 256 | pub fn finish(self) { |
| 257 | let newest = self.looking.and_then(|looking| looking.recv_timeout(NOTICE_WAIT).ok()).or(self.known); |
| 258 | if let Some(line) = newest.and_then(|newest| notice_for(&newest, VERSION)) { |
| 259 | eprintln!("\n{line}"); |
| 260 | } |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | #[cfg(test)] |
| 265 | mod tests { |
| 266 | use super::*; |
| 267 | use ed25519_dalek::Signer; |
| 268 | |
| 269 | #[test] |
| 270 | fn versions_compare_as_numbers() { |
| 271 | assert!(newer("0.10.0", "0.9.9")); |
| 272 | assert!(newer("v1.0.0", "0.9.0")); |
| 273 | assert!(!newer("0.3.3", "0.3.3")); |
| 274 | assert!(!newer("0.3.2", "0.3.3")); |
| 275 | assert_eq!(notice_for("0.3.2", "0.3.3"), None); |
| 276 | assert_eq!(notice_for("0.4.0", "0.3.3").as_deref(), Some("g1t 0.4.0 is out; this is 0.3.3. Run `g1t upgrade` to install it.")); |
| 277 | } |
| 278 | |
| 279 | #[test] |
| 280 | fn only_the_release_keys_signature_is_trusted() { |
| 281 | let signing = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); |
| 282 | let key = STANDARD.encode(signing.verifying_key().to_bytes()); |
| 283 | let manifest = br#"{"version":"0.4.0","files":{}}"#; |
| 284 | let signature = STANDARD.encode(signing.sign(manifest).to_bytes()); |
| 285 | assert!(verify(manifest, &signature, &key).is_ok()); |
| 286 | assert!(verify(br#"{"version":"9.9.9","files":{}}"#, &signature, &key).is_err()); |
| 287 | let other = STANDARD.encode(ed25519_dalek::SigningKey::from_bytes(&[8u8; 32]).verifying_key().to_bytes()); |
| 288 | assert!(verify(manifest, &signature, &other).is_err()); |
| 289 | assert!(verify(manifest, "not base64", &key).is_err()); |
| 290 | } |
| 291 | |
| 292 | /// Made by scripts/runner-release.mjs, whose signing the CLI's releases use. |
| 293 | #[test] |
| 294 | fn the_release_scripts_signatures_check_out() { |
| 295 | let key = "4wuwkRbieiO9sWq1UBAcGDjAjin4cwJRG2F8LJVyr6U="; |
| 296 | let signature = "aXjDOfxYhm+iHacZwsxMadNxoHX07p62evYNqsXX4UZoDQ7pwWtFnF4jKfiqvAk+AmGVkcE+/uioMR2v+FixCw=="; |
| 297 | assert!(verify(br#"{"version":"0.2.0","files":{}}"#, signature, key).is_ok()); |
| 298 | assert!(verify(br#"{"version":"0.2.1","files":{}}"#, signature, key).is_err()); |
| 299 | } |
| 300 | |
| 301 | #[test] |
| 302 | fn platforms_are_named_as_releases_name_them() { |
| 303 | let name = platform(); |
| 304 | assert!(["linux-x64", "linux-arm64", "macos-arm64", "macos-x64", "windows-x64"].contains(&name.as_str()), "{name}"); |
| 305 | // The manifest the site publishes reads. |
| 306 | let manifest: Manifest = |
| 307 | serde_json::from_str(r#"{"version":"0.3.3","published_at":"2026-10-14T13:22:39.222Z","files":{"windows-x64":{"name":"g1t-windows-x64.exe","sha256":"61"}}}"#) |
| 308 | .unwrap(); |
| 309 | assert_eq!(manifest.files["windows-x64"].name, "g1t-windows-x64.exe"); |
| 310 | } |
| 311 | } |