Skip to main content

ninox_core/lifecycle/
update_check.rs

1//! Self-update: checks the private Cargo registry ninox is already
2//! distributed through for a newer version, and (on user action) applies it
3//! via `cargo install --force`.
4//!
5//! ## Why the Cargo registry, not GitHub Releases
6//!
7//! Two distribution channels exist (see the repo's `.github/workflows/`):
8//! `github.com/Made-by-Moonlight/ninox` tags commits but its `release.yml`
9//! workflow is disabled and publishes no binaries/bundles anywhere — there
10//! is nothing there to check against or download. The private mirror
11//! (`Synthesia-Technologies/ninox`) publishes every version bump to a
12//! private Cargo registry (`synthesia-cargo`, on AWS CodeArtifact) via
13//! `publish-codeartifact.yml`, and `cargo install ninox` against that
14//! registry is already the primary install path (the registry is the
15//! default via `~/.cargo/config.toml` source replacement — see
16//! `.github/workflows/publish-codeartifact.yml`'s header comment for the
17//! exact setup). That registry's sparse index is therefore both the
18//! authoritative "what's the latest version" source AND requires no new
19//! infrastructure: this module reads the same `~/.cargo/config.toml` cargo
20//! itself uses and mints tokens via the same
21//! `cargo:token-from-stdout` credential provider CI configures there.
22//!
23//! If ninox ever starts publishing binaries to GitHub Releases, swap
24//! `CargoRegistryUpdateSource` for a GitHub-releases-backed `UpdateSource` —
25//! nothing else in this module (or its callers) needs to change.
26
27use anyhow::{Context, Result};
28use async_trait::async_trait;
29use serde::Deserialize;
30use std::path::{Path, PathBuf};
31
32/// A resolved Cargo registry: where its sparse index lives, and how to
33/// authenticate against it (`None` for a registry that needs no auth).
34#[derive(Debug, Clone, PartialEq)]
35pub struct RegistrySource {
36    /// Sparse index base URL, `sparse+` prefix already stripped, always
37    /// ending in `/`.
38    pub index_url: String,
39    pub credential_provider: Option<String>,
40}
41
42/// Reads `{cargo_home}/config.toml` (falling back to the extensionless
43/// `config`, cargo's older name for the same file) and follows
44/// `[source.crates-io] replace-with` to the registry ninox is actually
45/// installed from. Returns `None` — not an error — whenever any piece of
46/// that chain is missing: no config file, no source replacement, or a
47/// referenced registry with no `index`. Any of those just means "this
48/// machine isn't set up to resolve ninox's registry", and the caller should
49/// skip the check rather than fail loudly.
50pub fn resolve_registry_source(cargo_home: &Path) -> Option<RegistrySource> {
51    let text = std::fs::read_to_string(cargo_home.join("config.toml"))
52        .or_else(|_| std::fs::read_to_string(cargo_home.join("config")))
53        .ok()?;
54    let value: toml::Value = text.parse().ok()?;
55
56    let registry_name = value
57        .get("source")?
58        .get("crates-io")?
59        .get("replace-with")?
60        .as_str()?;
61    let registry = value.get("registries")?.get(registry_name)?;
62    let index = registry.get("index")?.as_str()?;
63    // Only the sparse HTTP protocol is supported — a git-based index (no
64    // `sparse+` prefix) would need a full git checkout to read, which this
65    // module doesn't do. Treat it the same as "nothing configured" rather
66    // than sending a bogus HTTP request to a git URL.
67    let index_url = format!("{}/", index.strip_prefix("sparse+")?.trim_end_matches('/'));
68    let credential_provider = registry
69        .get("credential-provider")
70        .and_then(|v| v.as_str())
71        .map(str::to_string);
72
73    Some(RegistrySource { index_url, credential_provider })
74}
75
76/// Cargo's sparse-index path scheme for a package name: `>=4` chars uses
77/// the first two / next two / full-name layout; shorter names get their own
78/// documented special cases. Names are lowercased — the index is
79/// case-insensitive but stored lowercase.
80pub fn sparse_index_path(name: &str) -> String {
81    let name = name.to_lowercase();
82    match name.len() {
83        1 => format!("1/{name}"),
84        2 => format!("2/{name}"),
85        3 => format!("3/{}/{name}", &name[..1]),
86        _ => format!("{}/{}/{name}", &name[..2], &name[2..4]),
87    }
88}
89
90#[derive(Deserialize)]
91struct IndexEntry {
92    vers: String,
93    #[serde(default)]
94    yanked: bool,
95}
96
97/// Parses a sparse-index response body (newline-delimited JSON, one line
98/// per published version) and returns the highest non-yanked semver
99/// version, if any. Malformed lines are skipped rather than failing the
100/// whole parse — the index is append-only and any one line being odd
101/// shouldn't blind the check to every other (valid) line.
102pub fn parse_latest_version(body: &str) -> Option<semver::Version> {
103    body.lines()
104        .filter_map(|line| serde_json::from_str::<IndexEntry>(line).ok())
105        .filter(|entry| !entry.yanked)
106        .filter_map(|entry| semver::Version::parse(&entry.vers).ok())
107        .max()
108}
109
110/// Whether `latest` is newer than `current_version`. `current_version`
111/// failing to parse (should never happen — it's `env!("CARGO_PKG_VERSION")`
112/// — but a corrupted build could do it) is treated as "no update", not an
113/// error: better to silently skip a notification than nag on bad data.
114pub fn is_newer(current_version: &str, latest: &semver::Version) -> bool {
115    match semver::Version::parse(current_version) {
116        Ok(current) => *latest > current,
117        Err(e) => {
118            tracing::warn!("update check: couldn't parse current version {current_version:?}: {e}");
119            false
120        }
121    }
122}
123
124/// Runs a credential-provider command and returns its stdout as the bearer
125/// token, mirroring cargo's own `cargo:token-from-stdout` protocol (the
126/// only provider scheme this supports — see the module doc for why that's
127/// the one actually configured). Splits the command on whitespace, which
128/// is fine for the documented AWS CLI invocation but wouldn't survive a
129/// quoted argument; not needed for what's actually configured today.
130async fn mint_token(credential_provider: &str) -> Result<Option<String>> {
131    let Some(rest) = credential_provider.strip_prefix("cargo:token-from-stdout ") else {
132        tracing::warn!("update check: unsupported credential-provider scheme: {credential_provider}");
133        return Ok(None);
134    };
135    let mut parts = rest.split_whitespace();
136    let program = parts.next().context("empty credential-provider command")?;
137    let output = tokio::process::Command::new(program)
138        .args(parts)
139        .output()
140        .await
141        .with_context(|| format!("running credential-provider `{credential_provider}`"))?;
142    if !output.status.success() {
143        anyhow::bail!(
144            "credential-provider exited with {}: {}",
145            output.status,
146            String::from_utf8_lossy(&output.stderr).trim(),
147        );
148    }
149    Ok(Some(String::from_utf8_lossy(&output.stdout).trim().to_string()))
150}
151
152/// Source of "what's the latest published version of `package`" — real
153/// production code uses [`CargoRegistryUpdateSource`]; tests inject a fake
154/// so they never make a network call.
155#[async_trait]
156pub trait UpdateSource: Send + Sync {
157    async fn latest_version(&self, package: &str) -> Result<Option<semver::Version>>;
158}
159
160/// Queries the Cargo registry resolved from `~/.cargo/config.toml` (or
161/// `$CARGO_HOME/config.toml`) — see the module doc for why this, not
162/// GitHub Releases. `Ok(None)` (not an error) when this machine has no
163/// registry source-replacement configured at all.
164pub struct CargoRegistryUpdateSource;
165
166fn cargo_home() -> PathBuf {
167    std::env::var_os("CARGO_HOME")
168        .map(PathBuf::from)
169        .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
170        .unwrap_or_else(|| PathBuf::from(".cargo"))
171}
172
173#[async_trait]
174impl UpdateSource for CargoRegistryUpdateSource {
175    async fn latest_version(&self, package: &str) -> Result<Option<semver::Version>> {
176        let Some(source) = resolve_registry_source(&cargo_home()) else {
177            return Ok(None);
178        };
179        let token = match &source.credential_provider {
180            Some(provider) => mint_token(provider).await?,
181            None => None,
182        };
183
184        let url = format!("{}{}", source.index_url, sparse_index_path(package));
185        let mut request = reqwest::Client::new().get(&url);
186        if let Some(token) = token {
187            // Raw token, no "Bearer " prefix — CodeArtifact's cargo data
188            // plane rejects a prefixed token with 401.
189            request = request.header(reqwest::header::AUTHORIZATION, token);
190        }
191        let response = request.send().await.context("fetching sparse index")?;
192        if !response.status().is_success() {
193            anyhow::bail!("sparse index request for {package} failed: {}", response.status());
194        }
195        let body = response.text().await.context("reading sparse index response")?;
196        Ok(parse_latest_version(&body))
197    }
198}
199
200/// Checks `source` for a newer version of `package` than `current_version`.
201/// Returns `Ok(None)` both when there's nothing to check against (no
202/// registry configured) and when the latest published version isn't newer
203/// than what's already running.
204pub async fn check_for_update(
205    source: &dyn UpdateSource,
206    package: &str,
207    current_version: &str,
208) -> Result<Option<semver::Version>> {
209    let Some(latest) = source.latest_version(package).await? else {
210        return Ok(None);
211    };
212    Ok(is_newer(current_version, &latest).then_some(latest))
213}
214
215/// Applies an update — production code uses [`CargoInstallInstaller`];
216/// tests inject a fake so they never spawn a real `cargo install`.
217#[async_trait]
218pub trait UpdateInstaller: Send + Sync {
219    async fn install(&self, package: &str) -> Result<()>;
220}
221
222/// Re-runs `cargo install <package> --force --locked` — the simplest
223/// reliable update path given the app is already distributed via `cargo
224/// install`. `--locked` matches the already-published `Cargo.lock` exactly
225/// rather than re-resolving dependencies; `--force` overwrites the existing
226/// `~/.cargo/bin/<package>` binary.
227pub struct CargoInstallInstaller;
228
229#[async_trait]
230impl UpdateInstaller for CargoInstallInstaller {
231    async fn install(&self, package: &str) -> Result<()> {
232        let output = tokio::process::Command::new("cargo")
233            .args(["install", package, "--force", "--locked"])
234            .output()
235            .await
236            .context("spawning cargo install")?;
237        if !output.status.success() {
238            anyhow::bail!(
239                "cargo install {package} exited with {}: {}",
240                output.status,
241                String::from_utf8_lossy(&output.stderr).trim(),
242            );
243        }
244        Ok(())
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    fn write_cargo_config(dir: &Path, contents: &str) {
253        std::fs::write(dir.join("config.toml"), contents).unwrap();
254    }
255
256    #[test]
257    fn resolve_registry_source_reads_replace_with_chain() {
258        let dir = tempfile::tempdir().unwrap();
259        write_cargo_config(dir.path(), r#"
260[registries.synthesia-cargo]
261index = "sparse+https://example.com/cargo/synthesia-cargo/"
262credential-provider = "cargo:token-from-stdout aws codeartifact get-authorization-token --domain d --domain-owner o --region r --query authorizationToken --output text"
263
264[source.crates-io]
265replace-with = "synthesia-cargo"
266"#);
267        let source = resolve_registry_source(dir.path()).expect("must resolve");
268        assert_eq!(source.index_url, "https://example.com/cargo/synthesia-cargo/");
269        assert_eq!(
270            source.credential_provider.as_deref(),
271            Some("cargo:token-from-stdout aws codeartifact get-authorization-token --domain d --domain-owner o --region r --query authorizationToken --output text"),
272        );
273    }
274
275    #[test]
276    fn resolve_registry_source_none_without_replace_with() {
277        let dir = tempfile::tempdir().unwrap();
278        write_cargo_config(dir.path(), r#"
279[registries.synthesia-cargo]
280index = "sparse+https://example.com/cargo/synthesia-cargo/"
281"#);
282        assert!(resolve_registry_source(dir.path()).is_none());
283    }
284
285    #[test]
286    fn resolve_registry_source_none_when_config_missing() {
287        let dir = tempfile::tempdir().unwrap();
288        assert!(resolve_registry_source(dir.path()).is_none());
289    }
290
291    #[test]
292    fn resolve_registry_source_none_when_registry_has_no_index() {
293        let dir = tempfile::tempdir().unwrap();
294        write_cargo_config(dir.path(), r#"
295[registries.synthesia-cargo]
296
297[source.crates-io]
298replace-with = "synthesia-cargo"
299"#);
300        assert!(resolve_registry_source(dir.path()).is_none());
301    }
302
303    #[test]
304    fn resolve_registry_source_none_for_a_non_sparse_index() {
305        let dir = tempfile::tempdir().unwrap();
306        write_cargo_config(dir.path(), r#"
307[registries.git-registry]
308index = "https://example.com/git-index.git"
309
310[source.crates-io]
311replace-with = "git-registry"
312"#);
313        assert!(resolve_registry_source(dir.path()).is_none());
314    }
315
316    #[test]
317    fn resolve_registry_source_credential_provider_optional() {
318        let dir = tempfile::tempdir().unwrap();
319        write_cargo_config(dir.path(), r#"
320[registries.public-mirror]
321index = "sparse+https://example.com/cargo/public-mirror/"
322
323[source.crates-io]
324replace-with = "public-mirror"
325"#);
326        let source = resolve_registry_source(dir.path()).expect("must resolve");
327        assert_eq!(source.credential_provider, None);
328    }
329
330    #[test]
331    fn sparse_index_path_matches_cargo_scheme() {
332        assert_eq!(sparse_index_path("a"), "1/a");
333        assert_eq!(sparse_index_path("ab"), "2/ab");
334        assert_eq!(sparse_index_path("abc"), "3/a/abc");
335        assert_eq!(sparse_index_path("ninox"), "ni/no/ninox");
336        assert_eq!(sparse_index_path("Ninox"), "ni/no/ninox");
337    }
338
339    #[test]
340    fn parse_latest_version_picks_highest_unyanked_semver() {
341        let body = "\
342{\"vers\":\"0.9.0\",\"yanked\":false}
343{\"vers\":\"0.13.0\",\"yanked\":false}
344{\"vers\":\"0.14.0\",\"yanked\":true}
345{\"vers\":\"0.10.0\",\"yanked\":false}
346";
347        assert_eq!(parse_latest_version(body), Some(semver::Version::new(0, 13, 0)));
348    }
349
350    #[test]
351    fn parse_latest_version_skips_malformed_lines() {
352        let body = "not json\n{\"vers\":\"1.0.0\",\"yanked\":false}\n{\"vers\":\"not-semver\",\"yanked\":false}\n";
353        assert_eq!(parse_latest_version(body), Some(semver::Version::new(1, 0, 0)));
354    }
355
356    #[test]
357    fn parse_latest_version_none_when_everything_yanked_or_empty() {
358        assert_eq!(parse_latest_version(""), None);
359        assert_eq!(parse_latest_version("{\"vers\":\"1.0.0\",\"yanked\":true}\n"), None);
360    }
361
362    #[test]
363    fn is_newer_true_when_latest_greater() {
364        assert!(is_newer("0.13.0", &semver::Version::new(0, 14, 0)));
365    }
366
367    #[test]
368    fn is_newer_false_when_equal_or_behind() {
369        assert!(!is_newer("0.13.0", &semver::Version::new(0, 13, 0)));
370        assert!(!is_newer("0.14.0", &semver::Version::new(0, 13, 0)));
371    }
372
373    #[test]
374    fn is_newer_false_when_current_unparsable() {
375        assert!(!is_newer("not-a-version", &semver::Version::new(0, 13, 0)));
376    }
377
378    struct FakeSource(Option<semver::Version>);
379
380    #[async_trait]
381    impl UpdateSource for FakeSource {
382        async fn latest_version(&self, _package: &str) -> Result<Option<semver::Version>> {
383            Ok(self.0.clone())
384        }
385    }
386
387    #[tokio::test]
388    async fn check_for_update_returns_newer_version() {
389        let source = FakeSource(Some(semver::Version::new(0, 14, 0)));
390        let result = check_for_update(&source, "ninox", "0.13.0").await.unwrap();
391        assert_eq!(result, Some(semver::Version::new(0, 14, 0)));
392    }
393
394    #[tokio::test]
395    async fn check_for_update_none_when_already_current() {
396        let source = FakeSource(Some(semver::Version::new(0, 13, 0)));
397        let result = check_for_update(&source, "ninox", "0.13.0").await.unwrap();
398        assert_eq!(result, None);
399    }
400
401    #[tokio::test]
402    async fn check_for_update_none_when_source_has_nothing() {
403        let source = FakeSource(None);
404        let result = check_for_update(&source, "ninox", "0.13.0").await.unwrap();
405        assert_eq!(result, None);
406    }
407}