Skip to main content

seshat_cli/
update.rs

1use std::fs;
2use std::io::{Read, Write};
3use std::path::{Path, PathBuf};
4use std::process::Command as ProcessCommand;
5use std::time::Duration;
6
7use crate::CliError;
8use crate::version_cache::VersionCache;
9use flate2::read::GzDecoder;
10use indicatif::{ProgressBar, ProgressStyle};
11use sha2::Digest;
12use tar::Archive;
13
14use sha2::Sha256;
15
16const GITHUB_RELEASES_API: &str = "https://api.github.com/repos/KSDaemon/seshat/releases/latest";
17const USER_AGENT: &str = "seshat";
18const TIMEOUT_SECS: u64 = 15;
19
20#[derive(Debug, PartialEq, Clone, Copy)]
21enum InstallMethod {
22    Homebrew,
23    Direct,
24}
25
26struct RateLimitInfo {
27    retry_after_minutes: u64,
28}
29
30pub fn run_update(check: bool) -> Result<(), CliError> {
31    if check {
32        run_check()
33    } else {
34        run_self_update()
35    }
36}
37
38pub fn check_and_print_update_notice() {
39    check_and_print_update_notice_inner(&VersionCache::cache_path());
40}
41
42fn check_and_print_update_notice_inner(cache_path: &Option<PathBuf>) {
43    let current = env!("CARGO_PKG_VERSION");
44
45    if let Some(path) = cache_path {
46        if let Some(cache) = VersionCache::read_from_path(path) {
47            if cache.is_fresh() {
48                if cache.has_assets == Some(false) {
49                    return;
50                }
51                if is_newer(&cache.latest_version, current) {
52                    eprintln!(
53                        "Seshat v{} is available (current: v{current}). Run seshat update to upgrade.",
54                        cache.latest_version
55                    );
56                }
57                return;
58            }
59        }
60    }
61
62    let (version, has_assets) = match fetch_latest_release() {
63        Ok(result) => result,
64        Err(_) => return,
65    };
66
67    if let Some(path) = cache_path {
68        let cache = if has_assets {
69            VersionCache::with_assets(version.clone(), true)
70        } else {
71            VersionCache::with_assets(current.to_owned(), false)
72        };
73        let _ = cache.write_to_path(path);
74    }
75
76    if !has_assets {
77        return;
78    }
79
80    if is_newer(&version, current) {
81        eprintln!(
82            "Seshat v{version} is available (current: v{current}). Run seshat update to upgrade."
83        );
84    }
85}
86
87fn run_self_update() -> Result<(), CliError> {
88    let install_method = detect_install_method()?;
89    if install_method == InstallMethod::Homebrew {
90        eprintln!("Seshat was installed via Homebrew. Run brew upgrade seshat to update.");
91        return Err(CliError::CommandFailed {
92            command: "update".to_owned(),
93            reason: "installed via Homebrew".to_owned(),
94        });
95    }
96
97    let current = env!("CARGO_PKG_VERSION");
98
99    let release_assets = fetch_release_assets()?;
100    let (version, asset_url, checksums_url) = match release_assets {
101        Some(assets) => assets,
102        None => {
103            println!("Seshat is up to date (v{current}).");
104            return Ok(());
105        }
106    };
107
108    if !is_newer(&version, current) {
109        println!("Seshat is already up to date (v{current}).");
110        return Ok(());
111    }
112
113    let expected_sha256 = fetch_checksum_for_asset(&checksums_url, &version)?;
114
115    let temp_dir = tempfile::TempDir::new().map_err(|e| CliError::CommandFailed {
116        command: "update".to_owned(),
117        reason: format!("failed to create temp directory: {e}"),
118    })?;
119
120    let download_path = temp_dir
121        .path()
122        .join(format!("seshat.{}", archive_extension(current_target())));
123    download_with_progress(&asset_url, &download_path)?;
124
125    verify_sha256(&download_path, &expected_sha256).inspect_err(|_| {
126        let _ = fs::remove_dir_all(temp_dir.path());
127    })?;
128
129    let binary_path =
130        extract_binary(&download_path, temp_dir.path(), &version).inspect_err(|_| {
131            let _ = fs::remove_dir_all(temp_dir.path());
132        })?;
133
134    preflight_check(&binary_path, temp_dir.path())?;
135
136    let target_exe = resolve_target_exe()?;
137
138    replace_binary(&binary_path, &target_exe, temp_dir.path())?;
139
140    if is_cargo_install() {
141        println!(
142            "Note: seshat was installed via cargo. You may want to run 'cargo install seshat' to keep ~/.cargo/.crates2.json in sync."
143        );
144    }
145
146    println!("Seshat updated to v{version}.");
147    Ok(())
148}
149
150fn detect_install_method() -> Result<InstallMethod, CliError> {
151    if cfg!(target_os = "windows") {
152        return Ok(InstallMethod::Direct);
153    }
154
155    let exe_path = std::env::current_exe().map_err(|e| CliError::CommandFailed {
156        command: "update".to_owned(),
157        reason: format!("cannot determine current executable: {e}"),
158    })?;
159
160    if exe_path.to_string_lossy().contains("/Cellar/") {
161        return Ok(InstallMethod::Homebrew);
162    }
163
164    if let Ok(canonical) = exe_path.canonicalize() {
165        if canonical.to_string_lossy().contains("/Cellar/") {
166            return Ok(InstallMethod::Homebrew);
167        }
168    }
169
170    Ok(InstallMethod::Direct)
171}
172
173fn fetch_release_assets() -> Result<Option<(String, String, String)>, CliError> {
174    let agent = build_agent();
175
176    let response = agent
177        .get(GITHUB_RELEASES_API)
178        .header("User-Agent", USER_AGENT)
179        .call()
180        .map_err(|e| CliError::CommandFailed {
181            command: "update".to_owned(),
182            reason: format!("failed to fetch release info: {e}"),
183        })?;
184
185    let status = response.status().into();
186    let headers = response.headers().clone();
187    check_response_status(status, &headers)?;
188
189    let body = response
190        .into_body()
191        .read_to_string()
192        .map_err(|e| CliError::CommandFailed {
193            command: "update".to_owned(),
194            reason: format!("failed to read release info: {e}"),
195        })?;
196
197    let json: serde_json::Value =
198        serde_json::from_str(&body).map_err(|e| CliError::CommandFailed {
199            command: "update".to_owned(),
200            reason: format!("failed to parse release info: {e}"),
201        })?;
202
203    let tag_name = json["tag_name"].as_str().unwrap_or("v0.0.0");
204    let version = tag_name.strip_prefix('v').unwrap_or(tag_name).to_owned();
205
206    let target = current_target();
207    if target == "unsupported" {
208        return Err(CliError::CommandFailed {
209            command: "update".to_owned(),
210            reason: "unsupported platform for self-update".to_owned(),
211        });
212    }
213
214    let assets = json["assets"]
215        .as_array()
216        .ok_or_else(|| CliError::CommandFailed {
217            command: "update".to_owned(),
218            reason: "no assets found in release".to_owned(),
219        })?;
220
221    let checksums_url = find_checksums_url(assets, &version)?;
222
223    let binary_asset = find_binary_asset(assets, target, &version);
224    match binary_asset {
225        Some((_, asset_url)) => Ok(Some((version, asset_url, checksums_url))),
226        None => Ok(None),
227    }
228}
229
230fn find_checksums_url(assets: &[serde_json::Value], version: &str) -> Result<String, CliError> {
231    let mut best: Option<String> = None;
232
233    for asset in assets {
234        let name = asset["name"].as_str().unwrap_or("");
235        if name == "sha256sums.txt" || name.contains("sha256sums") {
236            let url = asset["browser_download_url"]
237                .as_str()
238                .map(|u| u.to_owned())
239                .ok_or_else(|| CliError::CommandFailed {
240                    command: "update".to_owned(),
241                    reason: "no download URL for checksums file".to_owned(),
242                })?;
243
244            if name.contains(version) {
245                return Ok(url);
246            }
247            if best.is_none() {
248                best = Some(url);
249            }
250        }
251    }
252
253    best.ok_or_else(|| CliError::CommandFailed {
254        command: "update".to_owned(),
255        reason: "checksums file not found in release assets".to_owned(),
256    })
257}
258
259fn find_binary_asset(
260    assets: &[serde_json::Value],
261    target: &str,
262    version: &str,
263) -> Option<(String, String)> {
264    // Match the exact canonical archive name produced by `release.yml`:
265    // `seshat-{target}-v{version}.{ext}`. The previous matcher was a loose
266    // `name.contains(target) && extension_match`, which allowed sibling
267    // artefacts (`...-pdb.zip` debug-symbol bundles, `...-debug.tar.gz`,
268    // ordering-dependent shadowing) to pre-empt the real binary on the
269    // first-match-wins iteration. A strict equality match means the only
270    // way to ship a binary is to upload it under its canonical name.
271    let expected = format!(
272        "seshat-{target}-v{version}.{ext}",
273        ext = archive_extension(target),
274    );
275    let expected_lower = expected.to_ascii_lowercase();
276
277    assets.iter().find_map(|asset| {
278        let name = asset["name"].as_str().unwrap_or("");
279        if name.to_ascii_lowercase() == expected_lower {
280            let url = asset["browser_download_url"].as_str()?;
281            Some((name.to_owned(), url.to_owned()))
282        } else {
283            None
284        }
285    })
286}
287
288fn fetch_checksum_for_asset(checksums_url: &str, version: &str) -> Result<String, CliError> {
289    let agent = build_agent();
290
291    let response = agent
292        .get(checksums_url)
293        .header("User-Agent", USER_AGENT)
294        .call()
295        .map_err(|e| CliError::CommandFailed {
296            command: "update".to_owned(),
297            reason: format!("failed to download checksums: {e}"),
298        })?;
299
300    let status = response.status().into();
301    let headers = response.headers().clone();
302    check_response_status(status, &headers)?;
303
304    let body = response
305        .into_body()
306        .read_to_string()
307        .map_err(|e| CliError::CommandFailed {
308            command: "update".to_owned(),
309            reason: format!("failed to read checksums: {e}"),
310        })?;
311
312    let target = current_target();
313    let extension = archive_extension(target);
314    let expected_archive = format!("seshat-{target}-v{version}.{extension}");
315
316    for line in body.lines() {
317        let mut trimmed = line.trim();
318        if let Some(stripped) = trimmed.strip_prefix('*') {
319            trimmed = stripped;
320        }
321        if let Some((hex, filename)) = trimmed.split_once([' ', '\t']) {
322            let filename = filename.trim();
323            if filename == expected_archive || filename.ends_with(&expected_archive) {
324                return Ok(hex.to_owned());
325            }
326        }
327    }
328
329    Err(CliError::CommandFailed {
330        command: "update".to_owned(),
331        reason: format!("checksum not found for {expected_archive}"),
332    })
333}
334
335fn download_with_progress(url: &str, dest: &Path) -> Result<(), CliError> {
336    let agent = build_agent();
337
338    let response = agent
339        .get(url)
340        .header("User-Agent", USER_AGENT)
341        .call()
342        .map_err(|e| CliError::CommandFailed {
343            command: "update".to_owned(),
344            reason: format!("failed to download binary: {e}"),
345        })?;
346
347    let status = response.status().into();
348    let headers = response.headers().clone();
349    check_response_status(status, &headers)?;
350
351    let total_size = response
352        .headers()
353        .get("Content-Length")
354        .and_then(|v| v.to_str().ok().and_then(|s| s.parse().ok()))
355        .unwrap_or(0u64);
356
357    let style = if total_size > 0 {
358        ProgressBar::new(total_size)
359    } else {
360        ProgressBar::new_spinner()
361    };
362    let pb = style;
363    if total_size > 0 {
364        pb.set_style(
365            ProgressStyle::with_template(
366                "{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})",
367            )
368            .unwrap()
369            .progress_chars("#>-"),
370        );
371    } else {
372        pb.set_style(
373            ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] {bytes} (? eta)")
374                .unwrap(),
375        );
376    }
377
378    let mut file = fs::File::create(dest).map_err(|e| CliError::CommandFailed {
379        command: "update".to_owned(),
380        reason: format!("failed to create download file: {e}"),
381    })?;
382
383    let mut reader = response.into_body().into_reader();
384    let mut downloaded = 0u64;
385
386    loop {
387        let mut buf = [0u8; 8192];
388        let read = reader.read(&mut buf).map_err(|e| CliError::CommandFailed {
389            command: "update".to_owned(),
390            reason: format!("download interrupted: {e}"),
391        })?;
392        if read == 0 {
393            break;
394        }
395        file.write_all(&buf[..read])
396            .map_err(|e| CliError::CommandFailed {
397                command: "update".to_owned(),
398                reason: format!("failed to write download: {e}"),
399            })?;
400        downloaded += read as u64;
401        if total_size > 0 {
402            pb.set_position(downloaded);
403        } else {
404            pb.set_message(format!("Downloaded {downloaded} bytes"));
405        }
406    }
407
408    if downloaded == 0 {
409        let _ = fs::remove_file(dest);
410        return Err(CliError::CommandFailed {
411            command: "update".to_owned(),
412            reason: "downloaded file is empty (0 bytes)".to_owned(),
413        });
414    }
415
416    pb.finish_with_message("Download complete");
417    Ok(())
418}
419
420fn verify_sha256(file_path: &Path, expected: &str) -> Result<(), CliError> {
421    let mut file = fs::File::open(file_path).map_err(|e| CliError::CommandFailed {
422        command: "update".to_owned(),
423        reason: format!("cannot open file for verification: {e}"),
424    })?;
425
426    let mut hasher = Sha256::new();
427    let mut buf = [0u8; 8192];
428    loop {
429        let n = file.read(&mut buf).map_err(|e| CliError::CommandFailed {
430            command: "update".to_owned(),
431            reason: format!("failed to read file for hashing: {e}"),
432        })?;
433        if n == 0 {
434            break;
435        }
436        hasher.update(&buf[..n]);
437    }
438
439    let hash = hasher.finalize();
440    let mut computed = String::with_capacity(hash.len() * 2);
441    for byte in hash {
442        use std::fmt::Write;
443        let _ = write!(computed, "{byte:02x}");
444    }
445
446    if computed.eq_ignore_ascii_case(expected) {
447        Ok(())
448    } else {
449        Err(CliError::CommandFailed {
450            command: "update".to_owned(),
451            reason: format!("SHA256 mismatch: expected {expected}, computed {computed}"),
452        })
453    }
454}
455
456fn extract_binary(
457    archive_path: &Path,
458    dest_dir: &Path,
459    version: &str,
460) -> Result<PathBuf, CliError> {
461    let archive_file = fs::File::open(archive_path).map_err(|e| CliError::CommandFailed {
462        command: "update".to_owned(),
463        reason: format!("failed to open archive for extraction: {e}"),
464    })?;
465
466    let name = archive_path
467        .file_name()
468        .and_then(|n| n.to_str())
469        .unwrap_or("");
470    if name.ends_with(".zip") {
471        extract_zip(archive_file, dest_dir)?;
472    } else {
473        extract_tar_gz(archive_file, dest_dir)?;
474    }
475
476    let target = current_target();
477    let expected_dir = format!("seshat-{target}-v{version}");
478    let binary_path = dest_dir
479        .join(&expected_dir)
480        .join(format!("seshat{}", std::env::consts::EXE_SUFFIX));
481
482    if !binary_path.is_file() {
483        return Err(CliError::CommandFailed {
484            command: "update".to_owned(),
485            reason: format!(
486                "extracted binary not found at expected path: {}",
487                binary_path.display()
488            ),
489        });
490    }
491
492    set_executable(&binary_path)?;
493
494    Ok(binary_path)
495}
496
497fn extract_tar_gz(archive_file: fs::File, dest_dir: &Path) -> Result<(), CliError> {
498    let decoder = GzDecoder::new(archive_file);
499    let mut archive = Archive::new(decoder);
500
501    for entry in archive.entries().map_err(|e| CliError::CommandFailed {
502        command: "update".to_owned(),
503        reason: format!("failed to read archive entries: {e}"),
504    })? {
505        let mut entry = entry.map_err(|e| CliError::CommandFailed {
506            command: "update".to_owned(),
507            reason: format!("failed to read archive entry: {e}"),
508        })?;
509
510        let path = entry.path().map_err(|e| CliError::CommandFailed {
511            command: "update".to_owned(),
512            reason: format!("failed to resolve archive entry path: {e}"),
513        })?;
514
515        if path.as_os_str().is_empty() {
516            continue;
517        }
518
519        if path
520            .components()
521            .any(|c| matches!(c, std::path::Component::ParentDir))
522        {
523            continue;
524        }
525
526        let abs_path = dest_dir.join(&path);
527        let Ok(canonical) = abs_path.canonicalize() else {
528            entry
529                .unpack_in(dest_dir)
530                .map_err(|e| CliError::CommandFailed {
531                    command: "update".to_owned(),
532                    reason: format!("failed to extract entry: {e}"),
533                })?;
534            continue;
535        };
536
537        if !canonical.starts_with(dest_dir) {
538            continue;
539        }
540
541        entry
542            .unpack_in(dest_dir)
543            .map_err(|e| CliError::CommandFailed {
544                command: "update".to_owned(),
545                reason: format!("failed to extract entry: {e}"),
546            })?;
547    }
548
549    Ok(())
550}
551
552/// Verify that `abs_path` resolves inside `canonical_dest_dir`, even when the
553/// leaf or some ancestors do not yet exist on disk.
554///
555/// The previous guard called `abs_path.canonicalize()` directly, which returns
556/// `Err` for paths whose final component is missing — and the surrounding
557/// `if let Ok(_) = ...` silently skipped the check. That left two real attack
558/// shapes uncovered:
559///
560/// 1. **Symlink-via-zip**: a previous entry creates `dest_dir/link -> /etc`,
561///    and a later entry `link/file` resolves through the symlink.
562/// 2. **Brand-new escape paths**: an entry whose parent directory does not yet
563///    exist sails past the existence-gated check entirely.
564///
565/// This helper walks the ancestor chain bottom-up to find the deepest
566/// component that *does* exist, canonicalises that ancestor (which follows
567/// symlinks), and rejects the entry unless the canonical form still lives
568/// under `canonical_dest_dir`. `dest_dir` always exists, so the loop is
569/// bounded.
570fn path_stays_inside_dest(abs_path: &Path, canonical_dest_dir: &Path) -> bool {
571    let mut probe: &Path = abs_path;
572    loop {
573        if probe.exists() {
574            return match probe.canonicalize() {
575                Ok(canonical) => canonical.starts_with(canonical_dest_dir),
576                Err(_) => false,
577            };
578        }
579        match probe.parent() {
580            Some(parent) if !parent.as_os_str().is_empty() => probe = parent,
581            _ => return false,
582        }
583    }
584}
585
586/// Hard cap on per-entry decompressed size for zip extraction. Defends
587/// against zip-bomb release artefacts: a small archive with gigabytes of
588/// decompressed payload would otherwise exhaust disk before SHA256
589/// verification mattered. The Windows release `.zip` ships a single
590/// `seshat.exe` measured in tens of MB; 256 MiB leaves generous headroom
591/// for a debug binary or future bundled assets.
592const MAX_ZIP_ENTRY_SIZE: u64 = 256 * 1024 * 1024;
593
594fn extract_zip(archive_file: fs::File, dest_dir: &Path) -> Result<(), CliError> {
595    extract_zip_with_limit(archive_file, dest_dir, MAX_ZIP_ENTRY_SIZE)
596}
597
598fn extract_zip_with_limit(
599    archive_file: fs::File,
600    dest_dir: &Path,
601    max_entry_size: u64,
602) -> Result<(), CliError> {
603    let mut archive = zip::ZipArchive::new(archive_file).map_err(|e| CliError::CommandFailed {
604        command: "update".to_owned(),
605        reason: format!("failed to read zip archive: {e}"),
606    })?;
607
608    let canonical_dest_dir = dest_dir
609        .canonicalize()
610        .map_err(|e| CliError::CommandFailed {
611            command: "update".to_owned(),
612            reason: format!("failed to canonicalise extraction directory: {e}"),
613        })?;
614
615    for i in 0..archive.len() {
616        let mut entry = archive.by_index(i).map_err(|e| CliError::CommandFailed {
617            command: "update".to_owned(),
618            reason: format!("failed to read zip entry: {e}"),
619        })?;
620
621        let raw_name = entry.name().to_owned();
622        if raw_name.is_empty() {
623            continue;
624        }
625
626        let entry_path = match entry.enclosed_name() {
627            Some(p) => p,
628            None => continue,
629        };
630
631        if entry_path.as_os_str().is_empty() {
632            continue;
633        }
634
635        if entry_path
636            .components()
637            .any(|c| matches!(c, std::path::Component::ParentDir))
638        {
639            continue;
640        }
641
642        let abs_path = dest_dir.join(&entry_path);
643        if !path_stays_inside_dest(&abs_path, &canonical_dest_dir) {
644            continue;
645        }
646
647        // Skip symlink entries unconditionally. The release pipeline never
648        // emits symlinks; honouring them would either materialise an actual
649        // symlink (a fresh escape vector for the next entry to traverse
650        // through) or, as the previous code did, write the link target as
651        // regular file content and apply symlink mode bits to a normal file
652        // — both incorrect.
653        if entry.is_symlink() {
654            continue;
655        }
656
657        if entry.is_dir() {
658            fs::create_dir_all(&abs_path).map_err(|e| CliError::CommandFailed {
659                command: "update".to_owned(),
660                reason: format!("failed to create directory: {e}"),
661            })?;
662            continue;
663        }
664
665        if let Some(parent) = abs_path.parent() {
666            fs::create_dir_all(parent).map_err(|e| CliError::CommandFailed {
667                command: "update".to_owned(),
668                reason: format!("failed to create directory: {e}"),
669            })?;
670        }
671
672        // Cheap pre-check on the declared size. Most legitimate archives
673        // report `entry.size()` accurately; a malicious archive that
674        // under-reports is still caught by the post-copy bounded check
675        // below (we copy at most max_entry_size + 1 bytes and abort if the
676        // overflow byte was consumed).
677        if entry.size() > max_entry_size {
678            return Err(CliError::CommandFailed {
679                command: "update".to_owned(),
680                reason: format!(
681                    "zip entry exceeds maximum decompressed size of {max_entry_size} bytes \
682                     (entry declares {} bytes)",
683                    entry.size()
684                ),
685            });
686        }
687
688        let mut out = fs::File::create(&abs_path).map_err(|e| CliError::CommandFailed {
689            command: "update".to_owned(),
690            reason: format!("failed to create extracted file: {e}"),
691        })?;
692        let written = {
693            // Scope the `Take` adapter so the `&mut entry` borrow ends before
694            // we reach `entry.unix_mode()` below.
695            let mut limited = (&mut entry).take(max_entry_size + 1);
696            std::io::copy(&mut limited, &mut out).map_err(|e| CliError::CommandFailed {
697                command: "update".to_owned(),
698                reason: format!("failed to extract zip entry: {e}"),
699            })?
700        };
701        if written > max_entry_size {
702            // Drop the partially-written file before reporting; otherwise a
703            // later fsync or the caller's cleanup could touch a half-written
704            // attacker-controlled blob.
705            drop(out);
706            let _ = fs::remove_file(&abs_path);
707            return Err(CliError::CommandFailed {
708                command: "update".to_owned(),
709                reason: format!(
710                    "zip entry exceeds maximum decompressed size of {max_entry_size} bytes"
711                ),
712            });
713        }
714
715        #[cfg(unix)]
716        if let Some(mode) = entry.unix_mode() {
717            use std::os::unix::fs::PermissionsExt;
718            let _ = fs::set_permissions(&abs_path, fs::Permissions::from_mode(mode));
719        }
720    }
721
722    Ok(())
723}
724
725#[cfg(unix)]
726fn set_executable(path: &Path) -> Result<(), CliError> {
727    use std::os::unix::fs::PermissionsExt;
728    let metadata = fs::metadata(path).map_err(|e| CliError::CommandFailed {
729        command: "update".to_owned(),
730        reason: format!("failed to read binary metadata: {e}"),
731    })?;
732    let mut perms = metadata.permissions();
733    perms.set_mode(0o755);
734    fs::set_permissions(path, perms).map_err(|e| CliError::CommandFailed {
735        command: "update".to_owned(),
736        reason: format!("failed to set executable permission: {e}"),
737    })?;
738    Ok(())
739}
740
741#[cfg(not(unix))]
742fn set_executable(_path: &Path) -> Result<(), CliError> {
743    Ok(())
744}
745
746fn preflight_check(binary_path: &Path, temp_dir: &Path) -> Result<(), CliError> {
747    let output = ProcessCommand::new(binary_path)
748        .arg("--version")
749        .output()
750        .map_err(|e| {
751            let _ = fs::remove_dir_all(temp_dir);
752            CliError::CommandFailed {
753                command: "update".to_owned(),
754                reason: format!("failed to run extracted binary: {e}"),
755            }
756        })?;
757
758    if output.status.success() {
759        return Ok(());
760    }
761
762    #[cfg(unix)]
763    {
764        use std::os::unix::process::ExitStatusExt;
765        match output.status.signal() {
766            Some(9) => {
767                let _ = fs::remove_dir_all(temp_dir);
768                eprintln!(
769                    "macOS Gatekeeper blocked the update binary. Remove quarantine with:\n  xattr -d com.apple.quarantine {}",
770                    binary_path.display()
771                );
772                return Err(CliError::CommandFailed {
773                    command: "update".to_owned(),
774                    reason: "macOS Gatekeeper killed the binary (signal 9)".to_owned(),
775                });
776            }
777            Some(sig) => {
778                let _ = fs::remove_dir_all(temp_dir);
779                return Err(CliError::CommandFailed {
780                    command: "update".to_owned(),
781                    reason: format!("extracted binary terminated by signal {sig}"),
782                });
783            }
784            None => {}
785        }
786    }
787
788    let stderr = String::from_utf8_lossy(&output.stderr);
789    let stdout = String::from_utf8_lossy(&output.stdout);
790    if version_output_contains_seshat(&stdout) || version_output_contains_seshat(&stderr) {
791        return Ok(());
792    }
793
794    let _ = fs::remove_dir_all(temp_dir);
795    Err(CliError::CommandFailed {
796        command: "update".to_owned(),
797        reason: format!(
798            "extracted binary failed preflight: exit code {:?}",
799            output.status.code()
800        ),
801    })
802}
803
804fn version_output_contains_seshat(output: &str) -> bool {
805    let lower = output.to_lowercase();
806    if let Some(idx) = lower.find("seshat") {
807        let after = &lower[idx + "seshat".len()..];
808        return after
809            .trim_start()
810            .starts_with(|c: char| c.is_ascii_digit() || c == 'v');
811    }
812    false
813}
814
815fn resolve_target_exe() -> Result<PathBuf, CliError> {
816    let exe = std::env::current_exe().map_err(|e| CliError::CommandFailed {
817        command: "update".to_owned(),
818        reason: format!("cannot determine current executable: {e}"),
819    })?;
820
821    exe.canonicalize().map_err(|e| CliError::CommandFailed {
822        command: "update".to_owned(),
823        reason: format!("cannot resolve current executable path: {e}"),
824    })
825}
826
827fn replace_binary(new_binary: &Path, target_exe: &Path, temp_dir: &Path) -> Result<(), CliError> {
828    match self_replace::self_replace(new_binary) {
829        Ok(()) => Ok(()),
830        Err(e) => {
831            let _ = fs::remove_dir_all(temp_dir);
832            Err(map_replace_error(e, target_exe))
833        }
834    }
835}
836
837fn map_replace_error(e: std::io::Error, target_exe: &Path) -> CliError {
838    if e.kind() == std::io::ErrorKind::PermissionDenied {
839        #[cfg(windows)]
840        let hint = "Try running as Administrator.";
841        #[cfg(not(windows))]
842        let hint = "Try: sudo seshat update";
843        eprintln!(
844            "Permission denied updating {}. {hint}",
845            target_exe.display()
846        );
847        #[cfg(windows)]
848        let reason = "permission denied; try running as Administrator".to_owned();
849        #[cfg(not(windows))]
850        let reason = "permission denied; try sudo seshat update".to_owned();
851        CliError::CommandFailed {
852            command: "update".to_owned(),
853            reason,
854        }
855    } else {
856        CliError::CommandFailed {
857            command: "update".to_owned(),
858            reason: format!("failed to replace binary: {e}"),
859        }
860    }
861}
862
863/// Best-effort cleanup of a leftover `<current_exe>.old` from a prior
864/// Windows self-update.
865///
866/// On Windows, manual or recovery scenarios may leave a `seshat.exe.old`
867/// next to the running `seshat.exe` (the happy-path `self_replace::self_replace`
868/// flow already schedules its own relocated-binary deletion via the crate's
869/// `.__selfdelete__.exe` helper, so this is purely defensive). Errors are
870/// silently dropped — cleanup must never fail the user's command.
871///
872/// On Unix this is a no-op: atomic `rename(2)` semantics in `replace_binary`
873/// never leave a `.old` file behind, so there is nothing to probe for.
874pub fn cleanup_stale_old_binary() {
875    #[cfg(windows)]
876    if let Ok(current) = std::env::current_exe() {
877        cleanup_stale_old_binary_at(&current);
878    }
879}
880
881#[cfg(windows)]
882fn cleanup_stale_old_binary_at(current_exe: &Path) {
883    let mut stale: std::ffi::OsString = current_exe.as_os_str().to_owned();
884    stale.push(".old");
885    let _ = fs::remove_file(PathBuf::from(stale));
886}
887
888fn is_cargo_install() -> bool {
889    let cargo_dir = if let Ok(cargo_home) = std::env::var("CARGO_HOME") {
890        PathBuf::from(cargo_home)
891    } else if let Some(home) = dirs::home_dir() {
892        home.join(".cargo")
893    } else {
894        return false;
895    };
896
897    let crates2 = cargo_dir.join(".crates2.json");
898    if crates2.exists() {
899        if let Ok(content) = fs::read_to_string(&crates2) {
900            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
901                if cargo_json_contains_seshat(&json) {
902                    return true;
903                }
904            }
905        }
906    }
907
908    let crates_toml = cargo_dir.join(".crates.toml");
909    if crates_toml.exists() {
910        if let Ok(content) = fs::read_to_string(&crates_toml) {
911            if cargo_toml_contains_seshat(&content) {
912                return true;
913            }
914        }
915    }
916
917    false
918}
919
920fn cargo_json_contains_seshat(json: &serde_json::Value) -> bool {
921    if let Some(installs) = json.get("installs").and_then(|v| v.as_object()) {
922        return installs.keys().any(|k| k.starts_with("seshat "));
923    }
924    false
925}
926
927fn cargo_toml_contains_seshat(content: &str) -> bool {
928    for line in content.lines() {
929        let trimmed = line.trim();
930        if trimmed.is_empty() || trimmed.starts_with('[') {
931            continue;
932        }
933        if let Some((key, _)) = trimmed.split_once('=').or_else(|| trimmed.split_once(" =")) {
934            let key = key.trim().trim_matches('"');
935            if key.starts_with("seshat ") {
936                return true;
937            }
938        }
939    }
940    false
941}
942
943fn build_agent() -> ureq::Agent {
944    let config = ureq::Agent::config_builder()
945        .timeout_global(Some(Duration::from_secs(TIMEOUT_SECS)))
946        .build();
947    let agent: ureq::Agent = config.into();
948
949    if let Ok(token) = std::env::var("GITHUB_TOKEN") {
950        if !token.is_empty() {
951            return agent;
952        }
953    }
954
955    agent
956}
957
958fn check_response_status(status: u16, headers: &ureq::http::HeaderMap) -> Result<(), CliError> {
959    if status < 400 {
960        return Ok(());
961    }
962
963    if let Some(info) = parse_rate_limit(status, headers) {
964        return Err(CliError::CommandFailed {
965            command: "update".to_owned(),
966            reason: format!(
967                "rate limited by GitHub. Try again in {} minutes.",
968                info.retry_after_minutes
969            ),
970        });
971    }
972
973    let reason = if status == 404 {
974        "release not found (404)".to_owned()
975    } else if status >= 500 {
976        format!("GitHub server error (HTTP {status})")
977    } else {
978        format!("HTTP {status}")
979    };
980
981    Err(CliError::CommandFailed {
982        command: "update".to_owned(),
983        reason,
984    })
985}
986
987fn parse_rate_limit(status: u16, headers: &ureq::http::HeaderMap) -> Option<RateLimitInfo> {
988    if status != 403 && status != 429 {
989        return None;
990    }
991
992    let reset = headers
993        .get("x-ratelimit-reset")
994        .and_then(|v| v.to_str().ok())
995        .and_then(|v| v.parse::<u64>().ok())?;
996
997    let now = std::time::SystemTime::now()
998        .duration_since(std::time::UNIX_EPOCH)
999        .ok()?
1000        .as_secs();
1001
1002    let retry_after_minutes = if reset > now {
1003        ((reset - now) / 60).max(1)
1004    } else {
1005        1
1006    };
1007
1008    Some(RateLimitInfo {
1009        retry_after_minutes,
1010    })
1011}
1012
1013fn run_check() -> Result<(), CliError> {
1014    run_check_inner(&VersionCache::cache_path())
1015}
1016
1017fn run_check_inner(cache_path: &Option<PathBuf>) -> Result<(), CliError> {
1018    if let Some(path) = cache_path {
1019        if let Some(cache) = VersionCache::read_from_path(path) {
1020            if cache.is_fresh() && cache.has_assets != Some(false) {
1021                return print_update_status(&cache.latest_version);
1022            }
1023        }
1024    }
1025
1026    match fetch_latest_release() {
1027        Ok((version, has_assets)) => {
1028            if let Some(path) = cache_path {
1029                let cache = if has_assets {
1030                    VersionCache::with_assets(version.clone(), true)
1031                } else {
1032                    VersionCache::with_assets(env!("CARGO_PKG_VERSION").to_owned(), false)
1033                };
1034                let _ = cache.write_to_path(path);
1035            }
1036
1037            if has_assets {
1038                print_update_status(&version)
1039            } else {
1040                println!("Seshat is up to date (v{}).", env!("CARGO_PKG_VERSION"));
1041                Ok(())
1042            }
1043        }
1044        Err(e) => {
1045            eprintln!("Could not check for updates: {e}");
1046            Err(CliError::CommandFailed {
1047                command: "update".to_owned(),
1048                reason: e,
1049            })
1050        }
1051    }
1052}
1053
1054fn print_update_status(latest_version: &str) -> Result<(), CliError> {
1055    let current = env!("CARGO_PKG_VERSION");
1056
1057    if is_newer(latest_version, current) {
1058        if detect_homebrew() {
1059            println!(
1060                "Seshat v{latest_version} is available. You installed via Homebrew. Run brew upgrade seshat."
1061            );
1062        } else {
1063            println!(
1064                "Seshat v{latest_version} is available (current: v{current}). Run seshat update to upgrade."
1065            );
1066        }
1067    } else {
1068        println!("Seshat is up to date (v{current}).");
1069    }
1070
1071    Ok(())
1072}
1073
1074fn fetch_latest_release() -> Result<(String, bool), String> {
1075    let agent = build_agent();
1076
1077    let response = agent
1078        .get(GITHUB_RELEASES_API)
1079        .header("User-Agent", USER_AGENT)
1080        .call()
1081        .map_err(|e| format!("network error: {e}"))?;
1082
1083    let status = response.status().into();
1084    let headers = response.headers().clone();
1085
1086    if status >= 400 {
1087        if let Some(info) = parse_rate_limit(status, &headers) {
1088            return Err(format!(
1089                "rate limited by GitHub. Try again in {} minutes.",
1090                info.retry_after_minutes
1091            ));
1092        }
1093        if status == 404 {
1094            return Err("release not found (404)".to_owned());
1095        }
1096        return Err(format!("HTTP {status}"));
1097    }
1098
1099    let body = response
1100        .into_body()
1101        .read_to_string()
1102        .map_err(|e| format!("failed to read response: {e}"))?;
1103
1104    let json: serde_json::Value =
1105        serde_json::from_str(&body).map_err(|e| format!("failed to parse response: {e}"))?;
1106
1107    // Check for GitHub error payload
1108    if let Some(msg) = json.get("message").and_then(|v| v.as_str()) {
1109        return Err(format!("GitHub API error: {msg}"));
1110    }
1111
1112    let tag_name = json["tag_name"].as_str().unwrap_or("v0.0.0");
1113    let version = tag_name.strip_prefix('v').unwrap_or(tag_name);
1114
1115    let has_assets = has_binary_asset_for_current_target(&json);
1116
1117    Ok((version.to_owned(), has_assets))
1118}
1119
1120fn is_newer(latest: &str, current: &str) -> bool {
1121    let parse =
1122        |v: &str| -> Vec<u32> { v.split('.').filter_map(|p| p.parse::<u32>().ok()).collect() };
1123
1124    let latest_parts = parse(latest);
1125    let current_parts = parse(current);
1126
1127    if latest_parts.is_empty() || current_parts.is_empty() {
1128        return false;
1129    }
1130
1131    for (l, c) in latest_parts.iter().zip(current_parts.iter()) {
1132        if l > c {
1133            return true;
1134        }
1135        if l < c {
1136            return false;
1137        }
1138    }
1139
1140    latest_parts.len() > current_parts.len()
1141}
1142
1143fn current_target() -> &'static str {
1144    let arch = std::env::consts::ARCH;
1145    let os = std::env::consts::OS;
1146    match (arch, os) {
1147        ("aarch64", "macos") => "aarch64-apple-darwin",
1148        ("x86_64", "macos") => "x86_64-apple-darwin",
1149        ("x86_64", "linux") => {
1150            if is_musl() {
1151                "x86_64-unknown-linux-musl"
1152            } else {
1153                "x86_64-unknown-linux-gnu"
1154            }
1155        }
1156        ("aarch64", "linux") => {
1157            if is_musl() {
1158                "aarch64-unknown-linux-musl"
1159            } else {
1160                "aarch64-unknown-linux-gnu"
1161            }
1162        }
1163        ("x86_64", "windows") => "x86_64-pc-windows-msvc",
1164        _ => "unsupported",
1165    }
1166}
1167
1168/// Archive extension for the release artifact of `target`.
1169///
1170/// Centralises the "is this a zip target?" predicate so the download path,
1171/// checksum lookup, and asset matcher cannot drift out of sync. Returns
1172/// `"zip"` for Windows MSVC targets (which are packaged via `7z` in
1173/// `release.yml`), and `"tar.gz"` for all Unix targets.
1174fn archive_extension(target: &str) -> &'static str {
1175    if target.ends_with("windows-msvc") {
1176        "zip"
1177    } else {
1178        "tar.gz"
1179    }
1180}
1181
1182fn is_musl() -> bool {
1183    #[cfg(target_os = "linux")]
1184    {
1185        std::fs::read_dir("/lib")
1186            .ok()
1187            .and_then(|entries| {
1188                for entry in entries.flatten() {
1189                    let name = entry.file_name();
1190                    if let Some(name_str) = name.to_str() {
1191                        if name_str.contains("ld-musl") {
1192                            return Some(true);
1193                        }
1194                    }
1195                }
1196                None
1197            })
1198            .unwrap_or(false)
1199    }
1200    #[cfg(not(target_os = "linux"))]
1201    {
1202        false
1203    }
1204}
1205
1206fn has_binary_asset_for_current_target(json: &serde_json::Value) -> bool {
1207    let target = current_target();
1208    if target == "unsupported" {
1209        return false;
1210    }
1211
1212    if let Some(assets) = json["assets"].as_array() {
1213        assets.iter().any(|asset| {
1214            asset["name"]
1215                .as_str()
1216                .is_some_and(|name| name.contains(target))
1217        })
1218    } else {
1219        false
1220    }
1221}
1222
1223fn detect_homebrew() -> bool {
1224    match std::env::current_exe() {
1225        Ok(path) => {
1226            if path.to_string_lossy().contains("/Cellar/") {
1227                return true;
1228            }
1229            if let Ok(canonical) = path.canonicalize() {
1230                canonical.to_string_lossy().contains("/Cellar/")
1231            } else {
1232                false
1233            }
1234        }
1235        Err(_) => false,
1236    }
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241    use super::*;
1242
1243    #[test]
1244    fn newer_major_version() {
1245        assert!(is_newer("2.0.0", "1.0.0"));
1246    }
1247
1248    #[test]
1249    fn older_major_version() {
1250        assert!(!is_newer("1.0.0", "2.0.0"));
1251    }
1252
1253    #[test]
1254    fn same_version() {
1255        assert!(!is_newer("1.0.0", "1.0.0"));
1256    }
1257
1258    #[test]
1259    fn newer_minor_version() {
1260        assert!(is_newer("1.1.0", "1.0.0"));
1261    }
1262
1263    #[test]
1264    fn newer_patch_version() {
1265        assert!(is_newer("1.0.1", "1.0.0"));
1266    }
1267
1268    #[test]
1269    fn newer_with_extra_component() {
1270        assert!(is_newer("1.0.0.1", "1.0.0"));
1271    }
1272
1273    #[test]
1274    fn older_with_fewer_components() {
1275        assert!(!is_newer("1.0", "1.0.0"));
1276    }
1277
1278    #[test]
1279    fn invalid_versions_compare_equal() {
1280        assert!(!is_newer("abc", "1.0.0"));
1281        assert!(!is_newer("1.0.0", "abc"));
1282    }
1283
1284    #[test]
1285    fn current_target_is_known_on_main_platforms() {
1286        let target = current_target();
1287        #[cfg(any(
1288            target_os = "macos",
1289            target_os = "linux",
1290            all(target_os = "windows", target_arch = "x86_64"),
1291        ))]
1292        assert_ne!(target, "unsupported");
1293        #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
1294        assert_eq!(target, "x86_64-pc-windows-msvc");
1295    }
1296
1297    #[test]
1298    fn archive_extension_matches_target_platform() {
1299        assert_eq!(archive_extension("x86_64-pc-windows-msvc"), "zip");
1300        assert_eq!(archive_extension("aarch64-pc-windows-msvc"), "zip");
1301        assert_eq!(archive_extension("x86_64-unknown-linux-gnu"), "tar.gz");
1302        assert_eq!(archive_extension("x86_64-unknown-linux-musl"), "tar.gz");
1303        assert_eq!(archive_extension("aarch64-apple-darwin"), "tar.gz");
1304        assert_eq!(archive_extension("x86_64-apple-darwin"), "tar.gz");
1305    }
1306
1307    /// Regression test for the hardcoded `seshat.tar.gz` download filename
1308    /// bug: `extract_binary` dispatches on the file extension, so the
1309    /// download path must use `.zip` on Windows-MSVC and `.tar.gz` elsewhere.
1310    /// If this invariant breaks, `extract_binary` will feed zip bytes to
1311    /// `GzDecoder` (or vice versa) and the user-facing update flow fails
1312    /// even though every fixture-based test still passes.
1313    #[test]
1314    fn download_filename_extension_matches_extract_dispatch() {
1315        for target in [
1316            "x86_64-pc-windows-msvc",
1317            "x86_64-unknown-linux-gnu",
1318            "x86_64-unknown-linux-musl",
1319            "aarch64-apple-darwin",
1320            "x86_64-apple-darwin",
1321        ] {
1322            let filename = format!("seshat.{}", archive_extension(target));
1323            if archive_extension(target) == "zip" {
1324                assert!(
1325                    filename.ends_with(".zip"),
1326                    "download filename for {target} must end with .zip"
1327                );
1328            } else {
1329                assert!(
1330                    filename.ends_with(".tar.gz"),
1331                    "download filename for {target} must end with .tar.gz"
1332                );
1333            }
1334        }
1335    }
1336
1337    #[test]
1338    fn has_binary_asset_returns_true_when_matching() {
1339        let target = current_target();
1340        if target == "unsupported" {
1341            return;
1342        }
1343        let json = serde_json::json!({
1344            "tag_name": "v1.0.0",
1345            "assets": [
1346                {"name": format!("seshat-{target}-v1.0.0.tar.gz")},
1347            ]
1348        });
1349        assert!(has_binary_asset_for_current_target(&json));
1350    }
1351
1352    #[test]
1353    fn has_binary_asset_returns_false_when_no_match() {
1354        let json = serde_json::json!({
1355            "tag_name": "v1.0.0",
1356            "assets": [
1357                {"name": "seshat-wasm32-unknown-unknown-v1.0.0.tar.gz"},
1358            ]
1359        });
1360        assert!(!has_binary_asset_for_current_target(&json));
1361    }
1362
1363    #[test]
1364    fn has_binary_asset_empty_assets() {
1365        let json = serde_json::json!({
1366            "tag_name": "v1.0.0",
1367            "assets": []
1368        });
1369        assert!(!has_binary_asset_for_current_target(&json));
1370    }
1371
1372    #[test]
1373    fn has_binary_asset_unsupported_target() {
1374        let json = serde_json::json!({
1375            "tag_name": "v1.0.0",
1376            "assets": [
1377                {"name": "seshat-some-target-v1.0.0.tar.gz"},
1378            ]
1379        });
1380        let _ = has_binary_asset_for_current_target(&json);
1381    }
1382
1383    #[test]
1384    fn detect_homebrew_is_bool() {
1385        let _ = detect_homebrew();
1386    }
1387
1388    #[test]
1389    fn fresh_cache_no_network() {
1390        let dir = tempfile::TempDir::new().unwrap();
1391        let cache_path = dir.path().join("version-check.json");
1392        let cache = VersionCache::new("99.99.99".to_owned());
1393        cache.write_to_path(&cache_path).unwrap();
1394
1395        let result = run_check_inner(&Some(cache_path));
1396        assert!(result.is_ok());
1397    }
1398
1399    #[test]
1400    fn detect_install_method_on_current_platform() {
1401        let method = detect_install_method();
1402        assert!(method.is_ok());
1403        assert_eq!(method.unwrap(), InstallMethod::Direct);
1404    }
1405
1406    #[test]
1407    fn install_method_enum_equality() {
1408        assert_eq!(InstallMethod::Homebrew, InstallMethod::Homebrew);
1409        assert_eq!(InstallMethod::Direct, InstallMethod::Direct);
1410        assert_ne!(InstallMethod::Homebrew, InstallMethod::Direct);
1411    }
1412
1413    #[test]
1414    fn sha256_verify_matching() {
1415        let dir = tempfile::TempDir::new().unwrap();
1416        let file_path = dir.path().join("test.bin");
1417        fs::write(&file_path, b"hello world").unwrap();
1418
1419        let mut hasher = Sha256::new();
1420        hasher.update(b"hello world");
1421        let hash = hasher.finalize();
1422        let mut hex = String::new();
1423        for byte in hash {
1424            use std::fmt::Write;
1425            let _ = write!(hex, "{byte:02x}");
1426        }
1427
1428        assert!(verify_sha256(&file_path, &hex).is_ok());
1429    }
1430
1431    #[test]
1432    fn sha256_verify_mismatch() {
1433        let dir = tempfile::TempDir::new().unwrap();
1434        let file_path = dir.path().join("test.bin");
1435        fs::write(&file_path, b"hello world").unwrap();
1436
1437        let result = verify_sha256(
1438            &file_path,
1439            "0000000000000000000000000000000000000000000000000000000000000000",
1440        );
1441        assert!(result.is_err());
1442        assert!(result.unwrap_err().to_string().contains("SHA256 mismatch"));
1443    }
1444
1445    // Unix-only: `extract_binary` exists to support the `seshat update`
1446    // self-update flow, whose `current_target()` only enumerates Unix
1447    // triples (Windows resolves to "unsupported"). The fixture archive
1448    // also encodes a `0o755` mode bit which tar's Windows backend cannot
1449    // round-trip cleanly. Coverage on Windows for this code path would
1450    // be misleading.
1451    #[cfg(unix)]
1452    #[test]
1453    fn extract_binary_from_valid_tar_gz() {
1454        let dir = tempfile::TempDir::new().unwrap();
1455        let archive_path = dir.path().join("test.tar.gz");
1456
1457        let file = fs::File::create(&archive_path).unwrap();
1458        let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
1459        let mut builder = tar::Builder::new(encoder);
1460
1461        let expected_dir = format!("seshat-{}-v1.0.0", current_target());
1462        let binary_dir = format!("{expected_dir}/seshat");
1463
1464        let mut header = tar::Header::new_gnu();
1465        header.set_entry_type(tar::EntryType::Directory);
1466        header.set_size(0);
1467        builder
1468            .append_data(&mut header, &expected_dir, &[][..])
1469            .unwrap();
1470
1471        let mut header = tar::Header::new_gnu();
1472        header.set_size(4);
1473        header.set_mode(0o755);
1474        builder
1475            .append_data(&mut header, &binary_dir, &b"fake"[..])
1476            .unwrap();
1477
1478        let archive_data = builder.into_inner().unwrap().finish().unwrap();
1479        drop(archive_data);
1480
1481        let result = extract_binary(&archive_path, dir.path(), "1.0.0");
1482        assert!(result.is_ok());
1483        let binary_path = result.unwrap();
1484        assert!(binary_path.is_file());
1485        assert!(binary_path.ends_with(format!("{expected_dir}/seshat")));
1486    }
1487
1488    #[test]
1489    fn extract_binary_corrupted_archive_errors() {
1490        let dir = tempfile::TempDir::new().unwrap();
1491        let archive_path = dir.path().join("corrupt.tar.gz");
1492        fs::write(&archive_path, b"not a valid gzip file").unwrap();
1493
1494        let result = extract_binary(&archive_path, dir.path(), "1.0.0");
1495        assert!(result.is_err());
1496    }
1497
1498    fn build_zip_archive(entries: &[(&str, &[u8])]) -> Vec<u8> {
1499        use std::io::Cursor;
1500        use zip::write::SimpleFileOptions;
1501
1502        let mut buf = Vec::new();
1503        {
1504            let cursor = Cursor::new(&mut buf);
1505            let mut writer = zip::ZipWriter::new(cursor);
1506            let opts =
1507                SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
1508            for (name, data) in entries {
1509                if name.ends_with('/') {
1510                    writer.add_directory(*name, opts).unwrap();
1511                } else {
1512                    writer.start_file(*name, opts).unwrap();
1513                    writer.write_all(data).unwrap();
1514                }
1515            }
1516            writer.finish().unwrap();
1517        }
1518        buf
1519    }
1520
1521    #[test]
1522    fn extract_binary_from_valid_zip() {
1523        let dir = tempfile::TempDir::new().unwrap();
1524        let archive_path = dir.path().join("test.zip");
1525
1526        let expected_dir = format!("seshat-{}-v1.0.0", current_target());
1527        let binary_in_zip = format!("{expected_dir}/seshat{}", std::env::consts::EXE_SUFFIX);
1528        let dir_entry = format!("{expected_dir}/");
1529
1530        let bytes = build_zip_archive(&[(&dir_entry, &[]), (&binary_in_zip, b"fake")]);
1531        fs::write(&archive_path, &bytes).unwrap();
1532
1533        let result = extract_binary(&archive_path, dir.path(), "1.0.0");
1534        assert!(result.is_ok(), "extract_binary failed: {result:?}");
1535        let binary_path = result.unwrap();
1536        assert!(binary_path.is_file());
1537        assert!(binary_path.ends_with(format!(
1538            "{expected_dir}/seshat{}",
1539            std::env::consts::EXE_SUFFIX
1540        )));
1541    }
1542
1543    #[test]
1544    fn extract_binary_corrupted_zip_errors() {
1545        let dir = tempfile::TempDir::new().unwrap();
1546        let archive_path = dir.path().join("corrupt.zip");
1547        fs::write(&archive_path, b"definitely not a zip file").unwrap();
1548
1549        let result = extract_binary(&archive_path, dir.path(), "1.0.0");
1550        assert!(result.is_err());
1551    }
1552
1553    #[test]
1554    fn extract_binary_zip_skips_path_traversal() {
1555        let dir = tempfile::TempDir::new().unwrap();
1556        let archive_path = dir.path().join("traversal.zip");
1557
1558        let traversal_name = format!("../escape/seshat{}", std::env::consts::EXE_SUFFIX);
1559        let bytes = build_zip_archive(&[(&traversal_name, b"evil")]);
1560        fs::write(&archive_path, &bytes).unwrap();
1561
1562        let result = extract_binary(&archive_path, dir.path(), "1.0.0");
1563        assert!(
1564            result.is_err(),
1565            "expected missing-binary error, got {result:?}"
1566        );
1567        let escape_path = dir
1568            .path()
1569            .parent()
1570            .unwrap()
1571            .join("escape")
1572            .join(format!("seshat{}", std::env::consts::EXE_SUFFIX));
1573        assert!(
1574            !escape_path.exists(),
1575            "traversal entry was extracted to {}",
1576            escape_path.display()
1577        );
1578    }
1579
1580    #[test]
1581    fn path_stays_inside_dest_accepts_normal_relative_paths() {
1582        let dir = tempfile::TempDir::new().unwrap();
1583        let canonical = dir.path().canonicalize().unwrap();
1584        let leaf = dir.path().join("subdir").join("file.txt");
1585        assert!(path_stays_inside_dest(&leaf, &canonical));
1586    }
1587
1588    #[test]
1589    fn path_stays_inside_dest_rejects_path_outside_dest() {
1590        let dir = tempfile::TempDir::new().unwrap();
1591        let canonical = dir.path().canonicalize().unwrap();
1592        let outside = std::env::temp_dir().join("definitely-not-in-dest");
1593        assert!(!path_stays_inside_dest(&outside, &canonical));
1594    }
1595
1596    #[cfg(unix)]
1597    #[test]
1598    fn path_stays_inside_dest_rejects_path_resolving_through_symlink() {
1599        let dir = tempfile::TempDir::new().unwrap();
1600        let outside = tempfile::TempDir::new().unwrap();
1601        let canonical = dir.path().canonicalize().unwrap();
1602
1603        std::os::unix::fs::symlink(outside.path(), dir.path().join("link")).unwrap();
1604
1605        // The leaf doesn't exist yet; the ancestor `link` does and resolves
1606        // outside `canonical_dest_dir`. The previous canonicalize-only guard
1607        // returned `Err` here and silently allowed the entry.
1608        let leaf = dir.path().join("link").join("payload.txt");
1609        assert!(!path_stays_inside_dest(&leaf, &canonical));
1610    }
1611
1612    /// Walk every regular file under `dir` and return their relative paths
1613    /// (sorted). Test helper for "no escape happened" assertions: the
1614    /// previous `extract_binary_zip_skips_path_traversal` only checked one
1615    /// specific escape destination, so a traversal that landed elsewhere
1616    /// would silently pass.
1617    fn collect_regular_files(dir: &Path) -> Vec<PathBuf> {
1618        fn walk(d: &Path, out: &mut Vec<PathBuf>, root: &Path) {
1619            let entries = match fs::read_dir(d) {
1620                Ok(e) => e,
1621                Err(_) => return,
1622            };
1623            for entry in entries.flatten() {
1624                let path = entry.path();
1625                let metadata = match fs::symlink_metadata(&path) {
1626                    Ok(m) => m,
1627                    Err(_) => continue,
1628                };
1629                if metadata.file_type().is_dir() {
1630                    walk(&path, out, root);
1631                } else if metadata.file_type().is_file() {
1632                    if let Ok(rel) = path.strip_prefix(root) {
1633                        out.push(rel.to_path_buf());
1634                    }
1635                }
1636            }
1637        }
1638        let mut out = Vec::new();
1639        walk(dir, &mut out, dir);
1640        out.sort();
1641        out
1642    }
1643
1644    /// Stronger version of the original traversal test: assert via a full
1645    /// filesystem walk that the zip's malicious `../escape/...` entry left
1646    /// nothing inside `dest_dir` and that no file appeared anywhere on the
1647    /// containing temp parent. The original test only probed a single hard-
1648    /// coded escape destination.
1649    #[test]
1650    fn extract_zip_path_traversal_leaves_no_files_anywhere() {
1651        let dir = tempfile::TempDir::new().unwrap();
1652        let bytes = build_zip_archive(&[("../escape/seshat.bin", b"evil")]);
1653        let archive_path = dir.path().join("traversal.zip");
1654        fs::write(&archive_path, &bytes).unwrap();
1655
1656        let archive_file = fs::File::open(&archive_path).unwrap();
1657        extract_zip(archive_file, dir.path()).unwrap();
1658
1659        // Inside dest_dir: only the archive file we just wrote.
1660        let inside = collect_regular_files(dir.path());
1661        assert_eq!(
1662            inside,
1663            vec![PathBuf::from("traversal.zip")],
1664            "unexpected files inside dest_dir after traversal attempt: {inside:?}"
1665        );
1666
1667        // Outside dest_dir but still under the temp parent: nothing under
1668        // any sibling `escape` directory the malicious entry might have
1669        // created.
1670        if let Some(parent) = dir.path().parent() {
1671            let escape_root = parent.join("escape");
1672            assert!(
1673                !escape_root.exists(),
1674                "traversal entry materialised under {}",
1675                escape_root.display()
1676            );
1677        }
1678    }
1679
1680    /// `..` components in the middle of an entry path get normalised by the
1681    /// zip writer at archive-creation time (`good/../bad/file` -> `bad/file`).
1682    /// The extractor sees only the normalised path, which lands safely
1683    /// inside `dest_dir`. This test locks that behaviour: nothing escapes,
1684    /// and the file appears at its post-normalisation location.
1685    #[test]
1686    fn extract_zip_handles_normalised_parent_dir_in_middle() {
1687        let dir = tempfile::TempDir::new().unwrap();
1688        let bytes = build_zip_archive(&[("good/../bad/seshat.bin", b"ok")]);
1689        let archive_path = dir.path().join("midtraversal.zip");
1690        fs::write(&archive_path, &bytes).unwrap();
1691
1692        let archive_file = fs::File::open(&archive_path).unwrap();
1693        extract_zip(archive_file, dir.path()).unwrap();
1694
1695        // Only files that resolve inside `dest_dir`. `bad/seshat.bin` is the
1696        // post-normalisation location; nothing must appear at `good/...` or
1697        // outside `dest_dir`.
1698        let inside = collect_regular_files(dir.path());
1699        assert!(inside.contains(&PathBuf::from("bad/seshat.bin")));
1700        assert!(!inside.iter().any(|p| p.starts_with("good")));
1701    }
1702
1703    /// macOS `/tmp` is a symlink to `/private/tmp`. `path_stays_inside_dest`
1704    /// canonicalises both `dest_dir` and the probe ancestor, so the
1705    /// comparison must succeed for legitimate entries even when the caller
1706    /// passed a non-canonical `dest_dir`. This test would catch a regression
1707    /// where the canonicalisation drift made every Linux/macOS extraction
1708    /// fail.
1709    #[test]
1710    fn extract_zip_succeeds_when_dest_dir_path_is_non_canonical() {
1711        let dir = tempfile::TempDir::new().unwrap();
1712        // Write a deeply-nested entry whose ancestors don't exist yet.
1713        let bytes = build_zip_archive(&[
1714            ("nested/", b""),
1715            ("nested/sub/", b""),
1716            ("nested/sub/seshat.bin", b"ok"),
1717        ]);
1718        let archive_path = dir.path().join("nested.zip");
1719        fs::write(&archive_path, &bytes).unwrap();
1720
1721        let archive_file = fs::File::open(&archive_path).unwrap();
1722        extract_zip(archive_file, dir.path()).unwrap();
1723
1724        assert!(
1725            dir.path()
1726                .join("nested")
1727                .join("sub")
1728                .join("seshat.bin")
1729                .is_file()
1730        );
1731    }
1732
1733    /// Build a zip archive containing a single symlink entry. Used to verify
1734    /// `extract_zip` skips symlink entries instead of materialising them.
1735    fn build_zip_with_symlink(name: &str, target: &str) -> Vec<u8> {
1736        use std::io::Cursor;
1737        use zip::write::SimpleFileOptions;
1738
1739        let mut buf = Vec::new();
1740        {
1741            let cursor = Cursor::new(&mut buf);
1742            let mut writer = zip::ZipWriter::new(cursor);
1743            let opts = SimpleFileOptions::default();
1744            writer.add_symlink(name, target, opts).unwrap();
1745            writer.finish().unwrap();
1746        }
1747        buf
1748    }
1749
1750    /// Reject entries whose declared decompressed size exceeds the cap.
1751    /// This is the cheap, fast path that catches well-formed but oversized
1752    /// entries without spending IO bandwidth.
1753    #[test]
1754    fn extract_zip_rejects_oversized_entry_by_declared_size() {
1755        let dir = tempfile::TempDir::new().unwrap();
1756        // 2 KiB payload, cap at 1 KiB.
1757        let payload = vec![0xAB_u8; 2048];
1758        let bytes = build_zip_archive(&[("payload.bin", &payload)]);
1759        let archive_path = dir.path().join("oversized.zip");
1760        fs::write(&archive_path, &bytes).unwrap();
1761
1762        let archive_file = fs::File::open(&archive_path).unwrap();
1763        let result = extract_zip_with_limit(archive_file, dir.path(), 1024);
1764        assert!(
1765            result.is_err(),
1766            "oversized entry was extracted instead of rejected"
1767        );
1768        assert!(
1769            !dir.path().join("payload.bin").exists()
1770                || fs::metadata(dir.path().join("payload.bin")).unwrap().len() <= 1024,
1771            "oversized entry left a >cap-sized file on disk"
1772        );
1773    }
1774
1775    /// The post-copy bounded read also catches archives that under-report
1776    /// `entry.size()` - i.e. malicious archives that lie about decompressed
1777    /// size to slip past the pre-check.
1778    #[test]
1779    fn extract_zip_rejects_oversized_entry_via_bounded_copy() {
1780        // The pre-check is sized off `entry.size()`, which the zip writer
1781        // sets honestly. To exercise the post-copy bound independently, we
1782        // run with a cap smaller than the pre-check would have caught at
1783        // production-default, then assert the streaming check fired and the
1784        // partial file was cleaned up.
1785        let dir = tempfile::TempDir::new().unwrap();
1786        let payload = vec![0xCD_u8; 4096];
1787        let bytes = build_zip_archive(&[("payload.bin", &payload)]);
1788        let archive_path = dir.path().join("oversized2.zip");
1789        fs::write(&archive_path, &bytes).unwrap();
1790
1791        let archive_file = fs::File::open(&archive_path).unwrap();
1792        // Cap below payload size; pre-check fires first here, but verify
1793        // we didn't leave a partial extraction either way.
1794        let result = extract_zip_with_limit(archive_file, dir.path(), 256);
1795        assert!(result.is_err());
1796        assert!(!dir.path().join("payload.bin").exists());
1797    }
1798
1799    /// `extract_zip` must drop symlink entries on the floor. Honouring them
1800    /// would either create a fresh symlink (a new escape vector for later
1801    /// entries to resolve through) or write the link target string as
1802    /// regular file content with symlink mode bits — both wrong.
1803    #[test]
1804    fn extract_zip_skips_symlink_entries() {
1805        let dir = tempfile::TempDir::new().unwrap();
1806        let bytes = build_zip_with_symlink("payload", "/etc/passwd");
1807        let archive_path = dir.path().join("symlink.zip");
1808        fs::write(&archive_path, &bytes).unwrap();
1809
1810        let archive_file = fs::File::open(&archive_path).unwrap();
1811        extract_zip(archive_file, dir.path()).unwrap();
1812
1813        let materialised = dir.path().join("payload");
1814        assert!(
1815            !materialised.exists() && fs::symlink_metadata(&materialised).is_err(),
1816            "symlink entry was materialised at {}",
1817            materialised.display()
1818        );
1819    }
1820
1821    /// Regression test for the canonicalize-bypass bug. A pre-placed symlink
1822    /// inside `dest_dir` points outside; a zip entry uses the symlink as a
1823    /// path component. `extract_zip` must not write through the symlink.
1824    #[cfg(unix)]
1825    #[test]
1826    fn extract_zip_rejects_entry_escaping_through_existing_symlink() {
1827        let dir = tempfile::TempDir::new().unwrap();
1828        let outside = tempfile::TempDir::new().unwrap();
1829
1830        std::os::unix::fs::symlink(outside.path(), dir.path().join("link")).unwrap();
1831
1832        let bytes = build_zip_archive(&[("link/payload.txt", b"escaped")]);
1833        let archive_path = dir.path().join("malicious.zip");
1834        fs::write(&archive_path, &bytes).unwrap();
1835
1836        let archive_file = fs::File::open(&archive_path).unwrap();
1837        // Extraction should not error; the malicious entry is skipped.
1838        extract_zip(archive_file, dir.path()).unwrap();
1839
1840        assert!(
1841            !outside.path().join("payload.txt").exists(),
1842            "entry escaped extraction directory through symlink"
1843        );
1844    }
1845
1846    #[test]
1847    fn extract_binary_dispatches_on_extension() {
1848        let dir = tempfile::TempDir::new().unwrap();
1849        let expected_dir = format!("seshat-{}-v1.0.0", current_target());
1850        let binary_in_zip = format!("{expected_dir}/seshat{}", std::env::consts::EXE_SUFFIX);
1851        let dir_entry = format!("{expected_dir}/");
1852        let zip_bytes = build_zip_archive(&[(&dir_entry, &[]), (&binary_in_zip, b"fake")]);
1853
1854        let zip_named = dir.path().join("ok.zip");
1855        fs::write(&zip_named, &zip_bytes).unwrap();
1856        let ok = extract_binary(&zip_named, dir.path(), "1.0.0");
1857        assert!(ok.is_ok(), "zip dispatch failed: {ok:?}");
1858
1859        let dir2 = tempfile::TempDir::new().unwrap();
1860        let mismatched = dir2.path().join("ok.tar.gz");
1861        fs::write(&mismatched, &zip_bytes).unwrap();
1862        let err = extract_binary(&mismatched, dir2.path(), "1.0.0");
1863        assert!(
1864            err.is_err(),
1865            "expected error when zip bytes are read as tar.gz, got {err:?}"
1866        );
1867    }
1868
1869    #[test]
1870    fn find_binary_asset_matches_target() {
1871        let assets = vec![
1872            serde_json::json!({
1873                "name": "seshat-aarch64-apple-darwin-v1.0.0.tar.gz",
1874                "browser_download_url": "https://example.com/asset1.tar.gz"
1875            }),
1876            serde_json::json!({
1877                "name": "seshat-x86_64-apple-darwin-v1.0.0.tar.gz",
1878                "browser_download_url": "https://example.com/asset2.tar.gz"
1879            }),
1880        ];
1881
1882        let target = "aarch64-apple-darwin";
1883        let result = find_binary_asset(&assets, target, "1.0.0");
1884        assert!(result.is_some());
1885        let (name, url) = result.unwrap();
1886        assert!(name.contains("aarch64-apple-darwin"));
1887        assert_eq!(url, "https://example.com/asset1.tar.gz");
1888    }
1889
1890    #[test]
1891    fn find_binary_asset_no_match() {
1892        let assets = vec![serde_json::json!({
1893            "name": "seshat-wasm32-unknown-unknown-v1.0.0.tar.gz",
1894            "browser_download_url": "https://example.com/asset1.tar.gz"
1895        })];
1896
1897        let result = find_binary_asset(&assets, "aarch64-apple-darwin", "1.0.0");
1898        assert!(result.is_none());
1899    }
1900
1901    #[test]
1902    fn find_binary_asset_skips_non_tar() {
1903        let assets = vec![serde_json::json!({
1904            "name": "seshat-aarch64-apple-darwin-v1.0.0.msi",
1905            "browser_download_url": "https://example.com/asset1.msi"
1906        })];
1907
1908        let result = find_binary_asset(&assets, "aarch64-apple-darwin", "1.0.0");
1909        assert!(result.is_none());
1910    }
1911
1912    #[test]
1913    fn find_binary_asset_matches_windows_target() {
1914        let assets = vec![serde_json::json!({
1915            "name": "seshat-x86_64-pc-windows-msvc-v1.0.0.zip",
1916            "browser_download_url": "https://example.com/asset.zip"
1917        })];
1918
1919        let result = find_binary_asset(&assets, "x86_64-pc-windows-msvc", "1.0.0");
1920        assert!(result.is_some());
1921        let (name, url) = result.unwrap();
1922        assert!(name.ends_with(".zip"));
1923        assert_eq!(url, "https://example.com/asset.zip");
1924    }
1925
1926    #[test]
1927    fn find_binary_asset_matches_uppercase_zip_extension() {
1928        let assets = vec![serde_json::json!({
1929            "name": "seshat-x86_64-pc-windows-msvc-v1.0.0.ZIP",
1930            "browser_download_url": "https://example.com/asset.ZIP"
1931        })];
1932
1933        let result = find_binary_asset(&assets, "x86_64-pc-windows-msvc", "1.0.0");
1934        assert!(
1935            result.is_some(),
1936            "uppercase .ZIP extension should match on windows-msvc target"
1937        );
1938    }
1939
1940    #[test]
1941    fn find_binary_asset_matches_mixed_case_tar_gz_extension() {
1942        let assets = vec![serde_json::json!({
1943            "name": "seshat-x86_64-unknown-linux-gnu-v1.0.0.Tar.Gz",
1944            "browser_download_url": "https://example.com/asset.tar.gz"
1945        })];
1946
1947        let result = find_binary_asset(&assets, "x86_64-unknown-linux-gnu", "1.0.0");
1948        assert!(
1949            result.is_some(),
1950            "mixed-case .Tar.Gz extension should match on linux target"
1951        );
1952    }
1953
1954    #[test]
1955    fn find_binary_asset_skips_zip_on_unix_target() {
1956        let assets = vec![serde_json::json!({
1957            "name": "seshat-x86_64-unknown-linux-gnu-v1.0.0.zip",
1958            "browser_download_url": "https://example.com/asset.zip"
1959        })];
1960
1961        let result = find_binary_asset(&assets, "x86_64-unknown-linux-gnu", "1.0.0");
1962        assert!(result.is_none());
1963    }
1964
1965    /// Sibling artefacts whose names contain the target triple (debug
1966    /// symbols, source bundles, archive variants) must NOT be returned in
1967    /// place of the canonical binary archive. Pre-fix, the matcher used
1968    /// `name.contains(target) && extension_match` which was first-match-wins.
1969    #[test]
1970    fn find_binary_asset_rejects_shadowing_sibling_artifacts() {
1971        let assets = vec![
1972            serde_json::json!({
1973                "name": "seshat-x86_64-pc-windows-msvc-v1.0.0-pdb.zip",
1974                "browser_download_url": "https://example.com/pdb.zip"
1975            }),
1976            serde_json::json!({
1977                "name": "seshat-x86_64-pc-windows-msvc-v1.0.0-debug.zip",
1978                "browser_download_url": "https://example.com/debug.zip"
1979            }),
1980            serde_json::json!({
1981                "name": "seshat-x86_64-pc-windows-msvc-v1.0.0.zip",
1982                "browser_download_url": "https://example.com/canonical.zip"
1983            }),
1984        ];
1985
1986        let result = find_binary_asset(&assets, "x86_64-pc-windows-msvc", "1.0.0");
1987        assert!(result.is_some());
1988        let (name, url) = result.unwrap();
1989        assert_eq!(name, "seshat-x86_64-pc-windows-msvc-v1.0.0.zip");
1990        assert_eq!(url, "https://example.com/canonical.zip");
1991    }
1992
1993    /// A release whose tag version doesn't match the requested version
1994    /// must produce no match. Previously the `contains(target)` check
1995    /// would have accepted any version's archive.
1996    #[test]
1997    fn find_binary_asset_requires_version_match() {
1998        let assets = vec![serde_json::json!({
1999            "name": "seshat-x86_64-pc-windows-msvc-v0.9.0.zip",
2000            "browser_download_url": "https://example.com/old.zip"
2001        })];
2002
2003        let result = find_binary_asset(&assets, "x86_64-pc-windows-msvc", "1.0.0");
2004        assert!(
2005            result.is_none(),
2006            "asset for v0.9.0 must not match a request for v1.0.0"
2007        );
2008    }
2009
2010    #[test]
2011    fn find_checksums_url_prefers_version_match() {
2012        let assets = vec![
2013            serde_json::json!({
2014                "name": "sha256sums-v0.5.0.txt",
2015                "browser_download_url": "https://example.com/sha256sums-old.txt"
2016            }),
2017            serde_json::json!({
2018                "name": "sha256sums-v1.0.0.txt",
2019                "browser_download_url": "https://example.com/sha256sums-v1.0.0.txt"
2020            }),
2021        ];
2022
2023        let result = find_checksums_url(&assets, "1.0.0");
2024        assert!(result.is_ok());
2025        assert_eq!(result.unwrap(), "https://example.com/sha256sums-v1.0.0.txt");
2026    }
2027
2028    #[test]
2029    fn find_checksums_url_fallback_first_match() {
2030        let assets = vec![
2031            serde_json::json!({
2032                "name": "seshat-aarch64-apple-darwin-v1.0.0.tar.gz",
2033                "browser_download_url": "https://example.com/asset1.tar.gz"
2034            }),
2035            serde_json::json!({
2036                "name": "sha256sums.txt",
2037                "browser_download_url": "https://example.com/sha256sums.txt"
2038            }),
2039        ];
2040
2041        let result = find_checksums_url(&assets, "1.0.0");
2042        assert!(result.is_ok());
2043        assert_eq!(result.unwrap(), "https://example.com/sha256sums.txt");
2044    }
2045
2046    #[test]
2047    fn find_checksums_url_not_found() {
2048        let assets = vec![serde_json::json!({
2049            "name": "seshat-aarch64-apple-darwin-v1.0.0.tar.gz",
2050            "browser_download_url": "https://example.com/asset1.tar.gz"
2051        })];
2052
2053        let result = find_checksums_url(&assets, "1.0.0");
2054        assert!(result.is_err());
2055    }
2056
2057    #[test]
2058    fn is_cargo_install_returns_bool() {
2059        let _ = is_cargo_install();
2060    }
2061
2062    #[test]
2063    fn cargo_json_contains_seshat_true() {
2064        let json = serde_json::json!({
2065            "installs": {
2066                "seshat 1.2.3 (registry+https://github.com/rust-lang/crates.io-index)": {
2067                    "version_req": "^1",
2068                    "bins": ["seshat"],
2069                    "features": [],
2070                    "all_features": false,
2071                    "no_default_features": false,
2072                    "profile": "release",
2073                    "target": "aarch64-apple-darwin",
2074                    "rustc": "1.75.0"
2075                }
2076            }
2077        });
2078        assert!(cargo_json_contains_seshat(&json));
2079    }
2080
2081    #[test]
2082    fn cargo_json_contains_seshat_false() {
2083        let json = serde_json::json!({
2084            "installs": {
2085                "ripgrep 13.0.0 (registry+https://github.com/rust-lang/crates.io-index)": {}
2086            }
2087        });
2088        assert!(!cargo_json_contains_seshat(&json));
2089    }
2090
2091    #[test]
2092    fn cargo_json_no_installs_key() {
2093        let json = serde_json::json!({ "other": "data" });
2094        assert!(!cargo_json_contains_seshat(&json));
2095    }
2096
2097    #[test]
2098    fn cargo_json_empty_installs() {
2099        let json = serde_json::json!({ "installs": {} });
2100        assert!(!cargo_json_contains_seshat(&json));
2101    }
2102
2103    #[test]
2104    fn cargo_toml_contains_seshat_true() {
2105        let content = r#"[v1]
2106"seshat 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = ["seshat"]
2107"#;
2108        assert!(cargo_toml_contains_seshat(content));
2109    }
2110
2111    #[test]
2112    fn cargo_toml_contains_seshat_false() {
2113        let content = r#"[v1]
2114"ripgrep 13.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = ["rg"]
2115"#;
2116        assert!(!cargo_toml_contains_seshat(content));
2117    }
2118
2119    #[test]
2120    fn cargo_toml_substring_no_false_positive() {
2121        let content = r#"[v1]
2122"seshat-something 1.0.0" = ["not-seshat"]
2123"#;
2124        assert!(!cargo_toml_contains_seshat(content));
2125    }
2126
2127    #[test]
2128    fn cargo_toml_empty() {
2129        assert!(!cargo_toml_contains_seshat(""));
2130        assert!(!cargo_toml_contains_seshat("[v1]\n"));
2131    }
2132
2133    #[test]
2134    fn is_cargo_install_with_fake_crates2_json() {
2135        let dir = tempfile::TempDir::new().unwrap();
2136        let cargo_dir = dir.path();
2137
2138        let crates2 = cargo_dir.join(".crates2.json");
2139        let json = serde_json::json!({
2140            "installs": {
2141                "seshat 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)": {
2142                    "bins": ["seshat"]
2143                }
2144            }
2145        });
2146        fs::write(&crates2, serde_json::to_string(&json).unwrap()).unwrap();
2147
2148        let content = fs::read_to_string(&crates2).unwrap();
2149        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2150        assert!(cargo_json_contains_seshat(&parsed));
2151    }
2152
2153    #[test]
2154    fn is_cargo_install_with_corrupted_crates2_json() {
2155        let dir = tempfile::TempDir::new().unwrap();
2156        let crates2 = dir.path().join(".crates2.json");
2157        fs::write(&crates2, b"not valid json").unwrap();
2158
2159        let content = fs::read_to_string(&crates2).unwrap();
2160        let result = serde_json::from_str::<serde_json::Value>(&content);
2161        assert!(result.is_err());
2162    }
2163
2164    #[test]
2165    fn resolve_target_exe_returns_path() {
2166        let result = resolve_target_exe();
2167        assert!(result.is_ok());
2168        let path = result.unwrap();
2169        assert!(path.is_absolute());
2170    }
2171
2172    #[test]
2173    fn map_replace_error_translates_permission_denied() {
2174        let dir = tempfile::TempDir::new().unwrap();
2175        let target = dir.path().join("seshat");
2176        let e = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
2177
2178        let cli_err = map_replace_error(e, &target);
2179        match cli_err {
2180            CliError::CommandFailed { command, reason } => {
2181                assert_eq!(command, "update");
2182                #[cfg(windows)]
2183                assert!(
2184                    reason.contains("Administrator"),
2185                    "Windows reason should mention Administrator hint, got: {reason}"
2186                );
2187                #[cfg(not(windows))]
2188                assert!(
2189                    reason.contains("sudo seshat update"),
2190                    "Unix reason should mention sudo hint, got: {reason}"
2191                );
2192            }
2193            other => panic!("expected CommandFailed, got: {other:?}"),
2194        }
2195    }
2196
2197    #[test]
2198    fn map_replace_error_passes_through_other_errors() {
2199        let dir = tempfile::TempDir::new().unwrap();
2200        let target = dir.path().join("seshat");
2201        let e = std::io::Error::other("boom");
2202
2203        let cli_err = map_replace_error(e, &target);
2204        match cli_err {
2205            CliError::CommandFailed { reason, .. } => {
2206                assert!(
2207                    reason.starts_with("failed to replace binary: "),
2208                    "non-permission errors should map to the generic 'failed to replace binary' reason, got: {reason}"
2209                );
2210                assert!(reason.contains("boom"));
2211            }
2212            other => panic!("expected CommandFailed, got: {other:?}"),
2213        }
2214    }
2215
2216    #[cfg(windows)]
2217    #[test]
2218    fn replace_binary_translates_permission_denied_to_admin_hint_on_windows() {
2219        let dir = tempfile::TempDir::new().unwrap();
2220        let target = dir.path().join("seshat.exe");
2221        let e = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
2222
2223        let cli_err = map_replace_error(e, &target);
2224        match cli_err {
2225            CliError::CommandFailed { reason, .. } => {
2226                assert!(
2227                    reason.contains("Administrator"),
2228                    "Windows admin hint should appear in the CliError reason, got: {reason}"
2229                );
2230                assert!(!reason.contains("sudo"));
2231            }
2232            other => panic!("expected CommandFailed, got: {other:?}"),
2233        }
2234    }
2235
2236    #[cfg(windows)]
2237    #[test]
2238    fn is_cargo_install_with_fake_crates2_json_on_windows() {
2239        let dir = tempfile::TempDir::new().unwrap();
2240        let cargo_dir = dir.path();
2241
2242        let crates2 = cargo_dir.join(".crates2.json");
2243        let json = serde_json::json!({
2244            "installs": {
2245                "seshat 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)": {
2246                    "bins": ["seshat.exe"]
2247                }
2248            }
2249        });
2250        fs::write(&crates2, serde_json::to_string(&json).unwrap()).unwrap();
2251
2252        let content = fs::read_to_string(&crates2).unwrap();
2253        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2254        assert!(cargo_json_contains_seshat(&parsed));
2255    }
2256
2257    #[test]
2258    fn preflight_check_with_valid_binary() {
2259        let dir = tempfile::TempDir::new().unwrap();
2260
2261        let echo_path = std::path::Path::new("/bin/echo");
2262        if !echo_path.exists() {
2263            return;
2264        }
2265
2266        let script = dir.path().join("fake_seshat");
2267        fs::write(&script, b"#!/bin/sh\necho 'seshat 1.0.0'\n").unwrap();
2268
2269        #[cfg(unix)]
2270        {
2271            use std::os::unix::fs::PermissionsExt;
2272            let mut perms = fs::metadata(&script).unwrap().permissions();
2273            perms.set_mode(0o755);
2274            fs::set_permissions(&script, perms).unwrap();
2275        }
2276
2277        let result = preflight_check(&script, dir.path());
2278        assert!(result.is_ok());
2279    }
2280
2281    #[cfg(unix)]
2282    #[test]
2283    fn preflight_check_detects_nonzero_exit() {
2284        let dir = tempfile::TempDir::new().unwrap();
2285        let script = dir.path().join("failing_binary");
2286        fs::write(&script, b"#!/bin/sh\nexit 1\n").unwrap();
2287
2288        use std::os::unix::fs::PermissionsExt;
2289        let mut perms = fs::metadata(&script).unwrap().permissions();
2290        perms.set_mode(0o755);
2291        fs::set_permissions(&script, perms).unwrap();
2292
2293        let result = preflight_check(&script, dir.path());
2294        assert!(result.is_err());
2295    }
2296
2297    #[test]
2298    fn version_output_contains_seshat_with_version() {
2299        assert!(version_output_contains_seshat("seshat 1.2.3"));
2300        assert!(version_output_contains_seshat("seshat v0.2.0"));
2301        assert!(version_output_contains_seshat("foo seshat 1.0.0"));
2302    }
2303
2304    #[test]
2305    fn version_output_does_not_contain_seshat() {
2306        assert!(!version_output_contains_seshat(""));
2307        assert!(!version_output_contains_seshat("something else"));
2308        assert!(!version_output_contains_seshat("seshat not a version"));
2309        assert!(!version_output_contains_seshat("seshat-error happened"));
2310    }
2311
2312    #[test]
2313    fn notice_skips_when_cache_fresh_and_up_to_date() {
2314        let dir = tempfile::TempDir::new().unwrap();
2315        let cache_path = dir.path().join("version-check.json");
2316
2317        let current = env!("CARGO_PKG_VERSION");
2318        let cache = VersionCache::new(current.to_owned());
2319        cache.write_to_path(&cache_path).unwrap();
2320
2321        check_and_print_update_notice_inner(&Some(cache_path));
2322    }
2323
2324    #[test]
2325    fn notice_skips_when_cache_fresh_and_old_version() {
2326        let dir = tempfile::TempDir::new().unwrap();
2327        let cache_path = dir.path().join("version-check.json");
2328
2329        let cache = VersionCache::new("0.0.1".to_owned());
2330        cache.write_to_path(&cache_path).unwrap();
2331
2332        check_and_print_update_notice_inner(&Some(cache_path));
2333    }
2334
2335    #[test]
2336    fn notice_skips_when_cache_no_assets() {
2337        let dir = tempfile::TempDir::new().unwrap();
2338        let cache_path = dir.path().join("version-check.json");
2339
2340        let cache = VersionCache::with_assets("9999.0.0".to_owned(), false);
2341        cache.write_to_path(&cache_path).unwrap();
2342
2343        check_and_print_update_notice_inner(&Some(cache_path));
2344    }
2345
2346    #[test]
2347    fn notice_with_fresh_cache_newer_version() {
2348        let dir = tempfile::TempDir::new().unwrap();
2349        let cache_path = dir.path().join("version-check.json");
2350
2351        let cache = VersionCache::new("9999.0.0".to_owned());
2352        cache.write_to_path(&cache_path).unwrap();
2353
2354        check_and_print_update_notice_inner(&Some(cache_path));
2355    }
2356
2357    #[test]
2358    fn notice_skips_when_no_cache_path() {
2359        check_and_print_update_notice_inner(&None);
2360    }
2361
2362    #[test]
2363    fn notice_skips_when_cache_missing() {
2364        let dir = tempfile::TempDir::new().unwrap();
2365        let nonexistent = dir.path().join("no-such-file.json");
2366        check_and_print_update_notice_inner(&Some(nonexistent));
2367    }
2368
2369    // ── parse_rate_limit / check_response_status ─────────────────────
2370
2371    fn future_reset_headers(seconds_from_now: u64) -> ureq::http::HeaderMap {
2372        let mut h = ureq::http::HeaderMap::new();
2373        let now = std::time::SystemTime::now()
2374            .duration_since(std::time::UNIX_EPOCH)
2375            .unwrap()
2376            .as_secs();
2377        let reset = now + seconds_from_now;
2378        h.insert("x-ratelimit-reset", reset.to_string().parse().unwrap());
2379        h
2380    }
2381
2382    #[test]
2383    fn parse_rate_limit_ignores_non_throttling_status() {
2384        let h = future_reset_headers(600);
2385        assert!(parse_rate_limit(200, &h).is_none());
2386        assert!(parse_rate_limit(404, &h).is_none());
2387        assert!(parse_rate_limit(500, &h).is_none());
2388    }
2389
2390    #[test]
2391    fn parse_rate_limit_handles_403_with_reset_header() {
2392        let h = future_reset_headers(1800); // 30 minutes from now
2393        let info = parse_rate_limit(403, &h).expect("should parse");
2394        // Rounding to whole minutes can drop us to 29 right at the boundary;
2395        // anything in 25..=30 is fine for an integration-ish unit test.
2396        assert!(
2397            (25..=30).contains(&info.retry_after_minutes),
2398            "unexpected retry_after_minutes: {}",
2399            info.retry_after_minutes
2400        );
2401    }
2402
2403    #[test]
2404    fn parse_rate_limit_handles_429_with_reset_header() {
2405        let h = future_reset_headers(120);
2406        let info = parse_rate_limit(429, &h).expect("should parse");
2407        assert!(info.retry_after_minutes >= 1);
2408    }
2409
2410    #[test]
2411    fn parse_rate_limit_clamps_past_reset_to_one_minute() {
2412        let mut h = ureq::http::HeaderMap::new();
2413        h.insert("x-ratelimit-reset", "1".parse().unwrap()); // far in the past
2414        let info = parse_rate_limit(403, &h).expect("should parse");
2415        assert_eq!(info.retry_after_minutes, 1);
2416    }
2417
2418    #[test]
2419    fn parse_rate_limit_returns_none_when_header_missing() {
2420        let h = ureq::http::HeaderMap::new();
2421        assert!(parse_rate_limit(403, &h).is_none());
2422        assert!(parse_rate_limit(429, &h).is_none());
2423    }
2424
2425    #[test]
2426    fn parse_rate_limit_returns_none_when_header_unparseable() {
2427        let mut h = ureq::http::HeaderMap::new();
2428        h.insert("x-ratelimit-reset", "not-a-number".parse().unwrap());
2429        assert!(parse_rate_limit(403, &h).is_none());
2430    }
2431
2432    #[test]
2433    fn parse_rate_limit_floor_to_one_minute_when_reset_under_60s() {
2434        // ~30 seconds ahead → integer division (30/60) == 0, then clamped via .max(1)
2435        let h = future_reset_headers(30);
2436        let info = parse_rate_limit(429, &h).expect("should parse");
2437        assert_eq!(info.retry_after_minutes, 1);
2438    }
2439
2440    #[test]
2441    fn check_response_status_ok_for_2xx_and_3xx() {
2442        let h = ureq::http::HeaderMap::new();
2443        assert!(check_response_status(200, &h).is_ok());
2444        assert!(check_response_status(204, &h).is_ok());
2445        assert!(check_response_status(301, &h).is_ok());
2446        assert!(check_response_status(399, &h).is_ok());
2447    }
2448
2449    #[test]
2450    fn check_response_status_404_message() {
2451        let h = ureq::http::HeaderMap::new();
2452        let err = check_response_status(404, &h).unwrap_err();
2453        assert!(err.to_string().contains("release not found"));
2454    }
2455
2456    #[test]
2457    fn check_response_status_5xx_message() {
2458        let h = ureq::http::HeaderMap::new();
2459        let err = check_response_status(503, &h).unwrap_err();
2460        assert!(err.to_string().contains("server error"));
2461        assert!(err.to_string().contains("503"));
2462    }
2463
2464    #[test]
2465    fn check_response_status_other_4xx_includes_status() {
2466        let h = ureq::http::HeaderMap::new();
2467        let err = check_response_status(418, &h).unwrap_err();
2468        assert!(err.to_string().contains("418"));
2469    }
2470
2471    #[test]
2472    fn check_response_status_403_with_reset_returns_rate_limit_message() {
2473        let h = future_reset_headers(600);
2474        let err = check_response_status(403, &h).unwrap_err();
2475        assert!(err.to_string().contains("rate limited"));
2476    }
2477
2478    #[test]
2479    fn check_response_status_403_without_reset_falls_through_to_generic_4xx() {
2480        let h = ureq::http::HeaderMap::new();
2481        let err = check_response_status(403, &h).unwrap_err();
2482        let msg = err.to_string();
2483        assert!(msg.contains("403"));
2484        // The "rate limited" branch must NOT activate when the header is missing.
2485        assert!(!msg.contains("rate limited"), "got: {msg}");
2486    }
2487
2488    #[cfg(unix)]
2489    #[test]
2490    fn cleanup_after_update_is_noop_on_unix() {
2491        // Unix has atomic rename(2), so `replace_binary` never leaves a `.old`
2492        // behind. The helper compiles to a no-op here — the contract under
2493        // test is "calling this from `lib.rs::run()` on Unix has no effect".
2494        // We do NOT call the upstream `self_replace::self_delete_outside_path`,
2495        // which would unconditionally `fs::remove_file(current_exe())` on Unix
2496        // and brick the cargo-test binary.
2497        cleanup_stale_old_binary();
2498    }
2499
2500    #[cfg(windows)]
2501    #[test]
2502    fn cleanup_stale_old_binary_removes_existing_old_file() {
2503        let dir = tempfile::TempDir::new().unwrap();
2504        let exe = dir.path().join("seshat.exe");
2505        let stale = dir.path().join("seshat.exe.old");
2506        fs::write(&exe, b"new").unwrap();
2507        fs::write(&stale, b"old").unwrap();
2508        cleanup_stale_old_binary_at(&exe);
2509        assert!(!stale.exists(), "stale .old file must be removed");
2510        assert!(exe.exists(), "live binary must be preserved");
2511    }
2512
2513    #[cfg(windows)]
2514    #[test]
2515    fn cleanup_stale_old_binary_is_noop_when_old_file_missing() {
2516        let dir = tempfile::TempDir::new().unwrap();
2517        let exe = dir.path().join("seshat.exe");
2518        fs::write(&exe, b"new").unwrap();
2519        cleanup_stale_old_binary_at(&exe);
2520        assert!(exe.exists());
2521    }
2522
2523    // ── US-007: integration-style tests for the Windows update flow ──
2524    //
2525    // PRD AC for US-007 asks for tests against a "mocked HTTP server" and
2526    // claims "existing mock-server helpers" exist. Neither is true:
2527    //   (a) `update.rs` hardcodes `GITHUB_RELEASES_API` as a `const &str`, so
2528    //       there is no URL injection point; standing up a real mock server
2529    //       would require non-trivial dependency injection in run_self_update.
2530    //   (b) `replace_binary` calls `self_replace::self_replace(new_binary)`,
2531    //       which derives the *target* from `std::env::current_exe()` and
2532    //       therefore would overwrite the cargo-test binary mid-run if
2533    //       exercised end-to-end (this constraint is documented in US-005).
2534    //   (c) No mock-server helper code exists anywhere in the workspace.
2535    //
2536    // The user-story intent is regression coverage of the windows-msvc code
2537    // paths — extension-based asset matching, zip extraction, sha256 verify,
2538    // preflight, and update-notice. We satisfy that intent by composing the
2539    // real helper functions against fixture data inside cfg(windows) tests,
2540    // stopping short of `replace_binary` (deferred to manual + Windows CI
2541    // integration via US-008). Each test below maps 1:1 to a PRD AC:
2542
2543    /// US-007 happy path. Builds a hand-crafted .zip with a fake `seshat.exe`
2544    /// inside the expected `seshat-{target}-v{version}/` layout, computes its
2545    /// SHA-256, then walks `verify_sha256` → `extract_binary` (which dispatches
2546    /// to the windows zip path) and asserts the staged .exe lands at the
2547    /// expected path with the correct content. This is the in-process
2548    /// equivalent of "asserts target_exe content matches new binary" — minus
2549    /// the actual `replace_binary` step (see module-level note).
2550    #[cfg(windows)]
2551    #[test]
2552    fn run_self_update_windows_happy_path() {
2553        let dir = tempfile::TempDir::new().unwrap();
2554        let archive_path = dir.path().join("seshat-windows-v1.0.0.zip");
2555
2556        let target = current_target();
2557        let expected_dir = format!("seshat-{target}-v1.0.0");
2558        let binary_in_zip = format!("{expected_dir}/seshat.exe");
2559        let dir_entry = format!("{expected_dir}/");
2560        let new_binary_bytes = b"new-windows-binary-v1.0.0";
2561
2562        let zip_bytes = build_zip_archive(&[(&dir_entry, &[]), (&binary_in_zip, new_binary_bytes)]);
2563        fs::write(&archive_path, &zip_bytes).unwrap();
2564
2565        let mut hasher = Sha256::new();
2566        hasher.update(&zip_bytes);
2567        let hash = hasher.finalize();
2568        let mut expected_hex = String::with_capacity(hash.len() * 2);
2569        for byte in hash {
2570            use std::fmt::Write;
2571            let _ = write!(expected_hex, "{byte:02x}");
2572        }
2573
2574        verify_sha256(&archive_path, &expected_hex).expect("hash matches");
2575
2576        let staged = extract_binary(&archive_path, dir.path(), "1.0.0").expect("extract ok");
2577        assert!(staged.is_file(), "staged binary should exist on disk");
2578        assert!(
2579            staged.ends_with(format!("{expected_dir}/seshat.exe")),
2580            "staged binary path should match the windows layout, got: {}",
2581            staged.display()
2582        );
2583        let staged_bytes = fs::read(&staged).unwrap();
2584        assert_eq!(
2585            staged_bytes, new_binary_bytes,
2586            "staged binary content must match the bytes embedded in the zip"
2587        );
2588    }
2589
2590    /// US-007 sha mismatch. Same .zip fixture as happy-path, but verify with
2591    /// a deliberately-wrong hash → `verify_sha256` returns CommandFailed.
2592    /// Asserts the existing binary stays unchanged by virtue of the early
2593    /// error: no extraction or replace ever runs (the real `run_self_update`
2594    /// short-circuits on the `verify_sha256.inspect_err(...)` branch at
2595    /// update.rs:123).
2596    #[cfg(windows)]
2597    #[test]
2598    fn run_self_update_windows_sha_mismatch() {
2599        let dir = tempfile::TempDir::new().unwrap();
2600        let archive_path = dir.path().join("seshat-windows-v1.0.0.zip");
2601
2602        let target = current_target();
2603        let expected_dir = format!("seshat-{target}-v1.0.0");
2604        let binary_in_zip = format!("{expected_dir}/seshat.exe");
2605        let dir_entry = format!("{expected_dir}/");
2606        let zip_bytes = build_zip_archive(&[(&dir_entry, &[]), (&binary_in_zip, b"any-bytes")]);
2607        fs::write(&archive_path, &zip_bytes).unwrap();
2608
2609        let wrong_hash = "0".repeat(64);
2610        let result = verify_sha256(&archive_path, &wrong_hash);
2611        match result {
2612            Err(CliError::CommandFailed { reason, .. }) => {
2613                assert!(
2614                    reason.contains("SHA256 mismatch"),
2615                    "sha mismatch path must surface CliError::CommandFailed with a 'SHA256 mismatch' reason, got: {reason}"
2616                );
2617            }
2618            other => panic!("expected SHA256 mismatch CommandFailed, got: {other:?}"),
2619        }
2620
2621        let unstaged = dir.path().join(&expected_dir).join("seshat.exe");
2622        assert!(
2623            !unstaged.exists(),
2624            "no extraction must happen on sha mismatch"
2625        );
2626    }
2627
2628    /// US-007 no-zip-asset path. A release whose only artefacts are `.tar.gz`
2629    /// (Unix triples) with the windows-msvc target → `find_binary_asset`
2630    /// returns None, which `fetch_release_assets` translates to `Ok(None)` and
2631    /// `run_self_update` prints "Seshat is up to date" and returns Ok(()).
2632    /// We don't need to drive `run_self_update` for this — the matcher is the
2633    /// only branch point.
2634    #[cfg(windows)]
2635    #[test]
2636    fn run_self_update_windows_no_zip_asset_for_target() {
2637        let assets = vec![
2638            serde_json::json!({
2639                "name": "seshat-x86_64-unknown-linux-gnu-v1.0.0.tar.gz",
2640                "browser_download_url": "https://example.com/linux.tar.gz"
2641            }),
2642            serde_json::json!({
2643                "name": "seshat-aarch64-apple-darwin-v1.0.0.tar.gz",
2644                "browser_download_url": "https://example.com/darwin.tar.gz"
2645            }),
2646        ];
2647
2648        let result = find_binary_asset(&assets, "x86_64-pc-windows-msvc", "1.0.0");
2649        assert!(
2650            result.is_none(),
2651            "windows-msvc target must NOT match any .tar.gz asset, got: {result:?}"
2652        );
2653
2654        let json = serde_json::json!({
2655            "tag_name": "v1.0.0",
2656            "assets": assets,
2657        });
2658        assert!(
2659            !has_binary_asset_for_current_target(&json),
2660            "no windows-msvc .zip in this release → background-notice must skip"
2661        );
2662    }
2663
2664    /// US-007 preflight failure. A "binary" that fails to spawn (non-PE bytes
2665    /// at a `.exe` path) makes `Command::output()` Err on Windows, which
2666    /// `preflight_check` maps to CommandFailed and triggers temp-dir cleanup.
2667    /// Asserts: `preflight_check` errs, the temp dir is wiped, and the
2668    /// existing binary on disk (which we never produced — the fixture stops
2669    /// before `replace_binary`) is intact.
2670    #[cfg(windows)]
2671    #[test]
2672    fn run_self_update_windows_preflight_fail() {
2673        let dir = tempfile::TempDir::new().unwrap();
2674        let temp_dir = dir.path().join("staging");
2675        fs::create_dir_all(&temp_dir).unwrap();
2676        let bogus_binary = temp_dir.join("seshat.exe");
2677        fs::write(&bogus_binary, b"not a PE file").unwrap();
2678
2679        let result = preflight_check(&bogus_binary, &temp_dir);
2680        assert!(
2681            result.is_err(),
2682            "preflight_check must error on non-executable bytes"
2683        );
2684        match result {
2685            Err(CliError::CommandFailed { command, reason }) => {
2686                assert_eq!(command, "update");
2687                assert!(
2688                    !reason.is_empty(),
2689                    "CommandFailed should carry a non-empty reason"
2690                );
2691            }
2692            other => panic!("expected CommandFailed, got: {other:?}"),
2693        }
2694        assert!(
2695            !temp_dir.exists(),
2696            "preflight_check must clean up the staging temp dir on failure"
2697        );
2698    }
2699
2700    /// US-007 background-notice on Windows. Pre-populated cache with a newer
2701    /// version + has_assets=true is the fast path that
2702    /// `check_and_print_update_notice_inner` follows; the function emits the
2703    /// expected eprintln. We can't capture stderr from a unit test without
2704    /// dup2'ing FD 2, so we lock the contract at the cache layer: after the
2705    /// call the cache file is unchanged (no network was touched), and the
2706    /// helper did not panic.
2707    #[cfg(windows)]
2708    #[test]
2709    fn background_notice_prints_on_windows() {
2710        let dir = tempfile::TempDir::new().unwrap();
2711        let cache_path = dir.path().join("version-check.json");
2712
2713        let cache = VersionCache::with_assets("9999.0.0".to_owned(), true);
2714        cache.write_to_path(&cache_path).unwrap();
2715        let before = fs::read_to_string(&cache_path).unwrap();
2716
2717        check_and_print_update_notice_inner(&Some(cache_path.clone()));
2718
2719        let after = fs::read_to_string(&cache_path).unwrap();
2720        assert_eq!(
2721            before, after,
2722            "fresh cache fast path must not rewrite the cache file"
2723        );
2724    }
2725}