1use std::fs;
34use std::io::Read;
35use std::path::{Path, PathBuf};
36use std::time::Duration;
37
38use serde::{Deserialize, Serialize};
39use sha2::{Digest, Sha256};
40
41use crate::error::Result;
42
43pub const SOURCE_REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
47
48pub const LOCAL_FEED: Option<&str> = option_env!("AIUB_UPDATE_FEED");
54
55pub fn latest_release_url() -> Option<String> {
58 if let Some(feed) = LOCAL_FEED {
59 return Some(feed.to_string());
60 }
61 latest_release_url_for(SOURCE_REPOSITORY)
62}
63
64pub fn download_url_allowed(url: &str) -> bool {
67 download_url_allowed_with(url, LOCAL_FEED.is_some())
68}
69
70fn download_url_allowed_with(url: &str, local_feed: bool) -> bool {
71 if url.starts_with("https://") {
72 return true;
73 }
74 local_feed
77 && reqwest::Url::parse(url).is_ok_and(|parsed| {
78 parsed.scheme() == "http"
79 && parsed.username().is_empty()
80 && parsed.password().is_none()
81 && matches!(parsed.host_str(), Some("127.0.0.1" | "localhost"))
82 })
83}
84
85pub fn latest_release_url_for(repository: &str) -> Option<String> {
89 let path = repository
90 .trim()
91 .strip_prefix("https://github.com/")?
92 .trim_end_matches('/')
93 .trim_end_matches(".git");
94 let (owner, name) = path.split_once('/')?;
95 let valid = |s: &str| {
96 !s.is_empty()
97 && s.len() <= 100
98 && s.chars()
99 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
100 };
101 if !valid(owner) || !valid(name) || name.contains('/') {
102 return None;
103 }
104 Some(format!(
105 "https://api.github.com/repos/{owner}/{name}/releases/latest"
106 ))
107}
108
109pub const CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60);
112
113pub const MAX_ASSET_BYTES: u64 = 50 * 1024 * 1024;
116
117pub const BINARIES: [&str; 3] = ["ai-usagebar-tray", "ai-usagebar", "ai-usagebar-tui"];
120
121const MAX_FIELD_LEN: usize = 512;
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct Asset {
129 pub name: String,
130 pub size: u64,
131 pub url: String,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct Release {
138 pub assets: Vec<Asset>,
139 pub html_url: String,
140 pub version: String,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct Download {
146 pub binary: &'static str,
147 pub exe: Asset,
148 pub sha256: Asset,
149}
150
151#[derive(Deserialize)]
155struct WireRelease {
156 #[serde(default)]
157 assets: Vec<WireAsset>,
158 #[serde(default)]
159 draft: bool,
160 #[serde(default)]
161 html_url: String,
162 #[serde(default)]
163 prerelease: bool,
164 tag_name: Option<String>,
165}
166
167#[derive(Deserialize)]
168struct WireAsset {
169 #[serde(default)]
170 browser_download_url: String,
171 #[serde(default)]
172 name: String,
173 #[serde(default)]
174 size: u64,
175}
176
177pub fn parse_release(json: &str) -> std::result::Result<Release, String> {
188 let wire: WireRelease =
189 serde_json::from_str(json).map_err(|error| format!("release JSON: {error}"))?;
190 if wire.draft || wire.prerelease {
191 return Err("prerelease/draft release".to_string());
192 }
193 let tag = wire
194 .tag_name
195 .ok_or_else(|| "release JSON has no tag_name".to_string())?;
196 let version = tag.strip_prefix('v').unwrap_or(&tag);
197 if parse_version(version).is_none() {
198 return Err(format!("release tag {tag:?} is not vX.Y.Z"));
199 }
200 let html_url = if wire.html_url.starts_with("https://github.com/")
201 && wire.html_url.len() <= MAX_FIELD_LEN
202 {
203 wire.html_url
204 } else {
205 String::new()
206 };
207 let assets = wire
208 .assets
209 .into_iter()
210 .filter(|asset| asset_name_is_safe(&asset.name))
211 .filter(|asset| {
212 download_url_allowed(&asset.browser_download_url)
213 && asset.browser_download_url.len() <= MAX_FIELD_LEN
214 })
215 .map(|asset| Asset {
216 name: asset.name,
217 size: asset.size,
218 url: asset.browser_download_url,
219 })
220 .collect();
221 Ok(Release {
222 assets,
223 html_url,
224 version: version.to_string(),
225 })
226}
227
228fn asset_name_is_safe(name: &str) -> bool {
231 !name.is_empty()
232 && name.len() <= MAX_FIELD_LEN
233 && !name.contains(['/', '\\'])
234 && !name.contains("..")
235 && name != "."
236}
237
238fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
242 let mut parts = text.split('.');
243 let major = parts.next()?.parse().ok()?;
244 let minor = parts.next()?.parse().ok()?;
245 let patch = parts.next()?.parse().ok()?;
246 if parts.next().is_some() {
247 return None;
248 }
249 Some((major, minor, patch))
250}
251
252pub fn is_newer(current: &str, candidate: &str) -> bool {
255 match (parse_version(current), parse_version(candidate)) {
256 (Some(current), Some(candidate)) => candidate > current,
257 _ => false,
258 }
259}
260
261pub fn current_arch() -> &'static str {
265 if cfg!(target_arch = "x86_64") {
266 "x86_64"
267 } else if cfg!(target_arch = "aarch64") {
268 "aarch64"
269 } else {
270 "unknown"
271 }
272}
273
274pub fn self_update_blocker(
284 exe: &Path,
285 exe_is_link: bool,
286 exists: impl Fn(&Path) -> bool,
287) -> Option<&'static str> {
288 if exe_is_link {
289 return Some("installed through a link; update it with the tool that installed it");
290 }
291 if scoop_managed(exe, exists) {
292 return Some("managed by Scoop; update it with scoop update");
293 }
294 let names: Vec<String> = exe
295 .components()
296 .filter_map(|part| match part {
297 std::path::Component::Normal(name) => Some(name.to_string_lossy().into_owned()),
298 _ => None,
299 })
300 .collect();
301 if names.iter().any(|name| name == "Cellar") {
302 return Some("managed by Homebrew; update it with brew");
303 }
304 if exe.starts_with("/nix/store") {
305 return Some("managed by Nix; update it through your flake or channel");
306 }
307 let dirs = &names[..names.len().saturating_sub(1)];
309 if let Some(profile_at) = dirs.iter().rposition(|d| d == "debug" || d == "release")
310 && profile_at + 1 == dirs.len()
311 && dirs[..profile_at]
312 .iter()
313 .rev()
314 .take(2)
315 .any(|d| d == "target")
316 {
317 return Some("a cargo build; rebuild it from source");
318 }
319 None
320}
321
322pub fn scoop_managed(exe: &Path, exists: impl Fn(&Path) -> bool) -> bool {
328 let Some(version_dir) = exe.parent() else {
329 return false;
330 };
331 let under_apps = version_dir
332 .parent()
333 .and_then(Path::parent)
334 .and_then(Path::file_name)
335 .is_some_and(|name| name.eq_ignore_ascii_case("apps"));
336 under_apps && exists(&version_dir.join("install.json"))
337}
338
339pub fn current_os() -> &'static str {
342 if cfg!(windows) {
343 "windows"
344 } else if cfg!(target_os = "macos") {
345 "macos"
346 } else {
347 "unknown"
348 }
349}
350
351pub fn asset_name(binary: &str, os: &str, arch: &str) -> String {
355 format!("{binary}-{os}-{arch}{}", exe_suffix(os))
356}
357
358pub fn installed_name(binary: &str, os: &str) -> String {
361 format!("{binary}{}", exe_suffix(os))
362}
363
364fn exe_suffix(os: &str) -> &'static str {
365 if os == "windows" { ".exe" } else { "" }
366}
367
368pub fn select_downloads(
377 release: &Release,
378 os: &str,
379 arch: &str,
380) -> std::result::Result<Vec<Download>, String> {
381 let find = |name: &str| release.assets.iter().find(|asset| asset.name == name);
382 let mut downloads = Vec::with_capacity(BINARIES.len());
383 for binary in BINARIES {
384 let required = binary == BINARIES[0];
385 let exe_name = asset_name(binary, os, arch);
386 let sidecar_name = format!("{exe_name}.sha256");
387 let (exe, sha256) = match (find(&exe_name), find(&sidecar_name)) {
388 (Some(exe), Some(sha256)) => (exe, sha256),
389 _ if required => {
390 return Err(format!(
391 "release {} has no {exe_name} + {sidecar_name} pair",
392 release.version
393 ));
394 }
395 _ => continue,
396 };
397 if exe.size == 0 {
398 return Err(format!("{exe_name} is empty"));
399 }
400 if exe.size > MAX_ASSET_BYTES {
401 return Err(format!(
402 "{exe_name} is {} bytes, over the {MAX_ASSET_BYTES}-byte limit",
403 exe.size
404 ));
405 }
406 downloads.push(Download {
407 binary,
408 exe: exe.clone(),
409 sha256: sha256.clone(),
410 });
411 }
412 Ok(downloads)
413}
414
415pub fn parse_sha256_sidecar(text: &str) -> std::result::Result<String, String> {
419 let digest = text
420 .split_whitespace()
421 .next()
422 .ok_or_else(|| "empty sha256 sidecar".to_string())?;
423 if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
424 return Err(format!("sha256 sidecar is not a 64-hex digest: {digest:?}"));
425 }
426 Ok(digest.to_ascii_lowercase())
427}
428
429pub fn verify_sha256(path: &Path, expected_hex: &str) -> std::result::Result<(), String> {
433 let mut file =
434 fs::File::open(path).map_err(|error| format!("open {}: {error}", path.display()))?;
435 let mut hasher = Sha256::new();
436 let mut buffer = [0u8; 64 * 1024];
437 loop {
438 let read = file
439 .read(&mut buffer)
440 .map_err(|error| format!("read {}: {error}", path.display()))?;
441 if read == 0 {
442 break;
443 }
444 hasher.update(&buffer[..read]);
445 }
446 let actual = hex_lower(&hasher.finalize());
447 if actual == expected_hex.to_ascii_lowercase() {
448 Ok(())
449 } else {
450 Err(format!(
451 "sha256 mismatch for {}: expected {expected_hex}, got {actual}",
452 path.display()
453 ))
454 }
455}
456
457fn hex_lower(bytes: &[u8]) -> String {
458 use std::fmt::Write;
459 let mut text = String::with_capacity(bytes.len() * 2);
460 for byte in bytes {
461 let _ = write!(text, "{byte:02x}");
462 }
463 text
464}
465
466struct Swapped {
468 dest: PathBuf,
469 old: Option<PathBuf>,
470 source: PathBuf,
471}
472
473pub fn stage_swap(
485 install_dir: &Path,
486 staged: &[(String, PathBuf)],
487) -> std::result::Result<Vec<PathBuf>, String> {
488 for (name, source) in staged {
489 if !asset_name_is_safe(name) {
490 return Err(format!("refusing to install a file named {name:?}"));
491 }
492 if !source.is_file() {
493 return Err(format!(
494 "staged file {} for {name} does not exist",
495 source.display()
496 ));
497 }
498 }
499 let mut done: Vec<Swapped> = Vec::with_capacity(staged.len());
500 for (name, source) in staged {
501 match swap_one(install_dir, name, source) {
502 Ok(swapped) => done.push(swapped),
503 Err(error) => {
504 rollback(&done);
505 return Err(error);
506 }
507 }
508 }
509 Ok(done.into_iter().map(|swapped| swapped.dest).collect())
510}
511
512fn swap_one(install_dir: &Path, name: &str, source: &Path) -> std::result::Result<Swapped, String> {
513 let dest = install_dir.join(name);
514 let old_path = install_dir.join(format!("{name}.old"));
515 let mut old = None;
516 if dest.exists() {
517 if old_path.exists() {
518 fs::remove_file(&old_path)
519 .map_err(|error| format!("remove stale {}: {error}", old_path.display()))?;
520 }
521 fs::rename(&dest, &old_path).map_err(|error| {
522 format!(
523 "rename {} to {}: {error}",
524 dest.display(),
525 old_path.display()
526 )
527 })?;
528 old = Some(old_path);
529 }
530 if let Err(error) = move_file(source, &dest) {
531 let _ = fs::remove_file(&dest);
532 if let Some(old) = &old {
533 let _ = fs::rename(old, &dest);
534 }
535 return Err(error);
536 }
537 Ok(Swapped {
538 dest,
539 old,
540 source: source.to_path_buf(),
541 })
542}
543
544fn move_file(from: &Path, to: &Path) -> std::result::Result<(), String> {
547 let Err(rename_error) = fs::rename(from, to) else {
548 return Ok(());
549 };
550 if let Err(copy_error) = fs::copy(from, to) {
551 let _ = fs::remove_file(to);
552 return Err(format!(
553 "move {} to {}: rename failed ({rename_error}), copy failed ({copy_error})",
554 from.display(),
555 to.display()
556 ));
557 }
558 let _ = fs::remove_file(from);
559 Ok(())
560}
561
562fn rollback(done: &[Swapped]) {
563 for swapped in done.iter().rev() {
564 if move_file(&swapped.dest, &swapped.source).is_err() {
565 let _ = fs::remove_file(&swapped.dest);
566 }
567 if let Some(old) = &swapped.old {
568 let _ = fs::rename(old, &swapped.dest);
569 }
570 }
571}
572
573pub fn sweep_old(install_dir: &Path, os: &str) -> usize {
578 BINARIES
579 .iter()
580 .map(|binary| install_dir.join(format!("{}.old", installed_name(binary, os))))
581 .filter(|path| path.is_file())
582 .filter(|path| fs::remove_file(path).is_ok())
583 .count()
584}
585
586#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
590pub struct UpdateState {
591 #[serde(default)]
595 pub handed_off: Option<String>,
596 #[serde(default)]
597 pub last_check_ms: i64,
598 #[serde(default)]
599 pub snoozed_version: Option<String>,
600}
601
602impl UpdateState {
603 pub fn load_at(path: &Path) -> UpdateState {
607 fs::read(path)
608 .ok()
609 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
610 .unwrap_or_default()
611 }
612
613 pub fn save_at(&self, path: &Path) -> Result<()> {
615 let bytes = serde_json::to_vec_pretty(self)?;
616 crate::cache::atomic_write(path, &bytes)
617 }
618}
619
620pub fn default_state_path() -> Result<PathBuf> {
623 Ok(crate::cache::xdg_cache_dir()?
624 .join("ai-usagebar")
625 .join("update.json"))
626}
627
628pub fn staging_dir(cache_root: &Path, version: &str) -> PathBuf {
632 cache_root.join("updates").join(version)
633}
634
635#[cfg(test)]
636mod tests {
637 #[test]
638 fn latest_release_url_follows_the_cargo_repository_field() {
639 assert_eq!(
640 super::latest_release_url_for("https://github.com/akitaonrails/ai-usagebar"),
641 Some("https://api.github.com/repos/akitaonrails/ai-usagebar/releases/latest".into())
642 );
643 assert_eq!(
644 super::latest_release_url_for("https://github.com/djalmajr/ai-usagebar.git/"),
645 Some("https://api.github.com/repos/djalmajr/ai-usagebar/releases/latest".into())
646 );
647 assert_eq!(
648 super::latest_release_url_for("https://gitlab.com/x/y"),
649 None
650 );
651 assert_eq!(
652 super::latest_release_url_for("https://github.com/only-owner"),
653 None
654 );
655 assert_eq!(
656 super::latest_release_url_for("https://github.com/o/n/extra"),
657 None
658 );
659 assert_eq!(
660 super::latest_release_url_for("https://github.com/o/n%20e"),
661 None
662 );
663 assert!(
665 super::latest_release_url().is_some(),
666 "{}",
667 super::SOURCE_REPOSITORY
668 );
669 }
670
671 use super::*;
672 use tempfile::TempDir;
673
674 const RELEASE_FIXTURE: &str = r#"{
675 "url": "https://api.github.com/repos/akitaonrails/ai-usagebar/releases/300000",
676 "html_url": "https://github.com/akitaonrails/ai-usagebar/releases/tag/v1.11.0",
677 "id": 300000,
678 "tag_name": "v1.11.0",
679 "target_commitish": "main",
680 "name": "v1.11.0",
681 "draft": false,
682 "prerelease": false,
683 "created_at": "2026-09-01T12:00:00Z",
684 "published_at": "2026-09-01T12:05:00Z",
685 "assets": [
686 {
687 "name": "ai-usagebar-linux-x86_64.tar.gz",
688 "size": 9000000,
689 "content_type": "application/gzip",
690 "browser_download_url": "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/ai-usagebar-linux-x86_64.tar.gz"
691 },
692 {
693 "name": "ai-usagebar-tray-windows-x86_64.exe",
694 "size": 6100000,
695 "content_type": "application/octet-stream",
696 "browser_download_url": "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/ai-usagebar-tray-windows-x86_64.exe"
697 },
698 {
699 "name": "ai-usagebar-tray-windows-x86_64.exe.sha256",
700 "size": 102,
701 "content_type": "application/octet-stream",
702 "browser_download_url": "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/ai-usagebar-tray-windows-x86_64.exe.sha256"
703 },
704 {
705 "name": "../evil.exe",
706 "size": 10,
707 "browser_download_url": "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/evil.exe"
708 },
709 {
710 "name": "plain-http.exe",
711 "size": 10,
712 "browser_download_url": "http://example.com/plain-http.exe"
713 }
714 ]
715}"#;
716
717 fn asset(name: &str, size: u64) -> Asset {
718 Asset {
719 name: name.to_string(),
720 size,
721 url: format!(
722 "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/{name}"
723 ),
724 }
725 }
726
727 fn pair(binary: &str, size: u64) -> [Asset; 2] {
728 let exe = asset_name(binary, "windows", "x86_64");
729 [asset(&exe, size), asset(&format!("{exe}.sha256"), 100)]
730 }
731
732 fn release_with(assets: Vec<Asset>) -> Release {
733 Release {
734 assets,
735 html_url: String::new(),
736 version: "1.11.0".to_string(),
737 }
738 }
739
740 fn full_release() -> Release {
741 release_with(
742 BINARIES
743 .iter()
744 .flat_map(|binary| pair(binary, 5_000_000))
745 .collect(),
746 )
747 }
748
749 #[test]
750 fn parse_release_reads_version_url_and_safe_assets() {
751 let release = parse_release(RELEASE_FIXTURE).unwrap();
752
753 assert_eq!(release.version, "1.11.0");
754 assert_eq!(
755 release.html_url,
756 "https://github.com/akitaonrails/ai-usagebar/releases/tag/v1.11.0"
757 );
758 let names: Vec<&str> = release
759 .assets
760 .iter()
761 .map(|asset| asset.name.as_str())
762 .collect();
763 assert_eq!(
764 names,
765 vec![
766 "ai-usagebar-linux-x86_64.tar.gz",
767 "ai-usagebar-tray-windows-x86_64.exe",
768 "ai-usagebar-tray-windows-x86_64.exe.sha256",
769 ],
770 "the traversal name and the plain-http asset are dropped"
771 );
772 let tray = &release.assets[1];
773 assert_eq!(tray.size, 6_100_000);
774 assert_eq!(
775 tray.url,
776 "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/ai-usagebar-tray-windows-x86_64.exe"
777 );
778 }
779
780 #[test]
781 fn parse_release_accepts_a_bare_tag_and_tolerates_missing_assets() {
782 let release =
783 parse_release(r#"{"tag_name": "1.11.0", "html_url": "https://evil.example/x"}"#)
784 .unwrap();
785
786 assert_eq!(release.version, "1.11.0");
787 assert!(release.assets.is_empty());
788 assert_eq!(release.html_url, "", "a non-GitHub link is blanked");
789 }
790
791 #[test]
792 fn parse_release_rejects_prerelease_draft_and_malformed_tags() {
793 let prerelease = r#"{"tag_name": "v1.11.0", "prerelease": true}"#;
794 assert_eq!(
795 parse_release(prerelease).unwrap_err(),
796 "prerelease/draft release"
797 );
798
799 let draft = r#"{"tag_name": "v1.11.0", "draft": true}"#;
800 assert_eq!(
801 parse_release(draft).unwrap_err(),
802 "prerelease/draft release"
803 );
804
805 for tag in ["v1.11", "v1.11.0-rc1", "nightly", "v1.11.0.1", ""] {
806 let json = format!(r#"{{"tag_name": "{tag}"}}"#);
807 assert!(parse_release(&json).is_err(), "{tag:?} parsed");
808 }
809 assert!(parse_release(r#"{"draft": false}"#).is_err(), "no tag_name");
810 assert!(parse_release("not json").is_err());
811 }
812
813 #[test]
814 fn is_newer_compares_numerically() {
815 assert!(is_newer("1.10.0", "1.11.0"));
816 assert!(!is_newer("1.11.0", "1.11.0"), "equal is not newer");
817 assert!(!is_newer("1.11.0", "1.10.0"), "older is not newer");
818 assert!(is_newer("1.9.10", "1.10.0"), "not a string compare");
819 assert!(is_newer("1.10.0", "2.0.0"));
820 assert!(is_newer("1.10.0", "1.10.1"));
821 }
822
823 #[test]
824 fn is_newer_is_false_for_anything_malformed() {
825 assert!(!is_newer("1.10.0", "v1.11.0"));
826 assert!(!is_newer("1.10.0", "1.11"));
827 assert!(!is_newer("1.10.0", "1.11.0-rc1"));
828 assert!(!is_newer("garbage", "1.11.0"));
829 assert!(!is_newer("", ""));
830 }
831
832 #[test]
833 fn asset_name_follows_the_release_workflow() {
834 assert_eq!(
835 asset_name("ai-usagebar-tray", "windows", "x86_64"),
836 "ai-usagebar-tray-windows-x86_64.exe"
837 );
838 assert_eq!(
839 asset_name("ai-usagebar-tray", "macos", "aarch64"),
840 "ai-usagebar-tray-macos-aarch64"
841 );
842 assert_eq!(
843 installed_name("ai-usagebar-tui", "windows"),
844 "ai-usagebar-tui.exe"
845 );
846 assert_eq!(
847 installed_name("ai-usagebar-tui", "macos"),
848 "ai-usagebar-tui"
849 );
850 assert!(["x86_64", "aarch64", "unknown"].contains(¤t_arch()));
851 assert!(["windows", "macos", "unknown"].contains(¤t_os()));
852 }
853
854 #[test]
855 fn select_downloads_picks_the_assets_for_the_requested_os() {
856 let mut assets = full_release().assets;
857 for binary in BINARIES {
858 let name = asset_name(binary, "macos", "aarch64");
859 assets.push(asset(&name, 4_000_000));
860 assets.push(asset(&format!("{name}.sha256"), 100));
861 }
862 let release = release_with(assets);
863 let mac = select_downloads(&release, "macos", "aarch64").unwrap();
864 assert_eq!(mac[0].exe.name, "ai-usagebar-tray-macos-aarch64");
865 let windows = select_downloads(&release, "windows", "x86_64").unwrap();
866 assert_eq!(windows[0].exe.name, "ai-usagebar-tray-windows-x86_64.exe");
867 assert!(select_downloads(&full_release(), "macos", "aarch64").is_err());
869 }
870
871 #[test]
872 fn self_update_stays_out_of_package_managers_and_source_trees() {
873 use std::path::Path;
874 let may = |p: &str| self_update_blocker(Path::new(p), false, |_| false);
875 assert_eq!(may("/Users/a/.local/bin/ai-usagebar-tray"), None);
876 assert_eq!(may("/Applications/AI Usage/ai-usagebar-tray"), None);
877 assert!(
878 self_update_blocker(
879 Path::new("/opt/homebrew/bin/ai-usagebar-tray"),
880 true,
881 |_| false
882 )
883 .is_some()
884 );
885 assert!(may("/opt/homebrew/Cellar/ai-usagebar/1.21.1/bin/ai-usagebar-tray").is_some());
886 assert!(may("/nix/store/0abc-ai-usagebar-1.21.1/bin/ai-usagebar-tray").is_some());
887 assert!(may("/Users/a/src/ai-usagebar/target/release/ai-usagebar-tray").is_some());
888 assert!(may("/Users/a/src/ai-usagebar/target/debug/ai-usagebar-tray").is_some());
889 assert!(
890 may("/Users/a/src/ai-usagebar/target/aarch64-apple-darwin/release/ai-usagebar-tray")
891 .is_some()
892 );
893 assert_eq!(may("/Users/a/release/ai-usagebar-tray"), None);
895 assert_eq!(
896 may("/Users/a/target-practice/release/ai-usagebar-tray"),
897 None
898 );
899 }
900
901 #[test]
902 fn a_scoop_install_is_left_to_scoop() {
903 use std::path::Path;
904 let scoop = |p: &Path| p.ends_with("install.json");
906 let user = "C:/Users/a/scoop/apps/ai-usagebar/current/ai-usagebar-tray.exe";
907 let version = "C:/Users/a/scoop/apps/ai-usagebar/1.24.0/ai-usagebar-tray.exe";
908 let global = "C:/ProgramData/scoop/apps/ai-usagebar/current/ai-usagebar-tray.exe";
909 let fork = "D:/tools/scoop/apps/ai-usagebar-dev/current/ai-usagebar-tray.exe";
910 for exe in [user, version, global, fork] {
911 assert!(scoop_managed(Path::new(exe), scoop), "{exe}");
912 assert_eq!(
913 self_update_blocker(Path::new(exe), false, scoop),
914 Some("managed by Scoop; update it with scoop update"),
915 "{exe}"
916 );
917 }
918 assert!(scoop_managed(
920 Path::new("C:/Users/a/scoop/Apps/ai-usagebar/current/ai-usagebar-tray.exe"),
921 scoop
922 ));
923 }
924
925 #[test]
926 fn a_folder_that_only_looks_like_scoop_still_updates_itself() {
927 use std::path::Path;
928 let exe = Path::new("D:/apps/ai-usagebar/1.24.0/ai-usagebar-tray.exe");
929 assert!(!scoop_managed(exe, |_| false));
931 assert_eq!(self_update_blocker(exe, false, |_| false), None);
932 let has_manifest = |p: &Path| p.ends_with("install.json");
934 for exe in [
935 "C:/Users/a/AI Usage/ai-usagebar-tray.exe",
936 "C:/Users/a/scoop/ai-usagebar/current/ai-usagebar-tray.exe",
937 "ai-usagebar-tray.exe",
938 ] {
939 assert!(!scoop_managed(Path::new(exe), has_manifest), "{exe}");
940 }
941 }
942
943 #[test]
944 fn scoop_is_detected_from_the_install_json_on_disk() {
945 let tmp = tempfile::tempdir().unwrap();
946 let version_dir = tmp
947 .path()
948 .join("scoop")
949 .join("apps")
950 .join("ai-usagebar")
951 .join("1.24.0");
952 std::fs::create_dir_all(&version_dir).unwrap();
953 let exe = version_dir.join("ai-usagebar-tray.exe");
954 let on_disk = |p: &std::path::Path| p.is_file();
955 assert!(!scoop_managed(&exe, on_disk));
956 std::fs::write(version_dir.join("install.json"), b"{}").unwrap();
957 assert!(scoop_managed(&exe, on_disk));
958 }
959
960 #[test]
961 fn loopback_http_is_a_download_source_only_for_a_local_feed_build() {
962 assert!(download_url_allowed_with("https://github.com/x", false));
963 assert!(!download_url_allowed_with("http://127.0.0.1:8765/a", false));
964 assert!(download_url_allowed_with("http://127.0.0.1:8765/a", true));
965 assert!(download_url_allowed_with("http://localhost:8765/a", true));
966 assert!(!download_url_allowed_with("http://example.com/a", true));
967 assert!(!download_url_allowed_with(
968 "http://127.0.0.1.evil.com/a",
969 true
970 ));
971 assert!(!download_url_allowed_with(
972 "http://127.0.0.1:1@evil.com/a",
973 true
974 ));
975 assert!(!download_url_allowed_with(
976 "http://user:pw@127.0.0.1:8765/a",
977 true
978 ));
979 }
980
981 #[test]
982 fn select_downloads_pairs_all_three_binaries_tray_first() {
983 let downloads = select_downloads(&full_release(), "windows", "x86_64").unwrap();
984
985 let binaries: Vec<&str> = downloads.iter().map(|d| d.binary).collect();
986 assert_eq!(binaries, BINARIES.to_vec());
987 for download in &downloads {
988 assert_eq!(
989 download.exe.name,
990 asset_name(download.binary, "windows", "x86_64")
991 );
992 assert_eq!(
993 download.sha256.name,
994 format!("{}.sha256", download.exe.name)
995 );
996 }
997 }
998
999 #[test]
1000 fn select_downloads_requires_the_tray_pair() {
1001 let mut assets: Vec<Asset> = pair("ai-usagebar", 5_000_000).to_vec();
1002 assets.extend(pair("ai-usagebar-tui", 5_000_000));
1003 let error = select_downloads(&release_with(assets), "windows", "x86_64").unwrap_err();
1004 assert!(
1005 error.contains("ai-usagebar-tray-windows-x86_64.exe"),
1006 "{error}"
1007 );
1008
1009 let [tray_exe, _] = pair("ai-usagebar-tray", 5_000_000);
1011 assert!(select_downloads(&release_with(vec![tray_exe]), "windows", "x86_64").is_err());
1012
1013 assert!(select_downloads(&full_release(), "windows", "aarch64").is_err());
1015 }
1016
1017 #[test]
1018 fn select_downloads_skips_an_incomplete_optional_pair() {
1019 let mut assets: Vec<Asset> = pair("ai-usagebar-tray", 5_000_000).to_vec();
1020 assets.extend(pair("ai-usagebar-tui", 5_000_000));
1021 let [cli_exe, _] = pair("ai-usagebar", 5_000_000);
1022 assets.push(cli_exe); let downloads = select_downloads(&release_with(assets), "windows", "x86_64").unwrap();
1025
1026 let binaries: Vec<&str> = downloads.iter().map(|d| d.binary).collect();
1027 assert_eq!(binaries, vec!["ai-usagebar-tray", "ai-usagebar-tui"]);
1028 }
1029
1030 #[test]
1031 fn select_downloads_rejects_empty_and_oversized_exes() {
1032 let mut assets: Vec<Asset> = pair("ai-usagebar-tray", MAX_ASSET_BYTES + 1).to_vec();
1033 let error =
1034 select_downloads(&release_with(assets.clone()), "windows", "x86_64").unwrap_err();
1035 assert!(error.contains("over the"), "{error}");
1036
1037 assets = pair("ai-usagebar-tray", 0).to_vec();
1038 let error = select_downloads(&release_with(assets), "windows", "x86_64").unwrap_err();
1039 assert!(error.contains("empty"), "{error}");
1040
1041 let mut assets: Vec<Asset> = pair("ai-usagebar-tray", 5_000_000).to_vec();
1043 assets.extend(pair("ai-usagebar-tui", MAX_ASSET_BYTES + 1));
1044 assert!(select_downloads(&release_with(assets), "windows", "x86_64").is_err());
1045
1046 let assets: Vec<Asset> = pair("ai-usagebar-tray", MAX_ASSET_BYTES).to_vec();
1048 assert!(select_downloads(&release_with(assets), "windows", "x86_64").is_ok());
1049 }
1050
1051 #[test]
1052 fn parse_sha256_sidecar_accepts_both_formats_and_folds_case() {
1053 let hex = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1054
1055 assert_eq!(
1056 parse_sha256_sidecar(&format!("{hex} ai-usagebar-tray-windows-x86_64.exe\n")).unwrap(),
1057 hex
1058 );
1059 assert_eq!(parse_sha256_sidecar(hex).unwrap(), hex);
1060 assert_eq!(parse_sha256_sidecar(&format!(" {hex}\r\n")).unwrap(), hex);
1061 assert_eq!(
1062 parse_sha256_sidecar(&hex.to_ascii_uppercase()).unwrap(),
1063 hex,
1064 "uppercase is folded"
1065 );
1066 }
1067
1068 #[test]
1069 fn parse_sha256_sidecar_rejects_garbage() {
1070 assert!(parse_sha256_sidecar("").is_err());
1071 assert!(parse_sha256_sidecar(" \n").is_err());
1072 assert!(
1073 parse_sha256_sidecar("deadbeef name.exe").is_err(),
1074 "too short"
1075 );
1076 assert!(
1077 parse_sha256_sidecar(&"zz".repeat(32)).is_err(),
1078 "right length, not hex"
1079 );
1080 assert!(
1081 parse_sha256_sidecar(
1082 "name.exe e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1083 )
1084 .is_err(),
1085 "name first is not the format"
1086 );
1087 }
1088
1089 #[test]
1090 fn verify_sha256_streams_the_file_and_detects_mismatch() {
1091 let dir = TempDir::new().unwrap();
1092 let path = dir.path().join("blob.bin");
1093 let contents: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
1094 std::fs::write(&path, &contents).unwrap();
1095 let expected = hex_lower(&Sha256::digest(&contents));
1096
1097 assert_eq!(verify_sha256(&path, &expected), Ok(()));
1098 assert_eq!(
1099 verify_sha256(&path, &expected.to_ascii_uppercase()),
1100 Ok(()),
1101 "case-insensitive"
1102 );
1103
1104 let wrong = format!("{}{}", &expected[1..], "0");
1105 let error = verify_sha256(&path, &wrong).unwrap_err();
1106 assert!(error.contains("mismatch"), "{error}");
1107
1108 assert!(verify_sha256(&dir.path().join("absent"), &expected).is_err());
1109 }
1110
1111 #[test]
1112 fn stage_swap_replaces_the_exe_and_keeps_the_old_one() {
1113 let dir = TempDir::new().unwrap();
1114 let install = dir.path().join("install");
1115 let staging = dir.path().join("staging");
1116 std::fs::create_dir_all(&install).unwrap();
1117 std::fs::create_dir_all(&staging).unwrap();
1118 let name = "ai-usagebar-tray.exe";
1119 std::fs::write(install.join(name), b"v1").unwrap();
1120 let staged = staging.join(name);
1121 std::fs::write(&staged, b"v2").unwrap();
1122
1123 let written = stage_swap(&install, &[(name.to_string(), staged.clone())]).unwrap();
1124
1125 assert_eq!(written, vec![install.join(name)]);
1126 assert_eq!(std::fs::read(install.join(name)).unwrap(), b"v2");
1127 assert_eq!(
1128 std::fs::read(install.join("ai-usagebar-tray.exe.old")).unwrap(),
1129 b"v1"
1130 );
1131 assert!(!staged.exists(), "the staged file was moved, not copied");
1132
1133 std::fs::write(&staged, b"v3").unwrap();
1135 stage_swap(&install, &[(name.to_string(), staged.clone())]).unwrap();
1136 assert_eq!(std::fs::read(install.join(name)).unwrap(), b"v3");
1137 assert_eq!(
1138 std::fs::read(install.join("ai-usagebar-tray.exe.old")).unwrap(),
1139 b"v2"
1140 );
1141 }
1142
1143 #[test]
1144 fn stage_swap_installs_a_binary_that_was_not_there_before() {
1145 let dir = TempDir::new().unwrap();
1146 let install = dir.path().join("install");
1147 std::fs::create_dir_all(&install).unwrap();
1148 let staged = dir.path().join("ai-usagebar-tui.exe");
1149 std::fs::write(&staged, b"new").unwrap();
1150
1151 stage_swap(&install, &[("ai-usagebar-tui.exe".to_string(), staged)]).unwrap();
1152
1153 assert_eq!(
1154 std::fs::read(install.join("ai-usagebar-tui.exe")).unwrap(),
1155 b"new"
1156 );
1157 assert!(!install.join("ai-usagebar-tui.exe.old").exists());
1158 }
1159
1160 #[test]
1161 fn stage_swap_with_a_missing_staged_file_touches_nothing() {
1162 let dir = TempDir::new().unwrap();
1163 let install = dir.path().join("install");
1164 std::fs::create_dir_all(&install).unwrap();
1165 std::fs::write(install.join("ai-usagebar-tray.exe"), b"v1").unwrap();
1166 std::fs::write(install.join("ai-usagebar.exe"), b"v1").unwrap();
1167 let tray_staged = dir.path().join("ai-usagebar-tray.exe");
1168 std::fs::write(&tray_staged, b"v2").unwrap();
1169
1170 let error = stage_swap(
1171 &install,
1172 &[
1173 ("ai-usagebar-tray.exe".to_string(), tray_staged.clone()),
1174 (
1175 "ai-usagebar.exe".to_string(),
1176 dir.path().join("never-downloaded.exe"),
1177 ),
1178 ],
1179 )
1180 .unwrap_err();
1181
1182 assert!(error.contains("does not exist"), "{error}");
1183 assert_eq!(
1184 std::fs::read(install.join("ai-usagebar-tray.exe")).unwrap(),
1185 b"v1"
1186 );
1187 assert_eq!(
1188 std::fs::read(install.join("ai-usagebar.exe")).unwrap(),
1189 b"v1"
1190 );
1191 assert!(!install.join("ai-usagebar-tray.exe.old").exists());
1192 assert!(
1193 tray_staged.exists(),
1194 "the good download is kept for a retry"
1195 );
1196 }
1197
1198 #[test]
1199 fn stage_swap_rolls_back_entries_already_swapped_when_a_later_one_fails() {
1200 let dir = TempDir::new().unwrap();
1201 let install = dir.path().join("install");
1202 std::fs::create_dir_all(&install).unwrap();
1203 std::fs::write(install.join("ai-usagebar-tray.exe"), b"v1").unwrap();
1204 std::fs::write(install.join("ai-usagebar.exe"), b"v1").unwrap();
1205 let blocker = install.join("ai-usagebar.exe.old");
1208 std::fs::create_dir_all(&blocker).unwrap();
1209 std::fs::write(blocker.join("keep"), b"x").unwrap();
1210 let tray_staged = dir.path().join("ai-usagebar-tray.exe");
1211 let cli_staged = dir.path().join("ai-usagebar.exe");
1212 std::fs::write(&tray_staged, b"v2").unwrap();
1213 std::fs::write(&cli_staged, b"v2").unwrap();
1214
1215 let result = stage_swap(
1216 &install,
1217 &[
1218 ("ai-usagebar-tray.exe".to_string(), tray_staged.clone()),
1219 ("ai-usagebar.exe".to_string(), cli_staged.clone()),
1220 ],
1221 );
1222
1223 assert!(result.is_err());
1224 assert_eq!(
1225 std::fs::read(install.join("ai-usagebar-tray.exe")).unwrap(),
1226 b"v1",
1227 "the tray swap was undone"
1228 );
1229 assert!(!install.join("ai-usagebar-tray.exe.old").exists());
1230 assert_eq!(
1231 std::fs::read(&tray_staged).unwrap(),
1232 b"v2",
1233 "staged file restored"
1234 );
1235 assert_eq!(
1236 std::fs::read(install.join("ai-usagebar.exe")).unwrap(),
1237 b"v1"
1238 );
1239 assert_eq!(std::fs::read(&cli_staged).unwrap(), b"v2");
1240 }
1241
1242 #[test]
1243 fn stage_swap_refuses_names_that_leave_the_install_dir() {
1244 let dir = TempDir::new().unwrap();
1245 let staged = dir.path().join("x.exe");
1246 std::fs::write(&staged, b"x").unwrap();
1247
1248 for name in ["../x.exe", "sub/x.exe", "sub\\x.exe", "", ".."] {
1249 let error = stage_swap(dir.path(), &[(name.to_string(), staged.clone())]).unwrap_err();
1250 assert!(error.contains("refusing"), "{name:?}: {error}");
1251 }
1252 }
1253
1254 #[test]
1255 fn sweep_old_removes_only_the_binaries_old_files() {
1256 let dir = TempDir::new().unwrap();
1257 for name in [
1258 "ai-usagebar-tray.exe.old",
1259 "ai-usagebar.exe.old",
1260 "ai-usagebar-tray.exe",
1261 "notes.old",
1262 "config.toml",
1263 ] {
1264 std::fs::write(dir.path().join(name), b"x").unwrap();
1265 }
1266 std::fs::create_dir(dir.path().join("dir.exe.old")).unwrap();
1267
1268 assert_eq!(sweep_old(dir.path(), "windows"), 2);
1269
1270 let mut remaining: Vec<String> = std::fs::read_dir(dir.path())
1271 .unwrap()
1272 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
1273 .collect();
1274 remaining.sort();
1275 assert_eq!(
1276 remaining,
1277 vec![
1278 "ai-usagebar-tray.exe",
1279 "config.toml",
1280 "dir.exe.old",
1281 "notes.old"
1282 ]
1283 );
1284
1285 assert_eq!(sweep_old(dir.path(), "windows"), 0, "nothing left to sweep");
1286 assert_eq!(sweep_old(&dir.path().join("absent"), "windows"), 0);
1287 }
1288
1289 #[test]
1290 fn sweep_old_on_macos_removes_the_suffixless_leftovers() {
1291 let dir = TempDir::new().unwrap();
1292 for name in ["ai-usagebar-tray.old", "ai-usagebar-tray", "notes.old"] {
1293 std::fs::write(dir.path().join(name), b"x").unwrap();
1294 }
1295 assert_eq!(sweep_old(dir.path(), "macos"), 1);
1296 assert!(dir.path().join("ai-usagebar-tray").is_file());
1297 assert!(dir.path().join("notes.old").is_file());
1298 }
1299
1300 #[test]
1301 fn update_state_round_trips_and_a_corrupt_file_is_the_default() {
1302 let dir = TempDir::new().unwrap();
1303 let path = dir.path().join("nested").join("update.json");
1304 let state = UpdateState {
1305 handed_off: None,
1306 last_check_ms: 1_757_246_400_000,
1307 snoozed_version: Some("1.11.0".to_string()),
1308 };
1309
1310 state.save_at(&path).unwrap();
1311
1312 assert_eq!(UpdateState::load_at(&path), state);
1313 let text = std::fs::read_to_string(&path).unwrap();
1314 assert!(text.contains("\"snoozed_version\": \"1.11.0\""), "{text}");
1315
1316 assert_eq!(
1317 UpdateState::load_at(&dir.path().join("absent.json")),
1318 UpdateState::default()
1319 );
1320 let corrupt = dir.path().join("corrupt.json");
1321 std::fs::write(&corrupt, "{\"last_check_ms\": ").unwrap();
1322 assert_eq!(UpdateState::load_at(&corrupt), UpdateState::default());
1323
1324 let partial = dir.path().join("partial.json");
1326 std::fs::write(&partial, "{\"last_check_ms\": 5}").unwrap();
1327 assert_eq!(
1328 UpdateState::load_at(&partial),
1329 UpdateState {
1330 handed_off: None,
1331 last_check_ms: 5,
1332 snoozed_version: None,
1333 }
1334 );
1335 }
1336
1337 #[test]
1338 fn staging_dir_is_per_version_under_the_cache_root() {
1339 let root = Path::new("cache");
1340 assert_eq!(
1341 staging_dir(root, "1.11.0"),
1342 Path::new("cache").join("updates").join("1.11.0")
1343 );
1344 }
1345}