1use 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
21pub 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(¤t, &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
74pub async fn run(target_version: Option<&str>) -> Result<()> {
78 ::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 ::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 ::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(¤t_exe, &backup_path)
126 .await
127 .context("failed to backup current binary")?;
128
129 ::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 ::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, ¤t_exe).await {
144 ::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, ¤t_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 ::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(¤t_exe).await {
170 Ok(()) => {
171 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, ¤t_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
245fn 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 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 #[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
412fn 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 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_arch(path).await?;
453
454 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
473async 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
503fn detect_arch_from_header(header: &[u8]) -> Option<&'static str> {
505 if header.len() >= 20 && header[0..4] == [0x7f, b'E', b'L', b'F'] {
507 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 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
532fn 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 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 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 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 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 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 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]; 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']; 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 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 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}