Skip to main content

zeroclaw/commands/
update.rs

1//! `zeroclaw update` — self-update pipeline with rollback.
2
3use anyhow::{Context, Result, bail};
4use sha2::{Digest, Sha256};
5use std::path::Path;
6
7#[cfg(feature = "agent-runtime")]
8use zeroclaw_runtime::i18n::get_required_cli_string_with_args;
9
10fn update_already_current_message(version: &str) -> String {
11    #[cfg(feature = "agent-runtime")]
12    {
13        get_required_cli_string_with_args("cli-update-already-current", &[("version", version)])
14    }
15
16    #[cfg(not(feature = "agent-runtime"))]
17    {
18        format!("Already up to date (v{version}).")
19    }
20}
21
22fn update_success_message(version: &str) -> String {
23    #[cfg(feature = "agent-runtime")]
24    {
25        get_required_cli_string_with_args("cli-update-success", &[("version", version)])
26    }
27
28    #[cfg(not(feature = "agent-runtime"))]
29    {
30        format!("Successfully updated to v{version}!")
31    }
32}
33
34const GITHUB_RELEASES_LATEST_URL: &str =
35    "https://api.github.com/repos/zeroclaw-labs/zeroclaw/releases/latest";
36const GITHUB_RELEASES_TAG_URL: &str =
37    "https://api.github.com/repos/zeroclaw-labs/zeroclaw/releases/tags";
38
39#[derive(Debug)]
40pub struct UpdateInfo {
41    pub current_version: String,
42    pub latest_version: String,
43    pub download_url: Option<String>,
44    pub sha256sums_url: Option<String>,
45    pub is_newer: bool,
46}
47
48/// Check for available updates without downloading.
49///
50/// If `target_version` is `Some`, fetch that specific release tag instead of latest.
51pub async fn check(target_version: Option<&str>) -> Result<UpdateInfo> {
52    let current = env!("CARGO_PKG_VERSION").to_string();
53
54    let client = reqwest::Client::builder()
55        .user_agent(format!("zeroclaw/{current}"))
56        .timeout(std::time::Duration::from_secs(15))
57        .build()?;
58
59    let url = match target_version {
60        Some(v) => {
61            let tag = if v.starts_with('v') {
62                v.to_string()
63            } else {
64                format!("v{v}")
65            };
66            format!("{GITHUB_RELEASES_TAG_URL}/{tag}")
67        }
68        None => GITHUB_RELEASES_LATEST_URL.to_string(),
69    };
70
71    let resp = client
72        .get(&url)
73        .send()
74        .await
75        .context("failed to reach GitHub releases API")?;
76
77    if !resp.status().is_success() {
78        bail!("GitHub API returned {}", resp.status());
79    }
80
81    let release: serde_json::Value = resp.json().await?;
82    let tag = release["tag_name"]
83        .as_str()
84        .unwrap_or("unknown")
85        .trim_start_matches('v')
86        .to_string();
87
88    let download_url = find_asset_url(&release);
89    let sha256sums_url = find_sha256sums_url(&release);
90    let is_newer = version_is_newer(&current, &tag);
91
92    Ok(UpdateInfo {
93        current_version: current,
94        latest_version: tag,
95        download_url,
96        sha256sums_url,
97        is_newer,
98    })
99}
100
101/// Run the full 6-phase update pipeline.
102///
103/// If `target_version` is `Some`, fetch that specific version instead of latest.
104pub async fn run(target_version: Option<&str>) -> Result<()> {
105    // Phase 1: Preflight
106    ::zeroclaw_log::record!(
107        INFO,
108        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
109        "Phase 1/6: Preflight checks..."
110    );
111    let update_info = check(target_version).await?;
112
113    if !update_info.is_newer {
114        println!(
115            "{}",
116            update_already_current_message(&update_info.current_version)
117        );
118        return Ok(());
119    }
120
121    println!(
122        "Update available: v{} -> v{}",
123        update_info.current_version, update_info.latest_version
124    );
125
126    let download_url = update_info
127        .download_url
128        .context("no suitable binary found for this platform")?;
129
130    let current_exe =
131        std::env::current_exe().context("cannot determine current executable path")?;
132
133    // Phase 2: Download
134    ::zeroclaw_log::record!(
135        INFO,
136        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
137        "Phase 2/6: Downloading..."
138    );
139    let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
140    let download_path = temp_dir.path().join("zeroclaw_new");
141    download_binary(
142        &download_url,
143        update_info.sha256sums_url.as_deref(),
144        &download_path,
145    )
146    .await?;
147
148    // Phase 3: Backup
149    ::zeroclaw_log::record!(
150        INFO,
151        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
152        "Phase 3/6: Creating backup..."
153    );
154    let backup_path = current_exe.with_extension("bak");
155    tokio::fs::copy(&current_exe, &backup_path)
156        .await
157        .context("failed to backup current binary")?;
158
159    // Phase 4: Validate
160    ::zeroclaw_log::record!(
161        INFO,
162        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
163        "Phase 4/6: Validating download..."
164    );
165    validate_binary(&download_path).await?;
166
167    // Phase 5: Swap
168    ::zeroclaw_log::record!(
169        INFO,
170        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
171        "Phase 5/6: Swapping binary..."
172    );
173    if let Err(e) = swap_binary(&download_path, &current_exe).await {
174        // Rollback
175        ::zeroclaw_log::record!(
176            WARN,
177            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
178                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
179                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
180            "Swap failed, rolling back"
181        );
182        if let Err(rollback_err) = rollback_binary(&backup_path, &current_exe).await {
183            eprintln!("CRITICAL: Rollback also failed: {rollback_err}"); // i18n-exempt: emergency operator recovery diagnostic, must be unambiguous
184            eprintln!(
185                "Manual recovery: cp {} {}",
186                backup_path.display(),
187                current_exe.display()
188            );
189        }
190        bail!("Update failed during swap: {e}");
191    }
192
193    // Phase 6: Smoke test
194    ::zeroclaw_log::record!(
195        INFO,
196        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
197        "Phase 6/6: Smoke test..."
198    );
199    match smoke_test(&current_exe).await {
200        Ok(()) => {
201            // Cleanup backup on success
202            let _ = tokio::fs::remove_file(&backup_path).await;
203            println!("{}", update_success_message(&update_info.latest_version));
204            Ok(())
205        }
206        Err(e) => {
207            ::zeroclaw_log::record!(
208                WARN,
209                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
210                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
211                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
212                "Smoke test failed, rolling back"
213            );
214            rollback_binary(&backup_path, &current_exe)
215                .await
216                .context("rollback after smoke test failure")?;
217            bail!("Update rolled back — smoke test failed: {e}");
218        }
219    }
220}
221
222fn find_asset_url(release: &serde_json::Value) -> Option<String> {
223    let target = current_target_triple()?;
224
225    release["assets"].as_array()?.iter().find_map(|asset| {
226        let name = asset["name"].as_str()?;
227        if !is_installable_release_asset(name, target) {
228            return None;
229        }
230        let url = asset["browser_download_url"].as_str()?.trim();
231        (!url.is_empty()).then(|| url.to_string())
232    })
233}
234
235fn find_sha256sums_url(release: &serde_json::Value) -> Option<String> {
236    let assets = release["assets"].as_array()?;
237    assets
238        .iter()
239        .find_map(|asset| sha256sums_url_for_asset(asset, is_exact_sha256sums_asset))
240        .or_else(|| {
241            assets
242                .iter()
243                .find_map(|asset| sha256sums_url_for_asset(asset, is_sha256sums_asset))
244        })
245}
246
247fn sha256sums_url_for_asset(
248    asset: &serde_json::Value,
249    predicate: impl Fn(&str) -> bool,
250) -> Option<String> {
251    let name = asset["name"].as_str()?;
252    if !predicate(name) {
253        return None;
254    }
255    let url = asset["browser_download_url"].as_str()?.trim();
256    (!url.is_empty()).then(|| url.to_string())
257}
258
259fn is_exact_sha256sums_asset(name: &str) -> bool {
260    name.eq_ignore_ascii_case("sha256sums")
261}
262
263fn is_sha256sums_asset(name: &str) -> bool {
264    is_exact_sha256sums_asset(name)
265        || name.eq_ignore_ascii_case("sha256sums.txt")
266        || name
267            .rsplit_once('.')
268            .is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("sha256sums"))
269}
270
271fn is_installable_release_asset(name: &str, target: &str) -> bool {
272    name == format!("zeroclaw-{target}.tar.gz") || name == format!("zeroclaw-{target}.tgz")
273}
274
275/// Return the exact Rust target triple for the current platform.
276///
277/// Using full triples (e.g. `aarch64-unknown-linux-gnu` instead of the
278/// shorter `aarch64-unknown-linux`) prevents substring matches from
279/// selecting the wrong asset (e.g. an Android binary on a GNU/Linux host).
280fn current_target_triple() -> Option<&'static str> {
281    target_triple_for(
282        std::env::consts::OS,
283        std::env::consts::ARCH,
284        cfg!(target_env = "gnu"),
285    )
286}
287
288fn target_triple_for(os: &str, arch: &str, windows_gnu: bool) -> Option<&'static str> {
289    match (os, arch) {
290        ("macos", "aarch64") => Some("aarch64-apple-darwin"),
291        ("macos", "x86_64") => Some("x86_64-apple-darwin"),
292        ("linux", "aarch64") => Some("aarch64-unknown-linux-gnu"),
293        ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"),
294        ("windows", "aarch64") => Some("aarch64-pc-windows-msvc"),
295        ("windows", "x86_64") if windows_gnu => Some("x86_64-pc-windows-gnu"),
296        ("windows", "x86_64") => Some("x86_64-pc-windows-msvc"),
297        _ => None,
298    }
299}
300
301fn version_is_newer(current: &str, candidate: &str) -> bool {
302    let parse = |v: &str| -> Vec<u32> { v.split('.').filter_map(|p| p.parse().ok()).collect() };
303    let cur = parse(current);
304    let cand = parse(candidate);
305    cand > cur
306}
307
308async fn download_binary(url: &str, sha256sums_url: Option<&str>, dest: &Path) -> Result<()> {
309    let client = reqwest::Client::builder()
310        .user_agent(format!("zeroclaw/{}", env!("CARGO_PKG_VERSION")))
311        .timeout(std::time::Duration::from_secs(300))
312        .build()?;
313
314    let resp = client
315        .get(url)
316        .send()
317        .await
318        .context("download request failed")?;
319    if !resp.status().is_success() {
320        bail!("download returned {}", resp.status());
321    }
322
323    let bytes = resp.bytes().await.context("failed to read download body")?;
324
325    if let Some(sums_url) = sha256sums_url {
326        verify_download_checksum(&bytes, url, sums_url, &client).await?;
327    } else {
328        ::zeroclaw_log::record!(
329            WARN,
330            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
331                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
332            "No SHA256SUMS asset found; skipping update download checksum verification"
333        );
334    }
335
336    // Release assets are .tar.gz archives containing a single `zeroclaw` binary.
337    // Extract the binary from the archive instead of writing the raw tarball.
338    if url.ends_with(".tar.gz") || url.ends_with(".tgz") {
339        extract_tar_gz(&bytes, dest).context("failed to extract binary from tar.gz archive")?;
340    } else {
341        tokio::fs::write(dest, &bytes)
342            .await
343            .context("failed to write downloaded binary")?;
344    }
345
346    // Make executable on Unix
347    #[cfg(unix)]
348    {
349        use std::os::unix::fs::PermissionsExt;
350        let perms = std::fs::Permissions::from_mode(0o755);
351        tokio::fs::set_permissions(dest, perms).await?;
352    }
353
354    Ok(())
355}
356
357async fn verify_download_checksum(
358    bytes: &[u8],
359    asset_url: &str,
360    sha256sums_url: &str,
361    client: &reqwest::Client,
362) -> Result<()> {
363    let asset_name = asset_name_from_url(asset_url)
364        .context("cannot derive release asset filename from download URL")?;
365
366    let sums_resp = client
367        .get(sha256sums_url)
368        .send()
369        .await
370        .context("failed to fetch SHA256SUMS")?;
371    if !sums_resp.status().is_success() {
372        bail!("SHA256SUMS fetch returned {}", sums_resp.status());
373    }
374
375    let sums_text = sums_resp
376        .text()
377        .await
378        .context("failed to read SHA256SUMS body")?;
379    verify_checksum_bytes(bytes, &asset_name, &sums_text)?;
380
381    ::zeroclaw_log::record!(
382        INFO,
383        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
384            .with_outcome(::zeroclaw_log::EventOutcome::Success)
385            .with_attrs(::serde_json::json!({"asset": asset_name})),
386        "Update download checksum verified"
387    );
388    Ok(())
389}
390
391fn verify_checksum_bytes(bytes: &[u8], asset_name: &str, sums_text: &str) -> Result<()> {
392    let expected_hex = expected_sha256_for_asset(sums_text, asset_name)?;
393    let actual_hex = hex::encode(Sha256::digest(bytes));
394
395    if !actual_hex.eq_ignore_ascii_case(expected_hex) {
396        bail!(
397            "checksum mismatch for '{asset_name}': expected {expected_hex}, got {actual_hex}. \
398             The downloaded update may be corrupted or tampered with."
399        );
400    }
401
402    Ok(())
403}
404
405fn asset_name_from_url(url: &str) -> Option<String> {
406    reqwest::Url::parse(url)
407        .ok()?
408        .path_segments()?
409        .next_back()
410        .filter(|name| !name.is_empty())
411        .map(str::to_string)
412}
413
414fn expected_sha256_for_asset<'a>(sums_text: &'a str, asset_name: &str) -> Result<&'a str> {
415    for line in sums_text.lines() {
416        let mut parts = line.split_whitespace();
417        let Some(digest) = parts.next() else {
418            continue;
419        };
420        let Some(name) = parts.next() else {
421            continue;
422        };
423        let name = name.trim_start_matches('*');
424        if name == asset_name {
425            if parts.next().is_some() {
426                bail!("invalid SHA256SUMS entry for '{asset_name}'");
427            }
428            if !is_sha256_hex(digest) {
429                bail!("invalid SHA256SUMS entry for '{asset_name}'");
430            }
431            return Ok(digest);
432        }
433    }
434
435    bail!("asset '{asset_name}' not found in SHA256SUMS")
436}
437
438fn is_sha256_hex(value: &str) -> bool {
439    value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())
440}
441
442/// Extract the `zeroclaw` binary from a `.tar.gz` archive.
443fn extract_tar_gz(archive_bytes: &[u8], dest: &Path) -> Result<()> {
444    use flate2::read::GzDecoder;
445    use std::io::Read;
446    use tar::Archive;
447
448    let gz = GzDecoder::new(archive_bytes);
449    let mut archive = Archive::new(gz);
450
451    for entry in archive.entries().context("failed to read tar entries")? {
452        let mut entry = entry.context("failed to read tar entry")?;
453        let path = entry.path().context("failed to read entry path")?;
454
455        // The archive contains a single binary named "zeroclaw" (or "zeroclaw.exe" on Windows).
456        let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
457
458        if file_name == "zeroclaw" || file_name == "zeroclaw.exe" {
459            let mut buf = Vec::new();
460            entry
461                .read_to_end(&mut buf)
462                .context("failed to read binary from archive")?;
463            std::fs::write(dest, &buf).context("failed to write extracted binary")?;
464            return Ok(());
465        }
466    }
467
468    bail!("archive does not contain a 'zeroclaw' binary")
469}
470
471async fn validate_binary(path: &Path) -> Result<()> {
472    let meta = tokio::fs::metadata(path).await?;
473    if meta.len() < 1_000_000 {
474        bail!(
475            "downloaded binary too small ({} bytes), likely corrupt",
476            meta.len()
477        );
478    }
479
480    // Check binary architecture before attempting execution so we can give
481    // a clear diagnostic instead of the opaque "Exec format error (os error 8)".
482    check_binary_arch(path).await?;
483
484    // Quick check: try running --version
485    let output = tokio::process::Command::new(path)
486        .arg("--version")
487        .output()
488        .await
489        .context("cannot execute downloaded binary")?;
490
491    if !output.status.success() {
492        bail!("downloaded binary --version check failed");
493    }
494
495    let stdout = String::from_utf8_lossy(&output.stdout);
496    if !stdout.contains("zeroclaw") {
497        bail!("downloaded binary does not appear to be zeroclaw");
498    }
499
500    Ok(())
501}
502
503/// Read the binary header and verify its architecture matches the host.
504///
505/// On Linux/FreeBSD this reads the ELF header; on macOS the Mach-O header.
506/// If the binary is for a different architecture, returns a descriptive error
507/// instead of the opaque "Exec format error (os error 8)".
508async fn check_binary_arch(path: &Path) -> Result<()> {
509    let header = tokio::fs::read(path)
510        .await
511        .map(|bytes| bytes.into_iter().take(32).collect::<Vec<u8>>())
512        .context("failed to read binary header")?;
513
514    if header.len() < 20 {
515        bail!("downloaded file too small to be a valid binary");
516    }
517
518    let binary_arch = detect_arch_from_header(&header);
519    let host_arch = host_architecture();
520
521    if let (Some(bin), Some(host)) = (binary_arch, host_arch) {
522        if bin != host {
523            bail!(
524                "architecture mismatch: downloaded binary is {bin} but this host is {host} — \
525                 the release asset may be mispackaged"
526            );
527        }
528    }
529
530    Ok(())
531}
532
533/// Detect the CPU architecture from an ELF or Mach-O binary header.
534fn detect_arch_from_header(header: &[u8]) -> Option<&'static str> {
535    // ELF magic: 0x7f 'E' 'L' 'F'
536    if header.len() >= 20 && header[0..4] == [0x7f, b'E', b'L', b'F'] {
537        // e_machine is at offset 18 (2 bytes, little-endian for LE binaries)
538        let e_machine = u16::from_le_bytes([header[18], header[19]]);
539        return Some(match e_machine {
540            0x3E => "x86_64",
541            0xB7 => "aarch64",
542            0x03 => "x86",
543            0x28 => "arm",
544            0xF3 => "riscv",
545            _ => "unknown-elf",
546        });
547    }
548
549    // Mach-O magic (64-bit little-endian): 0xFEEDFACF
550    if header.len() >= 8 && header[0..4] == [0xCF, 0xFA, 0xED, 0xFE] {
551        let cputype = u32::from_le_bytes([header[4], header[5], header[6], header[7]]);
552        return Some(match cputype {
553            0x0100_0007 => "x86_64",
554            0x0100_000C => "aarch64",
555            _ => "unknown-macho",
556        });
557    }
558
559    None
560}
561
562/// Return the host CPU architecture as a human-readable string.
563fn host_architecture() -> Option<&'static str> {
564    if cfg!(target_arch = "x86_64") {
565        Some("x86_64")
566    } else if cfg!(target_arch = "aarch64") {
567        Some("aarch64")
568    } else if cfg!(target_arch = "x86") {
569        Some("x86")
570    } else if cfg!(target_arch = "arm") {
571        Some("arm")
572    } else {
573        None
574    }
575}
576
577async fn swap_binary(new: &Path, target: &Path) -> Result<()> {
578    // On Linux, a running binary cannot be overwritten in place (ETXTBSY).
579    // Remove the old file first, then copy the new one into the now-free path.
580    // This works because the kernel keeps the inode alive until the process exits.
581    tokio::fs::remove_file(target)
582        .await
583        .context("failed to remove old binary")?;
584    tokio::fs::copy(new, target)
585        .await
586        .context("failed to write new binary")?;
587    Ok(())
588}
589
590async fn rollback_binary(backup: &Path, target: &Path) -> Result<()> {
591    // Remove-then-copy to avoid ETXTBSY if the target is somehow still mapped.
592    let _ = tokio::fs::remove_file(target).await;
593    tokio::fs::copy(backup, target)
594        .await
595        .context("failed to restore backup binary")?;
596    Ok(())
597}
598
599async fn smoke_test(binary: &Path) -> Result<()> {
600    let output = tokio::process::Command::new(binary)
601        .arg("--version")
602        .output()
603        .await
604        .context("smoke test: cannot execute updated binary")?;
605
606    if !output.status.success() {
607        bail!("smoke test: updated binary returned non-zero exit code");
608    }
609
610    Ok(())
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    #[test]
618    fn test_version_comparison() {
619        assert!(version_is_newer("0.4.3", "0.5.0"));
620        assert!(version_is_newer("0.4.3", "0.4.4"));
621        assert!(!version_is_newer("0.5.0", "0.4.3"));
622        assert!(!version_is_newer("0.4.3", "0.4.3"));
623        assert!(version_is_newer("1.0.0", "2.0.0"));
624    }
625
626    #[test]
627    fn current_target_triple_is_not_empty() {
628        let triple = current_target_triple().expect("supported test platform");
629        // The triple must contain at least two hyphens (arch-vendor-os or arch-vendor-os-env)
630        assert!(
631            triple.matches('-').count() >= 2,
632            "triple should have at least two hyphens: {triple}"
633        );
634    }
635
636    #[test]
637    fn target_triple_for_rejects_unsupported_architectures() {
638        assert_eq!(target_triple_for("linux", "arm", false), None);
639        assert_eq!(target_triple_for("macos", "powerpc", false), None);
640        assert_eq!(target_triple_for("windows", "x86", false), None);
641    }
642
643    #[test]
644    fn target_triple_for_distinguishes_windows_envs() {
645        assert_eq!(
646            target_triple_for("windows", "x86_64", false),
647            Some("x86_64-pc-windows-msvc")
648        );
649        assert_eq!(
650            target_triple_for("windows", "x86_64", true),
651            Some("x86_64-pc-windows-gnu")
652        );
653    }
654
655    fn make_release(assets: &[&str]) -> serde_json::Value {
656        let assets: Vec<serde_json::Value> = assets
657            .iter()
658            .map(|name| {
659                serde_json::json!({
660                    "name": name,
661                    "browser_download_url": format!("https://example.com/{name}")
662                })
663            })
664            .collect();
665        serde_json::json!({ "assets": assets })
666    }
667
668    #[test]
669    fn find_asset_url_picks_correct_gnu_over_android() {
670        let release = make_release(&[
671            "zeroclaw-aarch64-linux-android.tar.gz",
672            "zeroclaw-aarch64-unknown-linux-gnu.tar.gz",
673            "zeroclaw-x86_64-unknown-linux-gnu.tar.gz",
674            "zeroclaw-x86_64-apple-darwin.tar.gz",
675            "zeroclaw-aarch64-apple-darwin.tar.gz",
676            "zeroclaw-x86_64-pc-windows-msvc.zip",
677            "zeroclaw-aarch64-pc-windows-msvc.zip",
678        ]);
679
680        let url = find_asset_url(&release);
681        assert!(url.is_some(), "should find an asset");
682        let url = url.unwrap();
683        // Must NOT match the android binary
684        assert!(
685            !url.contains("android"),
686            "should not select android binary, got: {url}"
687        );
688    }
689
690    #[test]
691    fn find_asset_url_ignores_non_installable_assets() {
692        let target = current_target_triple().expect("supported test platform");
693        let release = make_release(&[
694            &format!("zeroclaw-{target}.tar.gz.sha256"),
695            &format!("zeroclaw-{target}.zip.sha256"),
696            &format!("zeroclaw-{target}.zip"),
697            &format!("zeroclaw-{target}.tar.gz"),
698        ]);
699
700        let url = find_asset_url(&release).expect("should select archive asset");
701        assert!(
702            url.ends_with(".tar.gz"),
703            "should select release archive, got: {url}"
704        );
705    }
706
707    #[test]
708    fn find_asset_url_skips_matching_asset_with_unusable_url() {
709        let target = current_target_triple().expect("supported test platform");
710        let release = serde_json::json!({
711            "assets": [
712                {
713                    "name": format!("zeroclaw-{target}.tar.gz"),
714                    "browser_download_url": ""
715                },
716                {
717                    "name": format!("zeroclaw-{target}.tgz"),
718                    "browser_download_url": null
719                },
720                {
721                    "name": format!("zeroclaw-{target}.tar.gz"),
722                    "browser_download_url": format!("https://example.com/zeroclaw-{target}.tar.gz")
723                }
724            ]
725        });
726
727        let url = find_asset_url(&release).expect("should skip unusable URLs");
728        assert_eq!(url, format!("https://example.com/zeroclaw-{target}.tar.gz"));
729    }
730
731    #[test]
732    fn find_asset_url_ignores_non_zeroclaw_assets() {
733        let target = current_target_triple().expect("supported test platform");
734        let release = make_release(&[
735            &format!("helper-{target}.tar.gz"),
736            &format!("zeroclaw-{target}.tar.gz"),
737        ]);
738
739        let url = find_asset_url(&release).expect("should select zeroclaw asset");
740        assert!(
741            url.contains(&format!("zeroclaw-{target}.tar.gz")),
742            "should select zeroclaw archive, got: {url}"
743        );
744    }
745
746    #[test]
747    fn installable_release_asset_rejects_unknown_target() {
748        assert!(!is_installable_release_asset(
749            "zeroclaw-x86_64-unknown-linux-gnu.tar.gz",
750            "unknown"
751        ));
752    }
753
754    #[test]
755    fn find_asset_url_returns_none_for_empty_assets() {
756        let release = serde_json::json!({ "assets": [] });
757        assert!(find_asset_url(&release).is_none());
758    }
759
760    #[test]
761    fn find_asset_url_returns_none_for_missing_assets() {
762        let release = serde_json::json!({});
763        assert!(find_asset_url(&release).is_none());
764    }
765
766    #[test]
767    fn find_sha256sums_url_accepts_common_names() {
768        for name in ["SHA256SUMS", "sha256sums.txt", "checksums.sha256sums"] {
769            let release = make_release(&[name]);
770            assert_eq!(
771                find_sha256sums_url(&release),
772                Some(format!("https://example.com/{name}"))
773            );
774        }
775    }
776
777    #[test]
778    fn find_sha256sums_url_is_case_insensitive() {
779        let release = make_release(&["Sha256Sums"]);
780        assert_eq!(
781            find_sha256sums_url(&release),
782            Some("https://example.com/Sha256Sums".to_string())
783        );
784    }
785
786    #[test]
787    fn find_sha256sums_url_skips_missing_or_unusable_url() {
788        let release = serde_json::json!({
789            "assets": [
790                {
791                    "name": "zeroclaw-x86_64-unknown-linux-gnu.tar.gz",
792                    "browser_download_url": "https://example.com/asset"
793                },
794                {
795                    "name": "SHA256SUMS",
796                    "browser_download_url": ""
797                },
798                {
799                    "name": "sha256sums.txt",
800                    "browser_download_url": null
801                },
802                {
803                    "name": "checksums.sha256sums",
804                    "browser_download_url": "https://example.com/checksums.sha256sums"
805                }
806            ]
807        });
808
809        assert_eq!(
810            find_sha256sums_url(&release),
811            Some("https://example.com/checksums.sha256sums".to_string())
812        );
813    }
814
815    #[test]
816    fn find_sha256sums_url_prefers_canonical_asset() {
817        let release = serde_json::json!({
818            "assets": [
819                {
820                    "name": "checksums.sha256sums",
821                    "browser_download_url": "https://example.com/checksums.sha256sums"
822                },
823                {
824                    "name": "SHA256SUMS",
825                    "browser_download_url": "https://example.com/SHA256SUMS"
826                }
827            ]
828        });
829
830        assert_eq!(
831            find_sha256sums_url(&release),
832            Some("https://example.com/SHA256SUMS".to_string())
833        );
834    }
835
836    #[test]
837    fn expected_sha256_for_asset_matches_text_and_binary_mode_entries() {
838        let digest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
839        let sums = format!(
840            "{digest}  zeroclaw-aarch64-apple-darwin.tar.gz\n\
841             {digest} *zeroclaw-x86_64-unknown-linux-gnu.tar.gz\n"
842        );
843
844        assert_eq!(
845            expected_sha256_for_asset(&sums, "zeroclaw-aarch64-apple-darwin.tar.gz").unwrap(),
846            digest
847        );
848        assert_eq!(
849            expected_sha256_for_asset(&sums, "zeroclaw-x86_64-unknown-linux-gnu.tar.gz").unwrap(),
850            digest
851        );
852    }
853
854    #[test]
855    fn expected_sha256_for_asset_rejects_missing_or_malformed_entry() {
856        let err = expected_sha256_for_asset(
857            "not-a-hex-digest  zeroclaw-x86_64-unknown-linux-gnu.tar.gz\n",
858            "zeroclaw-x86_64-unknown-linux-gnu.tar.gz",
859        )
860        .unwrap_err()
861        .to_string();
862        assert!(err.contains("invalid SHA256SUMS entry"));
863
864        let err = expected_sha256_for_asset(
865            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855  other.tar.gz\n",
866            "zeroclaw-x86_64-unknown-linux-gnu.tar.gz",
867        )
868        .unwrap_err()
869        .to_string();
870        assert!(err.contains("not found"));
871
872        let err = expected_sha256_for_asset(
873            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855  zeroclaw-x86_64-unknown-linux-gnu.tar.gz extra\n",
874            "zeroclaw-x86_64-unknown-linux-gnu.tar.gz",
875        )
876        .unwrap_err()
877        .to_string();
878        assert!(err.contains("invalid SHA256SUMS entry"));
879    }
880
881    #[test]
882    fn verify_checksum_bytes_accepts_matching_digest_and_rejects_mismatch() {
883        let asset_name = "zeroclaw-x86_64-unknown-linux-gnu.tar.gz";
884        let digest = hex::encode(Sha256::digest(b"downloaded bytes"));
885        let sums = format!("{digest}  {asset_name}\n");
886
887        verify_checksum_bytes(b"downloaded bytes", asset_name, &sums).unwrap();
888
889        let err = verify_checksum_bytes(b"tampered bytes", asset_name, &sums)
890            .unwrap_err()
891            .to_string();
892        assert!(err.contains("checksum mismatch"));
893    }
894
895    #[test]
896    fn asset_name_from_url_uses_last_path_component() {
897        assert_eq!(
898            asset_name_from_url(
899                "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v0.8.0/zeroclaw-aarch64-apple-darwin.tar.gz"
900            ),
901            Some("zeroclaw-aarch64-apple-darwin.tar.gz".to_string())
902        );
903        assert_eq!(
904            asset_name_from_url(
905                "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v0.8.0/zeroclaw-aarch64-apple-darwin.tar.gz?download=1#asset"
906            ),
907            Some("zeroclaw-aarch64-apple-darwin.tar.gz".to_string())
908        );
909        assert_eq!(asset_name_from_url("https://example.com/releases/"), None);
910    }
911
912    #[tokio::test]
913    async fn download_binary_verifies_checksum_before_writing() {
914        use wiremock::matchers::{method, path};
915        use wiremock::{Mock, MockServer, ResponseTemplate};
916
917        let server = MockServer::start().await;
918        let asset = b"downloaded bytes";
919        let digest = hex::encode(Sha256::digest(asset));
920        let sums = format!("{digest}  zeroclaw-test.bin\n");
921
922        Mock::given(method("GET"))
923            .and(path("/zeroclaw-test.bin"))
924            .respond_with(ResponseTemplate::new(200).set_body_bytes(asset))
925            .mount(&server)
926            .await;
927        Mock::given(method("GET"))
928            .and(path("/SHA256SUMS"))
929            .respond_with(ResponseTemplate::new(200).set_body_string(sums))
930            .mount(&server)
931            .await;
932
933        let tmp = tempfile::tempdir().unwrap();
934        let dest = tmp.path().join("zeroclaw_new");
935        download_binary(
936            &format!("{}/zeroclaw-test.bin", server.uri()),
937            Some(&format!("{}/SHA256SUMS", server.uri())),
938            &dest,
939        )
940        .await
941        .unwrap();
942
943        assert_eq!(std::fs::read(dest).unwrap(), asset);
944    }
945
946    #[tokio::test]
947    async fn download_binary_rejects_checksum_mismatch_without_writing() {
948        use wiremock::matchers::{method, path};
949        use wiremock::{Mock, MockServer, ResponseTemplate};
950
951        let server = MockServer::start().await;
952        let asset = b"downloaded bytes";
953        let digest = hex::encode(Sha256::digest(b"different bytes"));
954        let sums = format!("{digest}  zeroclaw-test.bin\n");
955
956        Mock::given(method("GET"))
957            .and(path("/zeroclaw-test.bin"))
958            .respond_with(ResponseTemplate::new(200).set_body_bytes(asset))
959            .mount(&server)
960            .await;
961        Mock::given(method("GET"))
962            .and(path("/SHA256SUMS"))
963            .respond_with(ResponseTemplate::new(200).set_body_string(sums))
964            .mount(&server)
965            .await;
966
967        let tmp = tempfile::tempdir().unwrap();
968        let dest = tmp.path().join("zeroclaw_new");
969        let err = download_binary(
970            &format!("{}/zeroclaw-test.bin", server.uri()),
971            Some(&format!("{}/SHA256SUMS", server.uri())),
972            &dest,
973        )
974        .await
975        .unwrap_err()
976        .to_string();
977
978        assert!(err.contains("checksum mismatch"));
979        assert!(!dest.exists());
980    }
981
982    #[tokio::test]
983    async fn download_binary_preserves_missing_checksum_fallback() {
984        use wiremock::matchers::{method, path};
985        use wiremock::{Mock, MockServer, ResponseTemplate};
986
987        let server = MockServer::start().await;
988        let asset = b"downloaded bytes";
989
990        Mock::given(method("GET"))
991            .and(path("/zeroclaw-test.bin"))
992            .respond_with(ResponseTemplate::new(200).set_body_bytes(asset))
993            .mount(&server)
994            .await;
995
996        let tmp = tempfile::tempdir().unwrap();
997        let dest = tmp.path().join("zeroclaw_new");
998        download_binary(&format!("{}/zeroclaw-test.bin", server.uri()), None, &dest)
999            .await
1000            .unwrap();
1001
1002        assert_eq!(std::fs::read(dest).unwrap(), asset);
1003    }
1004
1005    #[test]
1006    fn detect_arch_elf_x86_64() {
1007        // Minimal ELF header with e_machine = 0x3E (x86_64)
1008        let mut header = vec![0u8; 20];
1009        header[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
1010        header[18] = 0x3E;
1011        header[19] = 0x00;
1012        assert_eq!(detect_arch_from_header(&header), Some("x86_64"));
1013    }
1014
1015    #[test]
1016    fn detect_arch_elf_aarch64() {
1017        let mut header = vec![0u8; 20];
1018        header[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
1019        header[18] = 0xB7;
1020        header[19] = 0x00;
1021        assert_eq!(detect_arch_from_header(&header), Some("aarch64"));
1022    }
1023
1024    #[test]
1025    fn detect_arch_macho_x86_64() {
1026        // Mach-O 64-bit LE magic + cputype 0x01000007 (x86_64)
1027        let mut header = vec![0u8; 8];
1028        header[0..4].copy_from_slice(&[0xCF, 0xFA, 0xED, 0xFE]);
1029        header[4..8].copy_from_slice(&0x0100_0007u32.to_le_bytes());
1030        assert_eq!(detect_arch_from_header(&header), Some("x86_64"));
1031    }
1032
1033    #[test]
1034    fn detect_arch_macho_aarch64() {
1035        let mut header = vec![0u8; 8];
1036        header[0..4].copy_from_slice(&[0xCF, 0xFA, 0xED, 0xFE]);
1037        header[4..8].copy_from_slice(&0x0100_000Cu32.to_le_bytes());
1038        assert_eq!(detect_arch_from_header(&header), Some("aarch64"));
1039    }
1040
1041    #[test]
1042    fn detect_arch_unknown_format() {
1043        let header = vec![0u8; 20]; // all zeros — not ELF or Mach-O
1044        assert_eq!(detect_arch_from_header(&header), None);
1045    }
1046
1047    #[test]
1048    fn detect_arch_too_short() {
1049        let header = vec![0x7f, b'E', b'L', b'F']; // only 4 bytes
1050        assert_eq!(detect_arch_from_header(&header), None);
1051    }
1052
1053    #[test]
1054    fn host_architecture_is_known() {
1055        assert!(
1056            host_architecture().is_some(),
1057            "host architecture should be detected on CI platforms"
1058        );
1059    }
1060
1061    #[test]
1062    fn extract_tar_gz_finds_binary() {
1063        use flate2::Compression;
1064        use flate2::write::GzEncoder;
1065        use std::io::Write;
1066
1067        // Build a tar.gz in memory containing a fake "zeroclaw" binary.
1068        let fake_binary = b"#!/bin/sh\necho zeroclaw";
1069        let mut tar_buf = Vec::new();
1070        {
1071            let mut builder = tar::Builder::new(&mut tar_buf);
1072            let mut header = tar::Header::new_gnu();
1073            header.set_size(fake_binary.len() as u64);
1074            header.set_mode(0o755);
1075            header.set_cksum();
1076            builder
1077                .append_data(&mut header, "zeroclaw", &fake_binary[..])
1078                .unwrap();
1079            builder.finish().unwrap();
1080        }
1081
1082        let mut gz_buf = Vec::new();
1083        {
1084            let mut encoder = GzEncoder::new(&mut gz_buf, Compression::fast());
1085            encoder.write_all(&tar_buf).unwrap();
1086            encoder.finish().unwrap();
1087        }
1088
1089        let tmp = tempfile::tempdir().unwrap();
1090        let dest = tmp.path().join("zeroclaw_extracted");
1091        extract_tar_gz(&gz_buf, &dest).unwrap();
1092
1093        let content = std::fs::read(&dest).unwrap();
1094        assert_eq!(content, fake_binary);
1095    }
1096
1097    #[test]
1098    fn extract_tar_gz_errors_on_missing_binary() {
1099        use flate2::Compression;
1100        use flate2::write::GzEncoder;
1101        use std::io::Write;
1102
1103        // Build a tar.gz with a file that is NOT named "zeroclaw".
1104        let mut tar_buf = Vec::new();
1105        {
1106            let mut builder = tar::Builder::new(&mut tar_buf);
1107            let mut header = tar::Header::new_gnu();
1108            header.set_size(5);
1109            header.set_mode(0o644);
1110            header.set_cksum();
1111            builder
1112                .append_data(&mut header, "README.md", &b"hello"[..])
1113                .unwrap();
1114            builder.finish().unwrap();
1115        }
1116
1117        let mut gz_buf = Vec::new();
1118        {
1119            let mut encoder = GzEncoder::new(&mut gz_buf, Compression::fast());
1120            encoder.write_all(&tar_buf).unwrap();
1121            encoder.finish().unwrap();
1122        }
1123
1124        let tmp = tempfile::tempdir().unwrap();
1125        let dest = tmp.path().join("zeroclaw_extracted");
1126        let result = extract_tar_gz(&gz_buf, &dest);
1127        assert!(result.is_err());
1128        assert!(
1129            result.unwrap_err().to_string().contains("does not contain"),
1130            "should report missing binary"
1131        );
1132    }
1133}