1use 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
38pub const SOURCE_REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
42
43pub fn latest_release_url() -> Option<String> {
46 latest_release_url_for(SOURCE_REPOSITORY)
47}
48
49pub 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
73pub const CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60);
76
77pub const MAX_ASSET_BYTES: u64 = 50 * 1024 * 1024;
80
81pub const BINARIES: [&str; 3] = ["ai-usagebar-tray", "ai-usagebar", "ai-usagebar-tui"];
84
85const MAX_FIELD_LEN: usize = 512;
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct Asset {
93 pub name: String,
94 pub size: u64,
95 pub url: String,
96}
97
98#[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#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct Download {
110 pub binary: &'static str,
111 pub exe: Asset,
112 pub sha256: Asset,
113}
114
115#[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
141pub 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
192fn 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
202fn 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
216pub 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
225pub 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
238pub fn asset_name(binary: &str, arch: &str) -> String {
241 format!("{binary}-windows-{arch}.exe")
242}
243
244pub 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
290pub 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
304pub 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
341struct Swapped {
343 dest: PathBuf,
344 old: Option<PathBuf>,
345 source: PathBuf,
346}
347
348pub 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
419fn 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
448pub 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#[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 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 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
492pub fn default_state_path() -> Result<PathBuf> {
495 Ok(crate::cache::xdg_cache_dir()?
496 .join("ai-usagebar")
497 .join("update.json"))
498}
499
500pub 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 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(¤t_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 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 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); 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 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 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 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 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 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}