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