Skip to main content

ai_usagebar/
update.rs

1//! Self-update support for the Windows tray: release discovery, asset
2//! selection, checksum verification, and the in-place binary swap.
3//!
4//! Linux installs get new versions from the AUR, Nix, or cargo-binstall; a
5//! Windows install is a zip the user unpacked by hand, so nothing would ever
6//! tell it a newer release exists. The tray host polls GitHub's *latest*
7//! release once per [`CHECK_INTERVAL`], downloads the per-binary `.exe`
8//! assets the release workflow publishes, checks each against its `.sha256`
9//! sidecar, and swaps the verified files into the install directory.
10//!
11//! Everything in this module is pure or takes an explicit path, so the whole
12//! flow is unit-tested against a temp directory and never touches the network
13//! or a real install. The tray host owns the HTTP calls and the UI; this
14//! module owns every decision the host must not get wrong twice:
15//!
16//! - [`parse_release`] / [`is_newer`]: a draft or prerelease is never an
17//!   update, and only a strict `X.Y.Z` compares — "newer" must never be a
18//!   string comparison, or `1.9.10` loses to `1.10.0`.
19//! - [`select_downloads`]: the tray binary is the one doing the updating, so
20//!   it is mandatory; the CLI and TUI are nice-to-have and a release that
21//!   ships without one of them still updates the tray.
22//! - [`verify_sha256`]: nothing lands in the install directory unverified.
23//! - [`stage_swap`] / [`sweep_old`]: Windows refuses to overwrite a running
24//!   executable but happily lets it be *renamed*, so the live exe becomes
25//!   `<name>.old`, the staged file takes its place, and the next start
26//!   sweeps the `.old` files once no process holds them any more.
27
28use std::fs;
29use std::io::Read;
30use std::path::{Path, PathBuf};
31use std::time::Duration;
32
33use serde::{Deserialize, Serialize};
34use sha2::{Digest, Sha256};
35
36use crate::error::Result;
37
38/// The repository the running binary was built from, as `Cargo.toml`'s
39/// `repository` field says. A fork that builds its own tray therefore
40/// updates from its own releases without touching code.
41pub const SOURCE_REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
42
43/// GitHub's "latest" is the newest non-draft, non-prerelease release, which
44/// is exactly the set the tray may install. Polled once per interval.
45pub fn latest_release_url() -> Option<String> {
46    latest_release_url_for(SOURCE_REPOSITORY)
47}
48
49/// `https://github.com/<owner>/<name>[.git][/]` → the releases/latest API URL.
50/// Anything that is not a GitHub repository yields `None`, and the tray
51/// reports that it cannot check rather than asking a random host.
52pub fn latest_release_url_for(repository: &str) -> Option<String> {
53    let path = repository
54        .trim()
55        .strip_prefix("https://github.com/")?
56        .trim_end_matches('/')
57        .trim_end_matches(".git");
58    let (owner, name) = path.split_once('/')?;
59    let valid = |s: &str| {
60        !s.is_empty()
61            && s.len() <= 100
62            && s.chars()
63                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
64    };
65    if !valid(owner) || !valid(name) || name.contains('/') {
66        return None;
67    }
68    Some(format!(
69        "https://api.github.com/repos/{owner}/{name}/releases/latest"
70    ))
71}
72
73/// Once an hour: releases are rare, and the unauthenticated GitHub API
74/// allows sixty requests an hour per IP — one of them is ours.
75pub const CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60);
76
77/// A release exe is a few MiB. Anything reporting more than this is not one
78/// of ours and is refused before a single byte is downloaded.
79pub const MAX_ASSET_BYTES: u64 = 50 * 1024 * 1024;
80
81/// The three Windows binaries, tray first: it is the one that must update
82/// (it runs the updater), the other two are optional extras.
83pub const BINARIES: [&str; 3] = ["ai-usagebar-tray", "ai-usagebar", "ai-usagebar-tui"];
84
85/// Longest `html_url` or asset name accepted from the release JSON. Real
86/// values are well under a hundred characters; anything longer is not
87/// something we want to log, display, or join into a path.
88const MAX_FIELD_LEN: usize = 512;
89
90/// One downloadable file of a release. `url` is the `browser_download_url`.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct Asset {
93    pub name: String,
94    pub size: u64,
95    pub url: String,
96}
97
98/// The parts of a GitHub release the updater acts on. `version` is bare
99/// (`1.11.0`, never `v1.11.0`) so it compares with `CARGO_PKG_VERSION`.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct Release {
102    pub assets: Vec<Asset>,
103    pub html_url: String,
104    pub version: String,
105}
106
107/// The exe/sidecar pair for one binary, as found in a release.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct Download {
110    pub binary: &'static str,
111    pub exe: Asset,
112    pub sha256: Asset,
113}
114
115/// Wire shape of `GET /releases/latest` — only the fields we read. Every
116/// field but `tag_name` defaults so a schema drift in something we ignore
117/// cannot break the update check.
118#[derive(Deserialize)]
119struct WireRelease {
120    #[serde(default)]
121    assets: Vec<WireAsset>,
122    #[serde(default)]
123    draft: bool,
124    #[serde(default)]
125    html_url: String,
126    #[serde(default)]
127    prerelease: bool,
128    tag_name: Option<String>,
129}
130
131#[derive(Deserialize)]
132struct WireAsset {
133    #[serde(default)]
134    browser_download_url: String,
135    #[serde(default)]
136    name: String,
137    #[serde(default)]
138    size: u64,
139}
140
141/// Parse a release response into what the updater needs.
142///
143/// A draft or prerelease is an error rather than "not newer": the tray shows
144/// the reason, and a silent skip would hide a mis-tagged release forever.
145/// Assets are filtered, not rejected wholesale — one odd file in a release
146/// must not block the update — but an asset whose name could escape the
147/// staging directory (a separator, `..`) is dropped, as is one served from
148/// anywhere but HTTPS. `html_url` is the "open release notes" link, which
149/// only ever points at GitHub; anything else becomes an empty string so the
150/// tray simply shows no link.
151pub fn parse_release(json: &str) -> std::result::Result<Release, String> {
152    let wire: WireRelease =
153        serde_json::from_str(json).map_err(|error| format!("release JSON: {error}"))?;
154    if wire.draft || wire.prerelease {
155        return Err("prerelease/draft release".to_string());
156    }
157    let tag = wire
158        .tag_name
159        .ok_or_else(|| "release JSON has no tag_name".to_string())?;
160    let version = tag.strip_prefix('v').unwrap_or(&tag);
161    if parse_version(version).is_none() {
162        return Err(format!("release tag {tag:?} is not vX.Y.Z"));
163    }
164    let html_url = if wire.html_url.starts_with("https://github.com/")
165        && wire.html_url.len() <= MAX_FIELD_LEN
166    {
167        wire.html_url
168    } else {
169        String::new()
170    };
171    let assets = wire
172        .assets
173        .into_iter()
174        .filter(|asset| asset_name_is_safe(&asset.name))
175        .filter(|asset| {
176            asset.browser_download_url.starts_with("https://")
177                && asset.browser_download_url.len() <= MAX_FIELD_LEN
178        })
179        .map(|asset| Asset {
180            name: asset.name,
181            size: asset.size,
182            url: asset.browser_download_url,
183        })
184        .collect();
185    Ok(Release {
186        assets,
187        html_url,
188        version: version.to_string(),
189    })
190}
191
192/// A name is joined onto the staging and install directories verbatim, so it
193/// must be a single plain component.
194fn asset_name_is_safe(name: &str) -> bool {
195    !name.is_empty()
196        && name.len() <= MAX_FIELD_LEN
197        && !name.contains(['/', '\\'])
198        && !name.contains("..")
199        && name != "."
200}
201
202/// Strict `X.Y.Z` with numeric components. Pre-release suffixes, build
203/// metadata, and two-part versions are all "not a version" here: the release
204/// workflow only ever tags `vX.Y.Z`, so anything else is not one of ours.
205fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
206    let mut parts = text.split('.');
207    let major = parts.next()?.parse().ok()?;
208    let minor = parts.next()?.parse().ok()?;
209    let patch = parts.next()?.parse().ok()?;
210    if parts.next().is_some() {
211        return None;
212    }
213    Some((major, minor, patch))
214}
215
216/// Numeric semver comparison. A malformed side is `false`: an update the
217/// tray cannot reason about is not an update it should offer.
218pub fn is_newer(current: &str, candidate: &str) -> bool {
219    match (parse_version(current), parse_version(candidate)) {
220        (Some(current), Some(candidate)) => candidate > current,
221        _ => false,
222    }
223}
224
225/// The architecture suffix the release workflow uses in asset names.
226/// `"unknown"` selects nothing and the tray reports that instead of
227/// installing the wrong binary.
228pub fn current_arch() -> &'static str {
229    if cfg!(target_arch = "x86_64") {
230        "x86_64"
231    } else if cfg!(target_arch = "aarch64") {
232        "aarch64"
233    } else {
234        "unknown"
235    }
236}
237
238/// `{binary}-windows-{arch}.exe` — the bare-exe asset naming in
239/// `.github/workflows/release.yml`. Its sidecar is this plus `.sha256`.
240pub fn asset_name(binary: &str, arch: &str) -> String {
241    format!("{binary}-windows-{arch}.exe")
242}
243
244/// Pair each binary with its exe and sidecar, tray first.
245///
246/// The tray pair is required: a release without it cannot update the thing
247/// that is updating, so the whole check fails loudly. The CLI and TUI pairs
248/// are skipped when either half is missing — a partial release still updates
249/// the tray. A zero-byte or oversized exe is an error for every binary: that
250/// is a broken release, not an optional one, and installing a subset would
251/// leave a version mix on disk.
252pub fn select_downloads(
253    release: &Release,
254    arch: &str,
255) -> std::result::Result<Vec<Download>, String> {
256    let find = |name: &str| release.assets.iter().find(|asset| asset.name == name);
257    let mut downloads = Vec::with_capacity(BINARIES.len());
258    for binary in BINARIES {
259        let required = binary == BINARIES[0];
260        let exe_name = asset_name(binary, arch);
261        let sidecar_name = format!("{exe_name}.sha256");
262        let (exe, sha256) = match (find(&exe_name), find(&sidecar_name)) {
263            (Some(exe), Some(sha256)) => (exe, sha256),
264            _ if required => {
265                return Err(format!(
266                    "release {} has no {exe_name} + {sidecar_name} pair",
267                    release.version
268                ));
269            }
270            _ => continue,
271        };
272        if exe.size == 0 {
273            return Err(format!("{exe_name} is empty"));
274        }
275        if exe.size > MAX_ASSET_BYTES {
276            return Err(format!(
277                "{exe_name} is {} bytes, over the {MAX_ASSET_BYTES}-byte limit",
278                exe.size
279            ));
280        }
281        downloads.push(Download {
282            binary,
283            exe: exe.clone(),
284            sha256: sha256.clone(),
285        });
286    }
287    Ok(downloads)
288}
289
290/// The sidecar the release workflow writes is `"<hex>  <name>"` (the
291/// `sha256sum` format, so `sha256sum -c` works on Linux too); accept a bare
292/// digest as well. Folded to lowercase so callers compare bytes.
293pub fn parse_sha256_sidecar(text: &str) -> std::result::Result<String, String> {
294    let digest = text
295        .split_whitespace()
296        .next()
297        .ok_or_else(|| "empty sha256 sidecar".to_string())?;
298    if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
299        return Err(format!("sha256 sidecar is not a 64-hex digest: {digest:?}"));
300    }
301    Ok(digest.to_ascii_lowercase())
302}
303
304/// Stream the file through SHA-256 and compare with `expected_hex`
305/// (case-insensitive). Streamed rather than read whole: the buffer is a
306/// fixed 64 KiB whatever the download turned out to be.
307pub fn verify_sha256(path: &Path, expected_hex: &str) -> std::result::Result<(), String> {
308    let mut file =
309        fs::File::open(path).map_err(|error| format!("open {}: {error}", path.display()))?;
310    let mut hasher = Sha256::new();
311    let mut buffer = [0u8; 64 * 1024];
312    loop {
313        let read = file
314            .read(&mut buffer)
315            .map_err(|error| format!("read {}: {error}", path.display()))?;
316        if read == 0 {
317            break;
318        }
319        hasher.update(&buffer[..read]);
320    }
321    let actual = hex_lower(&hasher.finalize());
322    if actual == expected_hex.to_ascii_lowercase() {
323        Ok(())
324    } else {
325        Err(format!(
326            "sha256 mismatch for {}: expected {expected_hex}, got {actual}",
327            path.display()
328        ))
329    }
330}
331
332fn hex_lower(bytes: &[u8]) -> String {
333    use std::fmt::Write;
334    let mut text = String::with_capacity(bytes.len() * 2);
335    for byte in bytes {
336        let _ = write!(text, "{byte:02x}");
337    }
338    text
339}
340
341/// One entry [`stage_swap`] has completed, kept so a later failure can undo it.
342struct Swapped {
343    dest: PathBuf,
344    old: Option<PathBuf>,
345    source: PathBuf,
346}
347
348/// Move verified staged files into `install_dir`, one `(file name, staged
349/// path)` per binary, returning the paths written.
350///
351/// Every staged path is checked up front so a missing download fails before
352/// anything on disk moves. Per entry: an existing `install_dir/<name>` is
353/// renamed to `<name>.old` (a stale `.old` from an earlier update is removed
354/// first) and the staged file is renamed into place — copy + remove when the
355/// staging directory sits on another volume and `rename` refuses. On any
356/// failure the entries already swapped are rolled back best-effort: the new
357/// file goes back to its staged path and the `.old` returns to its name, so
358/// the install never ends up half of one version and half of another.
359pub fn stage_swap(
360    install_dir: &Path,
361    staged: &[(String, PathBuf)],
362) -> std::result::Result<Vec<PathBuf>, String> {
363    for (name, source) in staged {
364        if !asset_name_is_safe(name) {
365            return Err(format!("refusing to install a file named {name:?}"));
366        }
367        if !source.is_file() {
368            return Err(format!(
369                "staged file {} for {name} does not exist",
370                source.display()
371            ));
372        }
373    }
374    let mut done: Vec<Swapped> = Vec::with_capacity(staged.len());
375    for (name, source) in staged {
376        match swap_one(install_dir, name, source) {
377            Ok(swapped) => done.push(swapped),
378            Err(error) => {
379                rollback(&done);
380                return Err(error);
381            }
382        }
383    }
384    Ok(done.into_iter().map(|swapped| swapped.dest).collect())
385}
386
387fn swap_one(install_dir: &Path, name: &str, source: &Path) -> std::result::Result<Swapped, String> {
388    let dest = install_dir.join(name);
389    let old_path = install_dir.join(format!("{name}.old"));
390    let mut old = None;
391    if dest.exists() {
392        if old_path.exists() {
393            fs::remove_file(&old_path)
394                .map_err(|error| format!("remove stale {}: {error}", old_path.display()))?;
395        }
396        fs::rename(&dest, &old_path).map_err(|error| {
397            format!(
398                "rename {} to {}: {error}",
399                dest.display(),
400                old_path.display()
401            )
402        })?;
403        old = Some(old_path);
404    }
405    if let Err(error) = move_file(source, &dest) {
406        let _ = fs::remove_file(&dest);
407        if let Some(old) = &old {
408            let _ = fs::rename(old, &dest);
409        }
410        return Err(error);
411    }
412    Ok(Swapped {
413        dest,
414        old,
415        source: source.to_path_buf(),
416    })
417}
418
419/// `rename`, falling back to copy + remove for a cross-volume move. A
420/// half-written copy is removed so the destination is never a truncated exe.
421fn move_file(from: &Path, to: &Path) -> std::result::Result<(), String> {
422    let Err(rename_error) = fs::rename(from, to) else {
423        return Ok(());
424    };
425    if let Err(copy_error) = fs::copy(from, to) {
426        let _ = fs::remove_file(to);
427        return Err(format!(
428            "move {} to {}: rename failed ({rename_error}), copy failed ({copy_error})",
429            from.display(),
430            to.display()
431        ));
432    }
433    let _ = fs::remove_file(from);
434    Ok(())
435}
436
437fn rollback(done: &[Swapped]) {
438    for swapped in done.iter().rev() {
439        if move_file(&swapped.dest, &swapped.source).is_err() {
440            let _ = fs::remove_file(&swapped.dest);
441        }
442        if let Some(old) = &swapped.old {
443            let _ = fs::rename(old, &swapped.dest);
444        }
445    }
446}
447
448/// Delete the `*.exe.old` files a previous [`stage_swap`] left behind and
449/// return how many went. A file still held by a process that has not exited
450/// yet stays for the next sweep; nothing else in the directory is touched.
451pub fn sweep_old(install_dir: &Path) -> usize {
452    let Ok(entries) = fs::read_dir(install_dir) else {
453        return 0;
454    };
455    entries
456        .flatten()
457        .filter(|entry| entry.file_name().to_string_lossy().ends_with(".exe.old"))
458        .filter(|entry| entry.path().is_file())
459        .filter(|entry| fs::remove_file(entry.path()).is_ok())
460        .count()
461}
462
463/// What the tray remembers between checks: when it last asked GitHub, so a
464/// restart does not re-poll, and which version the user dismissed, so the
465/// same release is not offered again until a newer one appears.
466#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
467pub struct UpdateState {
468    #[serde(default)]
469    pub last_check_ms: i64,
470    #[serde(default)]
471    pub snoozed_version: Option<String>,
472}
473
474impl UpdateState {
475    /// Missing or unreadable state is the default: the worst case is one
476    /// extra poll and one re-shown prompt, which is the safe direction for a
477    /// corrupt sidecar.
478    pub fn load_at(path: &Path) -> UpdateState {
479        fs::read(path)
480            .ok()
481            .and_then(|bytes| serde_json::from_slice(&bytes).ok())
482            .unwrap_or_default()
483    }
484
485    /// Atomic write (tempfile + rename), creating the parent directory.
486    pub fn save_at(&self, path: &Path) -> Result<()> {
487        let bytes = serde_json::to_vec_pretty(self)?;
488        crate::cache::atomic_write(path, &bytes)
489    }
490}
491
492/// `<cache dir>/ai-usagebar/update.json` — beside the vendor caches and
493/// `detect.json`, because it is derived state that is safe to delete.
494pub fn default_state_path() -> Result<PathBuf> {
495    Ok(crate::cache::xdg_cache_dir()?
496        .join("ai-usagebar")
497        .join("update.json"))
498}
499
500/// Where downloads for one version are staged: `<cache_root>/updates/<version>`.
501/// Per version, so an interrupted download of one release never mixes with
502/// the next, and the whole directory can be removed after a swap.
503pub fn staging_dir(cache_root: &Path, version: &str) -> PathBuf {
504    cache_root.join("updates").join(version)
505}
506
507#[cfg(test)]
508mod tests {
509    #[test]
510    fn latest_release_url_follows_the_cargo_repository_field() {
511        assert_eq!(
512            super::latest_release_url_for("https://github.com/akitaonrails/ai-usagebar"),
513            Some("https://api.github.com/repos/akitaonrails/ai-usagebar/releases/latest".into())
514        );
515        assert_eq!(
516            super::latest_release_url_for("https://github.com/djalmajr/ai-usagebar.git/"),
517            Some("https://api.github.com/repos/djalmajr/ai-usagebar/releases/latest".into())
518        );
519        assert_eq!(
520            super::latest_release_url_for("https://gitlab.com/x/y"),
521            None
522        );
523        assert_eq!(
524            super::latest_release_url_for("https://github.com/only-owner"),
525            None
526        );
527        assert_eq!(
528            super::latest_release_url_for("https://github.com/o/n/extra"),
529            None
530        );
531        assert_eq!(
532            super::latest_release_url_for("https://github.com/o/n%20e"),
533            None
534        );
535        // The build we are in points somewhere valid.
536        assert!(
537            super::latest_release_url().is_some(),
538            "{}",
539            super::SOURCE_REPOSITORY
540        );
541    }
542
543    use super::*;
544    use tempfile::TempDir;
545
546    const RELEASE_FIXTURE: &str = r#"{
547  "url": "https://api.github.com/repos/akitaonrails/ai-usagebar/releases/300000",
548  "html_url": "https://github.com/akitaonrails/ai-usagebar/releases/tag/v1.11.0",
549  "id": 300000,
550  "tag_name": "v1.11.0",
551  "target_commitish": "main",
552  "name": "v1.11.0",
553  "draft": false,
554  "prerelease": false,
555  "created_at": "2026-09-01T12:00:00Z",
556  "published_at": "2026-09-01T12:05:00Z",
557  "assets": [
558    {
559      "name": "ai-usagebar-linux-x86_64.tar.gz",
560      "size": 9000000,
561      "content_type": "application/gzip",
562      "browser_download_url": "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/ai-usagebar-linux-x86_64.tar.gz"
563    },
564    {
565      "name": "ai-usagebar-tray-windows-x86_64.exe",
566      "size": 6100000,
567      "content_type": "application/octet-stream",
568      "browser_download_url": "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/ai-usagebar-tray-windows-x86_64.exe"
569    },
570    {
571      "name": "ai-usagebar-tray-windows-x86_64.exe.sha256",
572      "size": 102,
573      "content_type": "application/octet-stream",
574      "browser_download_url": "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/ai-usagebar-tray-windows-x86_64.exe.sha256"
575    },
576    {
577      "name": "../evil.exe",
578      "size": 10,
579      "browser_download_url": "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/evil.exe"
580    },
581    {
582      "name": "plain-http.exe",
583      "size": 10,
584      "browser_download_url": "http://example.com/plain-http.exe"
585    }
586  ]
587}"#;
588
589    fn asset(name: &str, size: u64) -> Asset {
590        Asset {
591            name: name.to_string(),
592            size,
593            url: format!(
594                "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/{name}"
595            ),
596        }
597    }
598
599    fn pair(binary: &str, size: u64) -> [Asset; 2] {
600        let exe = asset_name(binary, "x86_64");
601        [asset(&exe, size), asset(&format!("{exe}.sha256"), 100)]
602    }
603
604    fn release_with(assets: Vec<Asset>) -> Release {
605        Release {
606            assets,
607            html_url: String::new(),
608            version: "1.11.0".to_string(),
609        }
610    }
611
612    fn full_release() -> Release {
613        release_with(
614            BINARIES
615                .iter()
616                .flat_map(|binary| pair(binary, 5_000_000))
617                .collect(),
618        )
619    }
620
621    #[test]
622    fn parse_release_reads_version_url_and_safe_assets() {
623        let release = parse_release(RELEASE_FIXTURE).unwrap();
624
625        assert_eq!(release.version, "1.11.0");
626        assert_eq!(
627            release.html_url,
628            "https://github.com/akitaonrails/ai-usagebar/releases/tag/v1.11.0"
629        );
630        let names: Vec<&str> = release
631            .assets
632            .iter()
633            .map(|asset| asset.name.as_str())
634            .collect();
635        assert_eq!(
636            names,
637            vec![
638                "ai-usagebar-linux-x86_64.tar.gz",
639                "ai-usagebar-tray-windows-x86_64.exe",
640                "ai-usagebar-tray-windows-x86_64.exe.sha256",
641            ],
642            "the traversal name and the plain-http asset are dropped"
643        );
644        let tray = &release.assets[1];
645        assert_eq!(tray.size, 6_100_000);
646        assert_eq!(
647            tray.url,
648            "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/ai-usagebar-tray-windows-x86_64.exe"
649        );
650    }
651
652    #[test]
653    fn parse_release_accepts_a_bare_tag_and_tolerates_missing_assets() {
654        let release =
655            parse_release(r#"{"tag_name": "1.11.0", "html_url": "https://evil.example/x"}"#)
656                .unwrap();
657
658        assert_eq!(release.version, "1.11.0");
659        assert!(release.assets.is_empty());
660        assert_eq!(release.html_url, "", "a non-GitHub link is blanked");
661    }
662
663    #[test]
664    fn parse_release_rejects_prerelease_draft_and_malformed_tags() {
665        let prerelease = r#"{"tag_name": "v1.11.0", "prerelease": true}"#;
666        assert_eq!(
667            parse_release(prerelease).unwrap_err(),
668            "prerelease/draft release"
669        );
670
671        let draft = r#"{"tag_name": "v1.11.0", "draft": true}"#;
672        assert_eq!(
673            parse_release(draft).unwrap_err(),
674            "prerelease/draft release"
675        );
676
677        for tag in ["v1.11", "v1.11.0-rc1", "nightly", "v1.11.0.1", ""] {
678            let json = format!(r#"{{"tag_name": "{tag}"}}"#);
679            assert!(parse_release(&json).is_err(), "{tag:?} parsed");
680        }
681        assert!(parse_release(r#"{"draft": false}"#).is_err(), "no tag_name");
682        assert!(parse_release("not json").is_err());
683    }
684
685    #[test]
686    fn is_newer_compares_numerically() {
687        assert!(is_newer("1.10.0", "1.11.0"));
688        assert!(!is_newer("1.11.0", "1.11.0"), "equal is not newer");
689        assert!(!is_newer("1.11.0", "1.10.0"), "older is not newer");
690        assert!(is_newer("1.9.10", "1.10.0"), "not a string compare");
691        assert!(is_newer("1.10.0", "2.0.0"));
692        assert!(is_newer("1.10.0", "1.10.1"));
693    }
694
695    #[test]
696    fn is_newer_is_false_for_anything_malformed() {
697        assert!(!is_newer("1.10.0", "v1.11.0"));
698        assert!(!is_newer("1.10.0", "1.11"));
699        assert!(!is_newer("1.10.0", "1.11.0-rc1"));
700        assert!(!is_newer("garbage", "1.11.0"));
701        assert!(!is_newer("", ""));
702    }
703
704    #[test]
705    fn asset_name_follows_the_release_workflow() {
706        assert_eq!(
707            asset_name("ai-usagebar-tray", "x86_64"),
708            "ai-usagebar-tray-windows-x86_64.exe"
709        );
710        assert!(["x86_64", "aarch64", "unknown"].contains(&current_arch()));
711    }
712
713    #[test]
714    fn select_downloads_pairs_all_three_binaries_tray_first() {
715        let downloads = select_downloads(&full_release(), "x86_64").unwrap();
716
717        let binaries: Vec<&str> = downloads.iter().map(|d| d.binary).collect();
718        assert_eq!(binaries, BINARIES.to_vec());
719        for download in &downloads {
720            assert_eq!(download.exe.name, asset_name(download.binary, "x86_64"));
721            assert_eq!(
722                download.sha256.name,
723                format!("{}.sha256", download.exe.name)
724            );
725        }
726    }
727
728    #[test]
729    fn select_downloads_requires_the_tray_pair() {
730        let mut assets: Vec<Asset> = pair("ai-usagebar", 5_000_000).to_vec();
731        assets.extend(pair("ai-usagebar-tui", 5_000_000));
732        let error = select_downloads(&release_with(assets), "x86_64").unwrap_err();
733        assert!(
734            error.contains("ai-usagebar-tray-windows-x86_64.exe"),
735            "{error}"
736        );
737
738        // Exe without its sidecar is just as missing.
739        let [tray_exe, _] = pair("ai-usagebar-tray", 5_000_000);
740        assert!(select_downloads(&release_with(vec![tray_exe]), "x86_64").is_err());
741
742        // Wrong arch: nothing matches.
743        assert!(select_downloads(&full_release(), "aarch64").is_err());
744    }
745
746    #[test]
747    fn select_downloads_skips_an_incomplete_optional_pair() {
748        let mut assets: Vec<Asset> = pair("ai-usagebar-tray", 5_000_000).to_vec();
749        assets.extend(pair("ai-usagebar-tui", 5_000_000));
750        let [cli_exe, _] = pair("ai-usagebar", 5_000_000);
751        assets.push(cli_exe); // sidecar missing → skipped
752
753        let downloads = select_downloads(&release_with(assets), "x86_64").unwrap();
754
755        let binaries: Vec<&str> = downloads.iter().map(|d| d.binary).collect();
756        assert_eq!(binaries, vec!["ai-usagebar-tray", "ai-usagebar-tui"]);
757    }
758
759    #[test]
760    fn select_downloads_rejects_empty_and_oversized_exes() {
761        let mut assets: Vec<Asset> = pair("ai-usagebar-tray", MAX_ASSET_BYTES + 1).to_vec();
762        let error = select_downloads(&release_with(assets.clone()), "x86_64").unwrap_err();
763        assert!(error.contains("over the"), "{error}");
764
765        assets = pair("ai-usagebar-tray", 0).to_vec();
766        let error = select_downloads(&release_with(assets), "x86_64").unwrap_err();
767        assert!(error.contains("empty"), "{error}");
768
769        // An optional exe with a bad size is a broken release, not a skip.
770        let mut assets: Vec<Asset> = pair("ai-usagebar-tray", 5_000_000).to_vec();
771        assets.extend(pair("ai-usagebar-tui", MAX_ASSET_BYTES + 1));
772        assert!(select_downloads(&release_with(assets), "x86_64").is_err());
773
774        // Exactly the limit is still allowed.
775        let assets: Vec<Asset> = pair("ai-usagebar-tray", MAX_ASSET_BYTES).to_vec();
776        assert!(select_downloads(&release_with(assets), "x86_64").is_ok());
777    }
778
779    #[test]
780    fn parse_sha256_sidecar_accepts_both_formats_and_folds_case() {
781        let hex = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
782
783        assert_eq!(
784            parse_sha256_sidecar(&format!("{hex}  ai-usagebar-tray-windows-x86_64.exe\n")).unwrap(),
785            hex
786        );
787        assert_eq!(parse_sha256_sidecar(hex).unwrap(), hex);
788        assert_eq!(parse_sha256_sidecar(&format!("  {hex}\r\n")).unwrap(), hex);
789        assert_eq!(
790            parse_sha256_sidecar(&hex.to_ascii_uppercase()).unwrap(),
791            hex,
792            "uppercase is folded"
793        );
794    }
795
796    #[test]
797    fn parse_sha256_sidecar_rejects_garbage() {
798        assert!(parse_sha256_sidecar("").is_err());
799        assert!(parse_sha256_sidecar("   \n").is_err());
800        assert!(
801            parse_sha256_sidecar("deadbeef  name.exe").is_err(),
802            "too short"
803        );
804        assert!(
805            parse_sha256_sidecar(&"zz".repeat(32)).is_err(),
806            "right length, not hex"
807        );
808        assert!(
809            parse_sha256_sidecar(
810                "name.exe  e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
811            )
812            .is_err(),
813            "name first is not the format"
814        );
815    }
816
817    #[test]
818    fn verify_sha256_streams_the_file_and_detects_mismatch() {
819        let dir = TempDir::new().unwrap();
820        let path = dir.path().join("blob.bin");
821        let contents: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
822        std::fs::write(&path, &contents).unwrap();
823        let expected = hex_lower(&Sha256::digest(&contents));
824
825        assert_eq!(verify_sha256(&path, &expected), Ok(()));
826        assert_eq!(
827            verify_sha256(&path, &expected.to_ascii_uppercase()),
828            Ok(()),
829            "case-insensitive"
830        );
831
832        let wrong = format!("{}{}", &expected[1..], "0");
833        let error = verify_sha256(&path, &wrong).unwrap_err();
834        assert!(error.contains("mismatch"), "{error}");
835
836        assert!(verify_sha256(&dir.path().join("absent"), &expected).is_err());
837    }
838
839    #[test]
840    fn stage_swap_replaces_the_exe_and_keeps_the_old_one() {
841        let dir = TempDir::new().unwrap();
842        let install = dir.path().join("install");
843        let staging = dir.path().join("staging");
844        std::fs::create_dir_all(&install).unwrap();
845        std::fs::create_dir_all(&staging).unwrap();
846        let name = "ai-usagebar-tray.exe";
847        std::fs::write(install.join(name), b"v1").unwrap();
848        let staged = staging.join(name);
849        std::fs::write(&staged, b"v2").unwrap();
850
851        let written = stage_swap(&install, &[(name.to_string(), staged.clone())]).unwrap();
852
853        assert_eq!(written, vec![install.join(name)]);
854        assert_eq!(std::fs::read(install.join(name)).unwrap(), b"v2");
855        assert_eq!(
856            std::fs::read(install.join("ai-usagebar-tray.exe.old")).unwrap(),
857            b"v1"
858        );
859        assert!(!staged.exists(), "the staged file was moved, not copied");
860
861        // A second update replaces the stale `.old` rather than failing on it.
862        std::fs::write(&staged, b"v3").unwrap();
863        stage_swap(&install, &[(name.to_string(), staged.clone())]).unwrap();
864        assert_eq!(std::fs::read(install.join(name)).unwrap(), b"v3");
865        assert_eq!(
866            std::fs::read(install.join("ai-usagebar-tray.exe.old")).unwrap(),
867            b"v2"
868        );
869    }
870
871    #[test]
872    fn stage_swap_installs_a_binary_that_was_not_there_before() {
873        let dir = TempDir::new().unwrap();
874        let install = dir.path().join("install");
875        std::fs::create_dir_all(&install).unwrap();
876        let staged = dir.path().join("ai-usagebar-tui.exe");
877        std::fs::write(&staged, b"new").unwrap();
878
879        stage_swap(&install, &[("ai-usagebar-tui.exe".to_string(), staged)]).unwrap();
880
881        assert_eq!(
882            std::fs::read(install.join("ai-usagebar-tui.exe")).unwrap(),
883            b"new"
884        );
885        assert!(!install.join("ai-usagebar-tui.exe.old").exists());
886    }
887
888    #[test]
889    fn stage_swap_with_a_missing_staged_file_touches_nothing() {
890        let dir = TempDir::new().unwrap();
891        let install = dir.path().join("install");
892        std::fs::create_dir_all(&install).unwrap();
893        std::fs::write(install.join("ai-usagebar-tray.exe"), b"v1").unwrap();
894        std::fs::write(install.join("ai-usagebar.exe"), b"v1").unwrap();
895        let tray_staged = dir.path().join("ai-usagebar-tray.exe");
896        std::fs::write(&tray_staged, b"v2").unwrap();
897
898        let error = stage_swap(
899            &install,
900            &[
901                ("ai-usagebar-tray.exe".to_string(), tray_staged.clone()),
902                (
903                    "ai-usagebar.exe".to_string(),
904                    dir.path().join("never-downloaded.exe"),
905                ),
906            ],
907        )
908        .unwrap_err();
909
910        assert!(error.contains("does not exist"), "{error}");
911        assert_eq!(
912            std::fs::read(install.join("ai-usagebar-tray.exe")).unwrap(),
913            b"v1"
914        );
915        assert_eq!(
916            std::fs::read(install.join("ai-usagebar.exe")).unwrap(),
917            b"v1"
918        );
919        assert!(!install.join("ai-usagebar-tray.exe.old").exists());
920        assert!(
921            tray_staged.exists(),
922            "the good download is kept for a retry"
923        );
924    }
925
926    #[test]
927    fn stage_swap_rolls_back_entries_already_swapped_when_a_later_one_fails() {
928        let dir = TempDir::new().unwrap();
929        let install = dir.path().join("install");
930        std::fs::create_dir_all(&install).unwrap();
931        std::fs::write(install.join("ai-usagebar-tray.exe"), b"v1").unwrap();
932        std::fs::write(install.join("ai-usagebar.exe"), b"v1").unwrap();
933        // A stale `.old` that is a non-empty directory cannot be removed, so
934        // the second entry fails after the first has already been swapped.
935        let blocker = install.join("ai-usagebar.exe.old");
936        std::fs::create_dir_all(&blocker).unwrap();
937        std::fs::write(blocker.join("keep"), b"x").unwrap();
938        let tray_staged = dir.path().join("ai-usagebar-tray.exe");
939        let cli_staged = dir.path().join("ai-usagebar.exe");
940        std::fs::write(&tray_staged, b"v2").unwrap();
941        std::fs::write(&cli_staged, b"v2").unwrap();
942
943        let result = stage_swap(
944            &install,
945            &[
946                ("ai-usagebar-tray.exe".to_string(), tray_staged.clone()),
947                ("ai-usagebar.exe".to_string(), cli_staged.clone()),
948            ],
949        );
950
951        assert!(result.is_err());
952        assert_eq!(
953            std::fs::read(install.join("ai-usagebar-tray.exe")).unwrap(),
954            b"v1",
955            "the tray swap was undone"
956        );
957        assert!(!install.join("ai-usagebar-tray.exe.old").exists());
958        assert_eq!(
959            std::fs::read(&tray_staged).unwrap(),
960            b"v2",
961            "staged file restored"
962        );
963        assert_eq!(
964            std::fs::read(install.join("ai-usagebar.exe")).unwrap(),
965            b"v1"
966        );
967        assert_eq!(std::fs::read(&cli_staged).unwrap(), b"v2");
968    }
969
970    #[test]
971    fn stage_swap_refuses_names_that_leave_the_install_dir() {
972        let dir = TempDir::new().unwrap();
973        let staged = dir.path().join("x.exe");
974        std::fs::write(&staged, b"x").unwrap();
975
976        for name in ["../x.exe", "sub/x.exe", "sub\\x.exe", "", ".."] {
977            let error = stage_swap(dir.path(), &[(name.to_string(), staged.clone())]).unwrap_err();
978            assert!(error.contains("refusing"), "{name:?}: {error}");
979        }
980    }
981
982    #[test]
983    fn sweep_old_removes_only_exe_old_files() {
984        let dir = TempDir::new().unwrap();
985        for name in [
986            "ai-usagebar-tray.exe.old",
987            "ai-usagebar.exe.old",
988            "ai-usagebar-tray.exe",
989            "notes.old",
990            "config.toml",
991        ] {
992            std::fs::write(dir.path().join(name), b"x").unwrap();
993        }
994        std::fs::create_dir(dir.path().join("dir.exe.old")).unwrap();
995
996        assert_eq!(sweep_old(dir.path()), 2);
997
998        let mut remaining: Vec<String> = std::fs::read_dir(dir.path())
999            .unwrap()
1000            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
1001            .collect();
1002        remaining.sort();
1003        assert_eq!(
1004            remaining,
1005            vec![
1006                "ai-usagebar-tray.exe",
1007                "config.toml",
1008                "dir.exe.old",
1009                "notes.old"
1010            ]
1011        );
1012
1013        assert_eq!(sweep_old(dir.path()), 0, "nothing left to sweep");
1014        assert_eq!(sweep_old(&dir.path().join("absent")), 0);
1015    }
1016
1017    #[test]
1018    fn update_state_round_trips_and_a_corrupt_file_is_the_default() {
1019        let dir = TempDir::new().unwrap();
1020        let path = dir.path().join("nested").join("update.json");
1021        let state = UpdateState {
1022            last_check_ms: 1_757_246_400_000,
1023            snoozed_version: Some("1.11.0".to_string()),
1024        };
1025
1026        state.save_at(&path).unwrap();
1027
1028        assert_eq!(UpdateState::load_at(&path), state);
1029        let text = std::fs::read_to_string(&path).unwrap();
1030        assert!(text.contains("\"snoozed_version\": \"1.11.0\""), "{text}");
1031
1032        assert_eq!(
1033            UpdateState::load_at(&dir.path().join("absent.json")),
1034            UpdateState::default()
1035        );
1036        let corrupt = dir.path().join("corrupt.json");
1037        std::fs::write(&corrupt, "{\"last_check_ms\": ").unwrap();
1038        assert_eq!(UpdateState::load_at(&corrupt), UpdateState::default());
1039
1040        // Missing fields default rather than failing the whole load.
1041        let partial = dir.path().join("partial.json");
1042        std::fs::write(&partial, "{\"last_check_ms\": 5}").unwrap();
1043        assert_eq!(
1044            UpdateState::load_at(&partial),
1045            UpdateState {
1046                last_check_ms: 5,
1047                snoozed_version: None,
1048            }
1049        );
1050    }
1051
1052    #[test]
1053    fn staging_dir_is_per_version_under_the_cache_root() {
1054        let root = Path::new("cache");
1055        assert_eq!(
1056            staging_dir(root, "1.11.0"),
1057            Path::new("cache").join("updates").join("1.11.0")
1058        );
1059    }
1060}