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