1use std::path::{Path, PathBuf};
27
28use anyhow::{anyhow, Context, Result};
29use serde::Deserialize;
30use sha2::{Digest, Sha256};
31use tokio::io::AsyncWriteExt;
32use tokio::sync::mpsc;
33
34pub const MANIFEST_URL: &str =
35 "https://raw.atomgit.com/atomgit_atomcode/atomcode/raw/main/latest.json";
36pub const DOWNLOAD_BASE: &str = "https://atomgit.com/atomgit_atomcode/atomcode/releases/download";
37
38#[derive(Debug, Clone)]
44pub enum UpgradeEvent {
45 ManifestFetched {
46 version: String,
47 },
48 Downloading {
49 bytes: u64,
50 total: u64,
51 },
52 Verifying,
53 Replacing,
54 Done {
55 version: String,
56 backup: PathBuf,
57 exe: PathBuf,
62 },
63 Failed(String),
66 RolledBack {
69 exe: PathBuf,
70 backup: PathBuf,
71 },
72}
73
74#[derive(Debug, Clone, Deserialize)]
75pub struct Manifest {
76 pub version: String,
77 #[serde(default)]
78 pub released_at: Option<String>,
79 pub binaries: std::collections::BTreeMap<String, BinaryEntry>,
80}
81
82#[derive(Debug, Clone, Deserialize)]
83pub struct BinaryEntry {
84 pub sha256: String,
85 pub size: u64,
86}
87
88#[derive(Debug, Clone)]
89pub struct UpgradeSummary {
90 pub version: String,
91 pub backup: PathBuf,
92 pub exe: PathBuf,
93}
94
95#[derive(Debug, Clone)]
96pub struct RollbackSummary {
97 pub exe: PathBuf,
98 pub backup: PathBuf,
99}
100
101pub fn detect_target() -> Option<&'static str> {
108 target_tag(std::env::consts::OS, std::env::consts::ARCH)
109}
110
111fn target_tag(os: &str, arch: &str) -> Option<&'static str> {
112 match (os, arch) {
113 ("macos", "aarch64") => Some("darwin-arm64"),
114 ("macos", "x86_64") => Some("darwin-x64"),
115 ("linux", "x86_64") => Some("linux-x64"),
116 ("linux", "aarch64") => Some("linux-arm64"),
117 ("windows", "x86_64") => Some("windows-x64"),
118 ("windows", "aarch64") => Some("windows-arm64"),
119 _ => None,
120 }
121}
122
123pub fn binary_filename(version: &str, target: &str) -> String {
126 if target.starts_with("windows") {
127 format!("atomcode-{}-{}.exe", version, target)
128 } else {
129 format!("atomcode-{}-{}", version, target)
130 }
131}
132
133pub fn binary_url(version: &str, target: &str) -> String {
134 format!(
135 "{}/{}/{}",
136 DOWNLOAD_BASE,
137 version,
138 binary_filename(version, target)
139 )
140}
141
142pub fn current_exe_path() -> Result<PathBuf> {
145 std::env::current_exe().context("could not resolve current executable path")
146}
147
148pub fn backup_path(exe: &Path) -> PathBuf {
153 let mut os = exe.as_os_str().to_os_string();
154 os.push(".bak");
155 PathBuf::from(os)
156}
157
158fn download_path(exe: &Path) -> PathBuf {
162 let dir = exe.parent().unwrap_or_else(|| Path::new("."));
163 dir.join(".atomcode.download")
164}
165
166pub(crate) fn rolling_path(exe: &Path) -> PathBuf {
168 let dir = exe.parent().unwrap_or_else(|| Path::new("."));
169 dir.join(".atomcode.rolling")
170}
171
172pub fn ensure_writable(exe: &Path) -> Result<()> {
180 let dir = exe
181 .parent()
182 .ok_or_else(|| anyhow!("executable has no parent directory: {}", exe.display()))?;
183 let probe = dir.join(".atomcode.writable-probe");
184 match std::fs::File::create(&probe) {
185 Ok(_) => {
186 let _ = std::fs::remove_file(&probe);
187 Ok(())
188 }
189 Err(e) => Err(anyhow!(
190 "{} is not writable by the current user ({}).\n\
191 Re-run with elevated privileges: sudo atomcode upgrade\n\
192 Or reinstall into a user-writable location (e.g. ~/.local/bin).",
193 dir.display(),
194 e
195 )),
196 }
197}
198
199pub async fn fetch_manifest() -> Result<Manifest> {
205 let client = reqwest::Client::builder()
206 .timeout(std::time::Duration::from_secs(30))
207 .user_agent(crate::ATOMCODE_USER_AGENT)
208 .build()?;
209 let resp = client
210 .get(MANIFEST_URL)
211 .send()
212 .await
213 .context("failed to fetch latest.json")?;
214 if !resp.status().is_success() {
215 return Err(anyhow!(
216 "fetching latest.json returned HTTP {}",
217 resp.status()
218 ));
219 }
220 let body = resp.text().await.context("reading latest.json body")?;
221 serde_json::from_str(&body)
222 .with_context(|| format!("parsing latest.json (body: {:?})", truncate(&body, 200)))
223}
224
225fn truncate(s: &str, max_chars: usize) -> String {
226 if s.chars().count() <= max_chars {
227 s.to_string()
228 } else {
229 let head: String = s.chars().take(max_chars).collect();
230 format!("{}…", head)
231 }
232}
233
234async fn download_and_verify(
243 url: &str,
244 expected_sha256: &str,
245 expected_size: u64,
246 dest: &Path,
247 progress: &mpsc::UnboundedSender<UpgradeEvent>,
248) -> Result<()> {
249 use futures::StreamExt;
250
251 if dest.exists() {
252 let _ = std::fs::remove_file(dest);
253 }
254
255 let client = reqwest::Client::builder()
256 .timeout(std::time::Duration::from_secs(600))
257 .user_agent(crate::ATOMCODE_USER_AGENT)
258 .build()?;
259 let resp = client
260 .get(url)
261 .send()
262 .await
263 .with_context(|| format!("GET {}", url))?;
264 if !resp.status().is_success() {
265 return Err(anyhow!(
266 "downloading {} returned HTTP {} — release may not exist for this platform",
267 url,
268 resp.status()
269 ));
270 }
271
272 let mut file = tokio::fs::File::create(dest)
286 .await
287 .with_context(|| format!("creating {}", dest.display()))?;
288 let mut hasher = Sha256::new();
289 let mut written: u64 = 0;
290 let mut stream = resp.bytes_stream();
291 while let Some(chunk) = stream.next().await {
292 let chunk = chunk.context("reading response chunk")?;
293 hasher.update(&chunk);
294 file.write_all(&chunk)
295 .await
296 .context("writing download to disk")?;
297 written += chunk.len() as u64;
298 let _ = progress.send(UpgradeEvent::Downloading {
300 bytes: written,
301 total: expected_size,
302 });
303 }
304 file.flush().await.context("flushing download to disk")?;
305 drop(file);
306
307 if written != expected_size {
308 let _ = std::fs::remove_file(dest);
309 return Err(anyhow!(
310 "short download: got {} bytes, expected {}",
311 written,
312 expected_size
313 ));
314 }
315
316 let _ = progress.send(UpgradeEvent::Verifying);
317 let got = hex_encode(&hasher.finalize());
318 if !got.eq_ignore_ascii_case(expected_sha256) {
319 let _ = std::fs::remove_file(dest);
320 return Err(anyhow!(
321 "checksum mismatch — possible corruption or tampering.\n expected: {}\n got: {}",
322 expected_sha256,
323 got
324 ));
325 }
326
327 Ok(())
328}
329
330fn hex_encode(bytes: &[u8]) -> String {
331 let mut out = String::with_capacity(bytes.len() * 2);
332 for b in bytes {
333 out.push_str(&format!("{:02x}", b));
334 }
335 out
336}
337
338fn try_remove_stale(path: &Path) -> bool {
358 if !path.exists() {
359 return true;
360 }
361
362 #[cfg(windows)]
363 {
364 if let Ok(meta) = path.metadata() {
365 let mut perm = meta.permissions();
366 if perm.readonly() {
367 perm.set_readonly(false);
368 let _ = std::fs::set_permissions(path, perm);
369 }
370 }
371 }
372
373 if std::fs::remove_file(path).is_ok() {
374 return true;
375 }
376 std::thread::sleep(std::time::Duration::from_millis(500));
378 std::fs::remove_file(path).is_ok()
379}
380
381fn replace_binary(new_bin: &Path, exe: &Path) -> Result<()> {
402 #[cfg(unix)]
403 {
404 use std::os::unix::fs::PermissionsExt;
405 let mut perm = std::fs::metadata(new_bin)
406 .with_context(|| format!("stat {}", new_bin.display()))?
407 .permissions();
408 perm.set_mode(0o755);
409 std::fs::set_permissions(new_bin, perm)
410 .with_context(|| format!("chmod {}", new_bin.display()))?;
411 }
412
413 let backup = backup_path(exe);
414 let rolling = rolling_path(exe);
415
416 try_remove_stale(&rolling);
418
419 std::fs::rename(exe, &rolling).with_context(|| {
421 format!(
422 "renaming current binary {} -> {} (swap step 1)",
423 exe.display(),
424 rolling.display()
425 )
426 })?;
427
428 if let Err(e) = std::fs::rename(new_bin, exe) {
430 let _ = std::fs::rename(&rolling, exe);
433 return Err(anyhow!(
434 "moving new binary into place failed ({}). Previous version restored.",
435 e
436 ));
437 }
438
439 let bak_removed = try_remove_stale(&backup);
442
443 if bak_removed {
445 if let Err(e) = std::fs::rename(&rolling, &backup) {
446 eprintln!(
450 "Note: could not preserve previous version as backup ({}). Rollback unavailable until next upgrade.",
451 e
452 );
453 }
454 } else {
455 eprintln!(
460 "Note: could not remove old backup {}. Rollback may point to an older version.\n The .rolling file at {} will be cleaned up on the next upgrade.",
461 backup.display(),
462 rolling.display()
463 );
464 }
465
466 Ok(())
467}
468
469pub async fn run_upgrade(
477 current_version: String,
478 force: bool,
479 tx: mpsc::UnboundedSender<UpgradeEvent>,
480) -> Result<UpgradeSummary> {
481 let current_version = current_version.as_str();
482 let target = detect_target().ok_or_else(|| {
483 anyhow!(
484 "this platform has no published atomcode release ({}/{})",
485 std::env::consts::OS,
486 std::env::consts::ARCH
487 )
488 })?;
489 let exe = current_exe_path()?;
490 ensure_writable(&exe)?;
491
492 let manifest = fetch_manifest().await?;
493 let _ = tx.send(UpgradeEvent::ManifestFetched {
494 version: manifest.version.clone(),
495 });
496
497 if !force && !is_newer(&manifest.version, current_version) {
498 return Err(anyhow!(
499 "{}: already on {} (latest is {}). Pass --force to reinstall.",
500 ALREADY_LATEST,
501 current_version,
502 manifest.version
503 ));
504 }
505
506 let entry = manifest.binaries.get(target).ok_or_else(|| {
507 anyhow!(
508 "manifest has no entry for target {} — this platform may not be in this release",
509 target
510 )
511 })?;
512
513 let url = binary_url(&manifest.version, target);
514 let download = download_path(&exe);
515 download_and_verify(&url, &entry.sha256, entry.size, &download, &tx).await?;
516
517 let _ = tx.send(UpgradeEvent::Replacing);
518 replace_binary(&download, &exe)?;
519
520 clear_pending_pointer();
527 if let Ok(entries) = std::fs::read_dir(staged_dir()) {
528 for e in entries.flatten() {
529 let p = e.path();
530 if p.file_name()
531 .and_then(|n| n.to_str())
532 .is_some_and(|n| n.starts_with("atomcode-"))
533 {
534 let _ = std::fs::remove_file(&p);
535 }
536 }
537 }
538
539 let backup = backup_path(&exe);
540 let _ = tx.send(UpgradeEvent::Done {
545 version: manifest.version.clone(),
546 backup: backup.clone(),
547 exe: exe.clone(),
548 });
549
550 Ok(UpgradeSummary {
551 version: manifest.version,
552 backup,
553 exe,
554 })
555}
556
557pub const ALREADY_LATEST: &str = "ALREADY_LATEST";
561
562const MAX_APPLY_ATTEMPTS: u32 = 3;
594
595#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
600pub struct PendingUpgrade {
601 pub version: String,
603 pub staged_path: PathBuf,
605 pub sha256: String,
609 pub size: u64,
611 pub created_at: String,
613 #[serde(default)]
617 pub attempts: u32,
618}
619
620#[derive(Debug, Clone)]
623pub struct AppliedUpgrade {
624 pub version: String,
625 pub backup: PathBuf,
626 pub exe: PathBuf,
627}
628
629pub fn staged_dir() -> PathBuf {
634 crate::config::Config::config_dir().join("staged")
635}
636
637fn pending_json_path() -> PathBuf {
638 staged_dir().join("pending.json")
639}
640
641fn staged_binary_path(version: &str, target: &str) -> PathBuf {
645 staged_dir().join(binary_filename(version, target))
646}
647
648pub fn read_pending() -> Result<Option<PendingUpgrade>> {
652 let path = pending_json_path();
653 if !path.exists() {
654 return Ok(None);
655 }
656 let body =
657 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
658 let pending: PendingUpgrade =
659 serde_json::from_str(&body).with_context(|| format!("parsing {}", path.display()))?;
660 Ok(Some(pending))
661}
662
663fn write_pending(pending: &PendingUpgrade) -> Result<()> {
664 let dir = staged_dir();
665 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
666 let body = serde_json::to_string_pretty(pending).context("serializing pending.json")?;
667 let path = pending_json_path();
668 std::fs::write(&path, body).with_context(|| format!("writing {}", path.display()))
669}
670
671fn clear_pending_pointer() {
672 let _ = std::fs::remove_file(pending_json_path());
673}
674
675pub async fn fetch_manifest_if_newer(current_version: &str) -> Result<Option<Manifest>> {
681 let manifest = fetch_manifest().await?;
682 if is_newer(&manifest.version, current_version) {
683 Ok(Some(manifest))
684 } else {
685 Ok(None)
686 }
687}
688
689pub async fn prepare_deferred_upgrade(
698 current_version: &str,
699 tx: mpsc::UnboundedSender<UpgradeEvent>,
700) -> Result<Option<PendingUpgrade>> {
701 let target = detect_target().ok_or_else(|| {
702 anyhow!(
703 "this platform has no published atomcode release ({}/{})",
704 std::env::consts::OS,
705 std::env::consts::ARCH
706 )
707 })?;
708
709 let manifest = fetch_manifest().await?;
710
711 if !is_newer(&manifest.version, current_version) {
712 return Ok(None);
713 }
714
715 let _ = tx.send(UpgradeEvent::ManifestFetched {
716 version: manifest.version.clone(),
717 });
718
719 if let Ok(Some(existing)) = read_pending() {
723 if existing.version == manifest.version && existing.staged_path.exists() {
724 if let Some(entry) = manifest.binaries.get(target) {
725 if existing.sha256.eq_ignore_ascii_case(&entry.sha256)
726 && existing.size == entry.size
727 {
728 return Ok(Some(existing));
729 }
730 }
731 }
732 }
733
734 let entry = manifest.binaries.get(target).ok_or_else(|| {
735 anyhow!(
736 "manifest has no entry for target {} — this platform may not be in this release",
737 target
738 )
739 })?;
740
741 let dir = staged_dir();
742 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
743
744 let staged_path = staged_binary_path(&manifest.version, target);
745 let url = binary_url(&manifest.version, target);
746 download_and_verify(&url, &entry.sha256, entry.size, &staged_path, &tx).await?;
747
748 let pending = PendingUpgrade {
749 version: manifest.version.clone(),
750 staged_path: staged_path.clone(),
751 sha256: entry.sha256.clone(),
752 size: entry.size,
753 created_at: chrono::Utc::now().to_rfc3339(),
754 attempts: 0,
755 };
756 write_pending(&pending)?;
757 Ok(Some(pending))
758}
759
760pub fn apply_pending_upgrade() -> Result<Option<AppliedUpgrade>> {
776 let mut pending = match read_pending() {
777 Ok(Some(p)) => p,
778 Ok(None) => return Ok(None),
779 Err(_) => {
780 clear_pending_pointer();
782 return Ok(None);
783 }
784 };
785
786 if pending.attempts >= MAX_APPLY_ATTEMPTS {
787 let _ = std::fs::remove_file(&pending.staged_path);
792 clear_pending_pointer();
793 return Ok(None);
794 }
795
796 pending.attempts += 1;
799 let _ = write_pending(&pending);
800
801 if !pending.staged_path.exists() {
802 clear_pending_pointer();
803 return Ok(None);
804 }
805
806 let actual_size = std::fs::metadata(&pending.staged_path)
807 .with_context(|| format!("stat {}", pending.staged_path.display()))?
808 .len();
809 if actual_size != pending.size {
810 let _ = std::fs::remove_file(&pending.staged_path);
811 clear_pending_pointer();
812 return Err(anyhow!(
813 "staged binary size changed between sessions (expected {}, got {}). Discarded.",
814 pending.size,
815 actual_size
816 ));
817 }
818
819 let mut hasher = Sha256::new();
820 let mut file = std::fs::File::open(&pending.staged_path)
821 .with_context(|| format!("opening {}", pending.staged_path.display()))?;
822 std::io::copy(&mut file, &mut hasher).context("hashing staged binary")?;
823 drop(file);
824 let got = hex_encode(&hasher.finalize());
825 if !got.eq_ignore_ascii_case(&pending.sha256) {
826 let _ = std::fs::remove_file(&pending.staged_path);
827 clear_pending_pointer();
828 return Err(anyhow!(
829 "staged binary sha256 drifted between sessions (expected {}, got {}). Discarded.",
830 pending.sha256,
831 got
832 ));
833 }
834
835 let exe = current_exe_path()?;
836 ensure_writable(&exe)?;
837 replace_binary(&pending.staged_path, &exe)?;
838
839 clear_pending_pointer();
841
842 Ok(Some(AppliedUpgrade {
843 version: pending.version,
844 backup: backup_path(&exe),
845 exe,
846 }))
847}
848
849pub fn re_exec_self(override_exe: Option<&Path>) -> Result<std::convert::Infallible> {
868 let exe = override_exe
869 .map(|p| p.to_path_buf())
870 .unwrap_or_else(|| current_exe_path().unwrap_or_else(|_| {
871 std::env::args_os().next()
874 .map(PathBuf::from)
875 .unwrap_or_default()
876 }));
877 let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
878
879 #[cfg(unix)]
880 {
881 use std::os::unix::process::CommandExt;
882 let err = std::process::Command::new(&exe).args(&args).exec();
883 Err(anyhow!("re-exec failed: {}", err))
885 }
886
887 #[cfg(windows)]
888 {
889 let status = std::process::Command::new(&exe)
894 .args(&args)
895 .spawn()
896 .with_context(|| format!("spawning new binary {}", exe.display()))?
897 .wait()
898 .with_context(|| "waiting for spawned binary to exit")?;
899 std::process::exit(status.code().unwrap_or(0));
900 }
901}
902
903fn is_newer(latest: &str, current: &str) -> bool {
908 match (parse_version(latest), parse_version(current)) {
909 (Some(a), Some(b)) => a > b,
910 _ => latest.trim() != current.trim(),
911 }
912}
913
914fn parse_version(s: &str) -> Option<(u64, u64, u64)> {
915 let s = s.trim();
916 let rest = s.strip_prefix('v')?;
917 let mut parts = rest.split('.');
918 let a = parts.next()?.parse().ok()?;
919 let b = parts.next()?.parse().ok()?;
920 let c = parts.next()?.parse().ok()?;
921 if parts.next().is_some() {
922 return None;
923 }
924 Some((a, b, c))
925}
926
927pub fn run_rollback() -> Result<RollbackSummary> {
932 let exe = current_exe_path()?;
933 let backup = backup_path(&exe);
934 if !backup.exists() {
935 return Err(anyhow!(
936 "no backup found at {} — nothing to roll back to",
937 backup.display()
938 ));
939 }
940 ensure_writable(&exe)?;
941
942 let rolling = rolling_path(&exe);
943 if rolling.exists() {
944 std::fs::remove_file(&rolling).ok();
945 }
946
947 std::fs::rename(&exe, &rolling).with_context(|| {
949 format!(
950 "renaming {} -> {} (swap step 1)",
951 exe.display(),
952 rolling.display()
953 )
954 })?;
955 if let Err(e) = std::fs::rename(&backup, &exe) {
957 let _ = std::fs::rename(&rolling, &exe);
959 return Err(anyhow!("rollback failed at step 2 ({}); state restored", e));
960 }
961 if let Err(e) = std::fs::rename(&rolling, &backup) {
963 return Err(anyhow!(
967 "rollback succeeded but tmp file {} could not be moved to {} ({}).\n\
968 Delete it manually; next /upgrade will overwrite it.",
969 rolling.display(),
970 backup.display(),
971 e
972 ));
973 }
974
975 Ok(RollbackSummary { exe, backup })
976}
977
978#[cfg(test)]
979mod tests {
980 use super::*;
981
982 #[test]
983 fn binary_filename_adds_exe_on_windows_targets() {
984 assert_eq!(
985 binary_filename("v4.19.0", "windows-x64"),
986 "atomcode-v4.19.0-windows-x64.exe"
987 );
988 assert_eq!(
989 binary_filename("v4.19.0", "windows-arm64"),
990 "atomcode-v4.19.0-windows-arm64.exe"
991 );
992 }
993
994 #[test]
995 fn binary_filename_plain_on_unix_targets() {
996 assert_eq!(
997 binary_filename("v4.19.0", "darwin-arm64"),
998 "atomcode-v4.19.0-darwin-arm64"
999 );
1000 assert_eq!(
1001 binary_filename("v4.19.0", "linux-x64"),
1002 "atomcode-v4.19.0-linux-x64"
1003 );
1004 }
1005
1006 #[test]
1007 fn binary_url_shape() {
1008 assert_eq!(
1009 binary_url("v4.19.0", "darwin-arm64"),
1010 "https://atomgit.com/atomgit_atomcode/atomcode/releases/download/v4.19.0/atomcode-v4.19.0-darwin-arm64"
1011 );
1012 }
1013
1014 #[test]
1015 fn detect_target_returns_something_on_tier1_hosts() {
1016 let t = detect_target();
1019 if cfg!(any(
1020 target_os = "macos",
1021 target_os = "linux",
1022 target_os = "windows"
1023 )) {
1024 if cfg!(any(target_arch = "x86_64", target_arch = "aarch64")) {
1025 assert!(t.is_some(), "expected target tag on this host");
1026 }
1027 }
1028 }
1029
1030 #[test]
1031 fn target_tag_matches_release_manifest_targets() {
1032 assert_eq!(target_tag("macos", "aarch64"), Some("darwin-arm64"));
1033 assert_eq!(target_tag("macos", "x86_64"), Some("darwin-x64"));
1034 assert_eq!(target_tag("linux", "x86_64"), Some("linux-x64"));
1035 assert_eq!(target_tag("linux", "aarch64"), Some("linux-arm64"));
1036 assert_eq!(target_tag("windows", "x86_64"), Some("windows-x64"));
1037 assert_eq!(target_tag("windows", "aarch64"), Some("windows-arm64"));
1038 assert_eq!(target_tag("linux", "arm"), None);
1039 }
1040
1041 #[test]
1042 fn backup_path_appends_bak() {
1043 let p = Path::new("/usr/local/bin/atomcode");
1044 assert_eq!(backup_path(p), PathBuf::from("/usr/local/bin/atomcode.bak"));
1045 }
1046
1047 #[test]
1048 fn try_remove_stale_deletes_normal_file() {
1049 let dir = tempfile::tempdir().expect("tempdir");
1050 let p = dir.path().join("atomcode.exe.bak");
1051 std::fs::write(&p, b"old").expect("seed");
1052 assert!(try_remove_stale(&p));
1053 assert!(!p.exists(), "backup should be gone");
1054 }
1055
1056 #[test]
1057 fn try_remove_stale_clears_readonly_then_deletes() {
1058 let dir = tempfile::tempdir().expect("tempdir");
1059 let p = dir.path().join("atomcode.exe.bak");
1060 std::fs::write(&p, b"old").expect("seed");
1061 let mut perm = std::fs::metadata(&p).unwrap().permissions();
1062 perm.set_readonly(true);
1063 std::fs::set_permissions(&p, perm).expect("set readonly");
1064 assert!(try_remove_stale(&p));
1068 assert!(!p.exists());
1069 }
1070
1071 #[test]
1072 fn try_remove_stale_returns_false_for_truly_locked_file() {
1073 let bogus = std::path::PathBuf::from("/no/such/dir/atomcode.exe.bak");
1081 assert!(!bogus.exists());
1082 assert!(try_remove_stale(&bogus));
1084 }
1085
1086 #[test]
1087 fn try_remove_stale_returns_true_for_nonexistent() {
1088 let dir = tempfile::tempdir().expect("tempdir");
1089 let p = dir.path().join("does_not_exist");
1090 assert!(!p.exists());
1091 assert!(try_remove_stale(&p));
1092 }
1093
1094 #[test]
1095 fn backup_path_preserves_exe_suffix_on_windows_style() {
1096 let p = Path::new("C:/Tools/atomcode.exe");
1097 assert_eq!(backup_path(p), PathBuf::from("C:/Tools/atomcode.exe.bak"));
1098 }
1099
1100 #[test]
1101 fn is_newer_semver() {
1102 assert!(is_newer("v4.19.0", "v4.18.2"));
1103 assert!(is_newer("v4.19.0", "v4.18.9"));
1104 assert!(is_newer("v5.0.0", "v4.99.99"));
1105 assert!(!is_newer("v4.19.0", "v4.19.0"));
1106 assert!(!is_newer("v4.18.0", "v4.19.0"));
1107 }
1108
1109 #[test]
1110 fn is_newer_falls_back_to_string_diff_on_garbage() {
1111 assert!(is_newer("build-abc", "build-xyz"));
1114 assert!(!is_newer("build-abc", "build-abc"));
1115 }
1116
1117 #[test]
1118 fn manifest_parses_minimal_shape() {
1119 let json = r#"{
1120 "version": "v4.19.0",
1121 "binaries": {
1122 "darwin-arm64": { "sha256": "abcd", "size": 1024 }
1123 }
1124 }"#;
1125 let m: Manifest = serde_json::from_str(json).unwrap();
1126 assert_eq!(m.version, "v4.19.0");
1127 assert_eq!(m.binaries["darwin-arm64"].size, 1024);
1128 }
1129
1130 #[test]
1131 fn manifest_ignores_unknown_fields() {
1132 let json = r#"{
1133 "version": "v4.19.0",
1134 "released_at": "2026-04-19T00:00:00Z",
1135 "signature": "future-field",
1136 "binaries": {
1137 "linux-x64": { "sha256": "ffff", "size": 42, "notes": "x" }
1138 }
1139 }"#;
1140 let m: Manifest = serde_json::from_str(json).unwrap();
1141 assert_eq!(m.released_at.as_deref(), Some("2026-04-19T00:00:00Z"));
1142 assert_eq!(m.binaries["linux-x64"].sha256, "ffff");
1143 }
1144
1145 #[test]
1146 fn hex_encode_matches_known_vectors() {
1147 assert_eq!(hex_encode(&[0x00, 0xff, 0x10]), "00ff10");
1148 assert_eq!(hex_encode(&[]), "");
1149 }
1150
1151 #[test]
1152 fn ensure_writable_probes_containing_dir() {
1153 let tmp = tempfile::tempdir().unwrap();
1156 let ok = tmp.path().join("atomcode");
1157 assert!(ensure_writable(&ok).is_ok());
1158
1159 let bogus = Path::new("/nonexistent-dir-xyzzy-9999/atomcode");
1160 let err = ensure_writable(bogus).unwrap_err().to_string();
1161 assert!(err.contains("sudo atomcode upgrade"), "got: {}", err);
1162 }
1163
1164 #[test]
1165 fn replace_binary_renames_live_to_bak_via_three_way_swap() {
1166 let tmp = tempfile::tempdir().unwrap();
1167 let exe = tmp.path().join("atomcode");
1168 let new = tmp.path().join(".atomcode.download");
1169 std::fs::write(&exe, b"OLD").unwrap();
1170 std::fs::write(&new, b"NEW").unwrap();
1171
1172 replace_binary(&new, &exe).unwrap();
1173
1174 assert_eq!(std::fs::read(&exe).unwrap(), b"NEW");
1175 let bak = backup_path(&exe);
1176 assert_eq!(std::fs::read(&bak).unwrap(), b"OLD");
1177 assert!(!new.exists());
1178 let rolling = rolling_path(&exe);
1180 assert!(!rolling.exists());
1181 }
1182
1183 #[test]
1184 fn replace_binary_overwrites_stale_bak() {
1185 let tmp = tempfile::tempdir().unwrap();
1186 let exe = tmp.path().join("atomcode");
1187 let new = tmp.path().join(".atomcode.download");
1188 let bak = backup_path(&exe);
1189 std::fs::write(&exe, b"V2").unwrap();
1190 std::fs::write(&new, b"V3").unwrap();
1191 std::fs::write(&bak, b"V1").unwrap();
1192
1193 replace_binary(&new, &exe).unwrap();
1194
1195 assert_eq!(std::fs::read(&exe).unwrap(), b"V3");
1196 assert_eq!(std::fs::read(&bak).unwrap(), b"V2");
1197 let rolling = rolling_path(&exe);
1199 assert!(!rolling.exists());
1200 }
1201
1202 #[test]
1203 fn replace_binary_cleans_leftover_rolling() {
1204 let tmp = tempfile::tempdir().unwrap();
1205 let exe = tmp.path().join("atomcode");
1206 let new = tmp.path().join(".atomcode.download");
1207 let rolling = rolling_path(&exe);
1208 std::fs::write(&exe, b"OLD").unwrap();
1209 std::fs::write(&new, b"NEW").unwrap();
1210 std::fs::write(&rolling, b"STALE").unwrap();
1211
1212 replace_binary(&new, &exe).unwrap();
1213
1214 assert_eq!(std::fs::read(&exe).unwrap(), b"NEW");
1215 let bak = backup_path(&exe);
1216 assert_eq!(std::fs::read(&bak).unwrap(), b"OLD");
1217 assert!(!rolling.exists());
1218 }
1219
1220 #[test]
1221 fn replace_binary_succeeds_even_when_bak_cannot_be_removed() {
1222 let tmp = tempfile::tempdir().unwrap();
1228 let exe = tmp.path().join("atomcode");
1229 let new = tmp.path().join(".atomcode.download");
1230 let bak = backup_path(&exe);
1231 let rolling = rolling_path(&exe);
1232 std::fs::write(&exe, b"V2").unwrap();
1233 std::fs::write(&new, b"V3").unwrap();
1234 std::fs::write(&bak, b"V1").unwrap();
1235
1236 replace_binary(&new, &exe).unwrap();
1237
1238 assert_eq!(std::fs::read(&exe).unwrap(), b"V3");
1240 assert!(bak.exists() || rolling.exists());
1244 }
1245
1246 #[test]
1247 fn rollback_swaps_live_and_bak() {
1248 let tmp = tempfile::tempdir().unwrap();
1249 let exe = tmp.path().join("atomcode");
1254 let bak = backup_path(&exe);
1255 std::fs::write(&exe, b"NEW").unwrap();
1256 std::fs::write(&bak, b"OLD").unwrap();
1257
1258 let rolling = tmp.path().join(".atomcode.rolling");
1260 std::fs::rename(&exe, &rolling).unwrap();
1261 std::fs::rename(&bak, &exe).unwrap();
1262 std::fs::rename(&rolling, &bak).unwrap();
1263
1264 assert_eq!(std::fs::read(&exe).unwrap(), b"OLD");
1265 assert_eq!(std::fs::read(&bak).unwrap(), b"NEW");
1266 }
1267
1268 #[test]
1269 fn pending_upgrade_serde_roundtrips() {
1270 let p = PendingUpgrade {
1271 version: "v4.19.1".to_string(),
1272 staged_path: PathBuf::from("/tmp/staged/atomcode-v4.19.1-darwin-arm64"),
1273 sha256: "abcd".to_string(),
1274 size: 1024,
1275 created_at: "2026-04-20T10:54:16Z".to_string(),
1276 attempts: 0,
1277 };
1278 let j = serde_json::to_string(&p).unwrap();
1279 let back: PendingUpgrade = serde_json::from_str(&j).unwrap();
1280 assert_eq!(back.version, "v4.19.1");
1281 assert_eq!(back.attempts, 0);
1282 }
1283
1284 #[test]
1285 fn pending_attempts_defaults_when_missing() {
1286 let j = r#"{
1289 "version": "v4.19.1",
1290 "staged_path": "/tmp/x",
1291 "sha256": "abcd",
1292 "size": 1024,
1293 "created_at": "2026-04-20T10:54:16Z"
1294 }"#;
1295 let p: PendingUpgrade = serde_json::from_str(j).unwrap();
1296 assert_eq!(p.attempts, 0);
1297 }
1298
1299 #[test]
1300 fn staged_binary_path_matches_release_artifact_name() {
1301 let p = staged_binary_path("v4.19.1", "darwin-arm64");
1302 assert!(p.ends_with("atomcode-v4.19.1-darwin-arm64"), "got: {:?}", p);
1303 }
1304
1305 #[test]
1306 fn staged_binary_path_adds_exe_for_windows() {
1307 let p = staged_binary_path("v4.19.1", "windows-x64");
1308 assert!(
1309 p.ends_with("atomcode-v4.19.1-windows-x64.exe"),
1310 "got: {:?}",
1311 p
1312 );
1313 }
1314
1315 fn sha256_hex(data: &[u8]) -> String {
1319 use sha2::{Digest, Sha256};
1320 let mut hasher = Sha256::new();
1321 hasher.update(data);
1322 hex_encode(&hasher.finalize())
1323 }
1324
1325 #[tokio::test]
1336 async fn download_succeeds_without_content_length() {
1337 use wiremock::matchers::{method, path};
1338 use wiremock::{Mock, MockServer, ResponseTemplate};
1339
1340 let server = MockServer::start().await;
1341
1342 let payload = vec![0xAB_u8; 100];
1346 let expected_sha = sha256_hex(&payload);
1347 let expected_size: u64 = payload.len() as u64;
1348
1349 Mock::given(method("GET"))
1350 .and(path("/binary"))
1351 .respond_with(
1352 ResponseTemplate::new(200)
1353 .set_body_raw(payload.clone(), "application/octet-stream"),
1355 )
1356 .mount(&server)
1357 .await;
1358
1359 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1360 let dir = tempfile::tempdir().unwrap();
1361 let dest = dir.path().join("download.bin");
1362
1363 let result = download_and_verify(
1364 &format!("{}/binary", server.uri()),
1365 &expected_sha,
1366 expected_size,
1367 &dest,
1368 &tx,
1369 )
1370 .await;
1371
1372 assert!(result.is_ok(), "download should succeed, got: {:?}", result);
1377 assert_eq!(std::fs::read(&dest).unwrap(), payload);
1378 }
1379
1380 #[tokio::test]
1383 async fn download_rejects_wrong_actual_size() {
1384 use wiremock::matchers::{method, path};
1385 use wiremock::{Mock, MockServer, ResponseTemplate};
1386
1387 let server = MockServer::start().await;
1388
1389 let payload = vec![0xAB_u8; 80];
1391 let expected_sha = sha256_hex(&payload);
1392 let expected_size: u64 = 100; Mock::given(method("GET"))
1395 .and(path("/binary"))
1396 .respond_with(
1397 ResponseTemplate::new(200)
1398 .set_body_raw(payload.clone(), "application/octet-stream"),
1399 )
1400 .mount(&server)
1401 .await;
1402
1403 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1404 let dir = tempfile::tempdir().unwrap();
1405 let dest = dir.path().join("download.bin");
1406
1407 let result = download_and_verify(
1408 &format!("{}/binary", server.uri()),
1409 &expected_sha,
1410 expected_size,
1411 &dest,
1412 &tx,
1413 )
1414 .await;
1415
1416 assert!(result.is_err(), "should fail on size mismatch");
1417 let err = result.unwrap_err().to_string();
1418 assert!(
1419 err.contains("short download"),
1420 "error should mention short download, got: {}",
1421 err
1422 );
1423 assert!(!dest.exists(), "partial download should be removed");
1425 }
1426
1427 #[tokio::test]
1430 async fn download_rejects_checksum_mismatch() {
1431 use wiremock::matchers::{method, path};
1432 use wiremock::{Mock, MockServer, ResponseTemplate};
1433
1434 let server = MockServer::start().await;
1435
1436 let payload = vec![0xAB_u8; 100];
1437 let wrong_sha = "0000000000000000000000000000000000000000000000000000000000000000";
1438 let expected_size: u64 = payload.len() as u64;
1439
1440 Mock::given(method("GET"))
1441 .and(path("/binary"))
1442 .respond_with(
1443 ResponseTemplate::new(200)
1444 .set_body_raw(payload.clone(), "application/octet-stream"),
1445 )
1446 .mount(&server)
1447 .await;
1448
1449 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1450 let dir = tempfile::tempdir().unwrap();
1451 let dest = dir.path().join("download.bin");
1452
1453 let result = download_and_verify(
1454 &format!("{}/binary", server.uri()),
1455 wrong_sha, expected_size,
1457 &dest,
1458 &tx,
1459 )
1460 .await;
1461
1462 assert!(result.is_err(), "should fail on checksum mismatch");
1463 let err = result.unwrap_err().to_string();
1464 assert!(
1465 err.contains("checksum mismatch"),
1466 "error should mention checksum mismatch, got: {}",
1467 err
1468 );
1469 assert!(!dest.exists(), "corrupted download should be removed");
1471 }
1472
1473 #[tokio::test]
1476 async fn download_succeeds_when_all_checks_pass() {
1477 use wiremock::matchers::{method, path};
1478 use wiremock::{Mock, MockServer, ResponseTemplate};
1479
1480 let server = MockServer::start().await;
1481
1482 let payload = vec![0xCD_u8; 256];
1483 let expected_sha = sha256_hex(&payload);
1484 let expected_size: u64 = payload.len() as u64;
1485
1486 Mock::given(method("GET"))
1487 .and(path("/binary"))
1488 .respond_with(
1489 ResponseTemplate::new(200)
1490 .set_body_raw(payload.clone(), "application/octet-stream"),
1491 )
1492 .mount(&server)
1493 .await;
1494
1495 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1496 let dir = tempfile::tempdir().unwrap();
1497 let dest = dir.path().join("download.bin");
1498
1499 let result = download_and_verify(
1500 &format!("{}/binary", server.uri()),
1501 &expected_sha,
1502 expected_size,
1503 &dest,
1504 &tx,
1505 )
1506 .await;
1507
1508 assert!(result.is_ok(), "download should succeed, got: {:?}", result);
1509 assert_eq!(std::fs::read(&dest).unwrap(), payload);
1510 }
1511
1512 #[tokio::test]
1514 async fn download_rejects_http_error() {
1515 use wiremock::matchers::{method, path};
1516 use wiremock::{Mock, MockServer, ResponseTemplate};
1517
1518 let server = MockServer::start().await;
1519
1520 Mock::given(method("GET"))
1521 .and(path("/binary"))
1522 .respond_with(ResponseTemplate::new(404))
1523 .mount(&server)
1524 .await;
1525
1526 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1527 let dir = tempfile::tempdir().unwrap();
1528 let dest = dir.path().join("download.bin");
1529
1530 let result = download_and_verify(
1531 &format!("{}/binary", server.uri()),
1532 "unused",
1533 100,
1534 &dest,
1535 &tx,
1536 )
1537 .await;
1538
1539 assert!(result.is_err());
1540 let err = result.unwrap_err().to_string();
1541 assert!(
1542 err.contains("HTTP 404"),
1543 "error should mention HTTP 404, got: {}",
1544 err
1545 );
1546 }
1547}