1use 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
48pub 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(¤t, &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
101pub async fn run(target_version: Option<&str>) -> Result<()> {
105 ::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 ::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 ::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(¤t_exe, &backup_path)
156 .await
157 .context("failed to backup current binary")?;
158
159 ::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 ::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, ¤t_exe).await {
174 ::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, ¤t_exe).await {
183 eprintln!("CRITICAL: Rollback also failed: {rollback_err}"); 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 ::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(¤t_exe).await {
200 Ok(()) => {
201 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, ¤t_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
275fn 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 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 #[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
442fn 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 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_arch(path).await?;
483
484 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
503async 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
533fn detect_arch_from_header(header: &[u8]) -> Option<&'static str> {
535 if header.len() >= 20 && header[0..4] == [0x7f, b'E', b'L', b'F'] {
537 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 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
562fn 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 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 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 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 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 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 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]; 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']; 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 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 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}