Skip to main content

atomcode_core/
self_update.rs

1//! In-place binary upgrade for atomcode.
2//!
3//! Flow:
4//! 1. Fetch `latest.json` manifest (version + per-target sha256/size).
5//! 2. Detect current platform and pick the matching binary entry.
6//! 3. Verify we can write to `current_exe()`'s directory — if not, fail
7//!    with a precise message telling the user to re-run with `sudo`.
8//! 4. Download the binary to a sibling temp file, streaming progress.
9//! 5. Verify SHA256 against the manifest. Bail (and delete temp) on
10//!    mismatch — we never touch the live binary until verification
11//!    passes.
12//! 6. Three-way swap to replace the live binary:
13//!    a. `atomcode` → `.atomcode.rolling`  (Windows allows renaming a running exe)
14//!    b. new binary → `atomcode`            (install the upgrade)
15//!    c. best-effort: remove old `.bak`, then `.atomcode.rolling` → `.bak`
16//!
17//!    Steps a–b are the critical path; step c is best-effort. If the old
18//!    `.bak` is locked (AV scanner, still-running process, read-only
19//!    attribute), the upgrade still succeeds — the `.rolling` file lingers
20//!    and is cleaned up on the next upgrade attempt.
21//!
22//! Rollback swaps the live binary with `.bak` in place, so one backup
23//! always points to "the other version" — the user can toggle by
24//! alternating `/upgrade` and `/upgrade rollback`.
25
26use 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/// Streamed progress events from the upgrade/rollback machinery.
39///
40/// Sender is always async-owned (the upgrade task); receivers are the
41/// TUI event loop or the CLI `stdout` logger. Events are advisory —
42/// dropping the receiver must never block the upgrade.
43#[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        /// The *original* exe path (e.g. `atomcode.exe`) **before**
58        /// `replace_binary` renamed it. On Windows, `current_exe()`
59        /// returns the renamed path after the swap, so callers must
60        /// use this field for `re_exec_self`.
61        exe: PathBuf,
62    },
63    /// Terminal failure. Carries the display-formatted error so the UI
64    /// layer doesn't need `anyhow` to render it.
65    Failed(String),
66    /// Rollback finished. Reported through the same channel so the TUI
67    /// can drive both flows with a single select arm.
68    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
101/// Return the target tag used in release artifact names
102/// (`darwin-arm64`, `linux-x64`, `windows-x64`, …).
103///
104/// `None` means the current platform has no published release — the
105/// caller must surface a clean "unsupported platform" message rather
106/// than fall through to a 404 download.
107pub 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
123/// Release artifact filename for a given version + target, matching
124/// what `scripts/release.sh` publishes to `dist/<version>/`.
125pub 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
142/// Path of the running `atomcode` executable. Resolved once at the
143/// start of an upgrade so we know what to replace.
144pub fn current_exe_path() -> Result<PathBuf> {
145    std::env::current_exe().context("could not resolve current executable path")
146}
147
148/// Sibling path used to stash the previous binary.
149///
150/// Unix: `atomcode` → `atomcode.bak`.
151/// Windows: `atomcode.exe` → `atomcode.exe.bak`.
152pub 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
158/// Temp file where an in-flight download is written before atomic
159/// rename. Dotted prefix so it doesn't show up in a casual `ls`, and
160/// also so it's easy to identify-and-clean if a crash orphans it.
161fn download_path(exe: &Path) -> PathBuf {
162    let dir = exe.parent().unwrap_or_else(|| Path::new("."));
163    dir.join(".atomcode.download")
164}
165
166/// Same-dir temp used during a three-way rollback swap.
167pub(crate) fn rolling_path(exe: &Path) -> PathBuf {
168    let dir = exe.parent().unwrap_or_else(|| Path::new("."));
169    dir.join(".atomcode.rolling")
170}
171
172/// Return `Ok(())` iff we can create a file alongside `exe`.
173///
174/// Testing the *directory* (not the file itself) is what matters:
175/// `rename(2)` needs write permission on the containing dir to atomic
176/// replace. Probing creates a real empty file and deletes it to avoid
177/// false positives from filesystems that claim metadata writability
178/// but reject opens.
179pub 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
199/// Fetch and parse `latest.json`.
200///
201/// Longer timeout than the passive `version_check` (which must fail
202/// fast at startup); here the user explicitly asked for an upgrade, so
203/// waiting 30s for a slow mirror is acceptable.
204pub 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
234/// Download `url` to `dest`, streaming SHA256 as bytes arrive.
235///
236/// `progress` fires every chunk with (bytes_so_far, total). Callers
237/// should debounce before redrawing the UI; we emit eagerly because
238/// the upgrade UI wants smooth progress for a ~10 MB file.
239///
240/// Verifies size AND sha256 before returning. On any failure the
241/// partial file is removed so a retry starts clean.
242async 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    // Don't bail on Content-Length mismatches.  The Content-Length
273    // header may differ from the manifest's `size` for several reasons:
274    //   - CDN mirrors or reverse proxies may alter Content-Length.
275    //   - The manifest may be slightly out of sync with the server's
276    //     binary (e.g. during a rolling release).
277    //   - Redirects can surface a different Content-Length from an
278    //     intermediate hop.
279    // The real integrity checks happen after the download completes:
280    //   1. Total bytes written must match `expected_size` (below).
281    //   2. SHA256 must match `expected_sha256`.
282    // These two checks are sufficient; an early Content-Length gate
283    // causes false-negative aborts (see issue #380).
284
285    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        // Ignore send errors — receiver may have been dropped.
299        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
338/// Best-effort cleanup of a leftover file (typically a `.bak` or `.rolling`
339/// from a prior upgrade/rollback). Returns `true` if the file was removed,
340/// `false` if it could not be removed (locked, permission denied, etc.).
341///
342/// Defensive against Windows ACCESS_DENIED scenarios:
343///
344/// 1. The file carries a read-only attribute (some AV / SCCM policies
345///    flag any executable in `%LOCALAPPDATA%` this way). Clear it first.
346/// 2. Windows Defender or another scanner briefly holds the file open
347///    during a real-time scan — typically <500 ms. Retry once with a
348///    short sleep before giving up.
349/// 3. The file is a still-running atomcode process from a prior upgrade
350///    where the user didn't restart. Nothing we can do at the code layer;
351///    the caller proceeds without blocking the upgrade.
352///
353/// This function is intentionally **best-effort** — a failure to remove
354/// the stale file must NOT block an upgrade. The three-way swap in
355/// `replace_binary` ensures the upgrade proceeds even if old backups
356/// cannot be deleted.
357fn 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    // Brief retry to ride out a transient AV / indexer hold.
377    std::thread::sleep(std::time::Duration::from_millis(500));
378    std::fs::remove_file(path).is_ok()
379}
380
381/// Put `new_bin` in place of `exe`, keeping the previous `exe` as `.bak`.
382///
383/// Uses a **three-way swap** to avoid ever needing to delete `.bak` as a
384/// prerequisite — the old approach of "delete .bak, then rename exe→.bak"
385/// could fail on Windows when `.bak` is locked (AV scanner, read-only
386/// attribute, still-running process). The swap sequence is:
387///
388///   1. `exe` → `.rolling`      (Windows allows renaming a running exe)
389///   2. `new_bin` → `exe`        (install new version)
390///   3. best-effort: delete old `.bak`
391///   4. `.rolling` → `.bak`      (preserve old version for rollback)
392///
393/// Steps 1–2 are the critical path; steps 3–4 are best-effort cleanup.
394/// If step 4 fails (e.g. old `.bak` is still locked), the upgrade still
395/// succeeds — the `.rolling` file is left behind and will be cleaned up
396/// on the next upgrade attempt.
397///
398/// On Unix, `rename(2)` within a directory is atomic; on Windows, an
399/// exe currently being executed can be renamed (but not deleted), so
400/// the same sequence works.
401fn 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    // Clean up any leftover .rolling from a prior interrupted upgrade.
417    try_remove_stale(&rolling);
418
419    // Step 1: live binary → rolling (Windows allows renaming a running exe)
420    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    // Step 2: new binary → live (the actual upgrade)
429    if let Err(e) = std::fs::rename(new_bin, exe) {
430        // Best-effort unwind of step 1 so the user isn't left without
431        // a live binary.
432        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    // Step 3: best-effort — remove old .bak so we can rename .rolling→.bak.
440    // Failure is non-fatal; we just leave .rolling behind.
441    let bak_removed = try_remove_stale(&backup);
442
443    // Step 4: rolling → .bak (preserve old version for rollback)
444    if bak_removed {
445        if let Err(e) = std::fs::rename(&rolling, &backup) {
446            // Upgrade succeeded but we couldn't preserve the old version
447            // as .bak. The .rolling file lingers; next upgrade will
448            // clean it up. Rollback won't be available this session.
449            eprintln!(
450                "Note: could not preserve previous version as backup ({}). Rollback unavailable until next upgrade.",
451                e
452            );
453        }
454    } else {
455        // Old .bak couldn't be removed (locked by AV, running process, etc.).
456        // The .rolling file stays behind; next upgrade attempt will clean
457        // it up. Rollback points to the version before this upgrade's
458        // predecessor, which is still better than failing the upgrade.
459        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
469/// Top-level upgrade driver.
470///
471/// `current_version` is what we're running right now (e.g. `"v4.19.0"`
472/// — callers typically pass `format!("v{}", env!("CARGO_PKG_VERSION"))`).
473/// When `force` is false and the manifest version is `<=` current, this
474/// returns an error carrying `ALREADY_LATEST` so callers can distinguish
475/// "already up to date" from a real failure.
476pub 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    // Manual `/upgrade` just installed whatever the current manifest
521    // advertises. Any staged upgrade sitting in `staged_dir()` is now
522    // superseded — if we leave `pending.json` in place, the next startup
523    // might try to "apply" an older (or identical) staged version on top
524    // of what we just installed, causing a downgrade or redundant churn.
525    // Clear both the pointer and any stray staged binaries.
526    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    // NOTE: `exe` was captured *before* `replace_binary` renamed the running
541    // binary. On Windows, `current_exe()` would now return `.atomcode.rolling`
542    // instead of the original `atomcode.exe`, so we must pass this saved
543    // value through to `re_exec_self`.
544    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
557/// Sentinel substring in the "already latest" error so the CLI/TUI
558/// layer can render a calm informational message instead of a scary
559/// red error. Kept as a plain string to avoid an error-type refactor.
560pub const ALREADY_LATEST: &str = "ALREADY_LATEST";
561
562// ============================================================================
563// Deferred upgrade (download-in-session, apply-at-next-startup)
564// ============================================================================
565//
566// The deferred path solves two problems that `run_upgrade` alone can't:
567//   1. Users whose sessions run for hours/days — they'll never voluntarily
568//      restart just to pick up a new version. A background task can prepare
569//      the staged binary while they work; apply happens whenever they do
570//      restart (which they will, eventually, for unrelated reasons).
571//   2. Users whose sessions are short but who rarely think to run
572//      `/upgrade` — same benefit: the next normal launch carries the bump.
573//
574// Flow:
575//   session N      : prepare_deferred_upgrade()
576//                    → download to ~/.atomcode/staged/<filename>
577//                    → write ~/.atomcode/staged/pending.json
578//                    → (UI surfaces "⟲ vX.Y.Z pending")
579//   session N exit : no special work; staged files survive any exit path
580//                    (graceful, SIGHUP on terminal close, SIGKILL, power
581//                    loss — all fine, state is on disk)
582//   session N+1    : apply_pending_upgrade() runs BEFORE tokio starts
583//                    → atomically swaps live binary with staged
584//                    → re-execs self with original argv
585//                    → user sees "✓ Upgraded to vX.Y.Z" on welcome
586//
587// A safety circuit-breaker is wired in: if apply succeeds but the new
588// binary fails to start `MAX_APPLY_ATTEMPTS` times in a row, the staged
589// file is discarded so a broken release can't brick the install.
590
591/// Maximum times we'll try to apply the same staged upgrade before
592/// giving up. Prevents a corrupted download from turning into a boot loop.
593const MAX_APPLY_ATTEMPTS: u32 = 3;
594
595/// Pointer record stored at `~/.atomcode/staged/pending.json`. Describes
596/// a downloaded binary that hasn't yet been promoted to live. Lifecycle:
597/// written by `prepare_deferred_upgrade`, read (and deleted on success /
598/// incremented on failure) by `apply_pending_upgrade`.
599#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
600pub struct PendingUpgrade {
601    /// Version tag the staged binary represents, e.g. `v4.19.1`.
602    pub version: String,
603    /// Absolute path to the verified binary sitting in `staged_dir()`.
604    pub staged_path: PathBuf,
605    /// SHA256 of the staged binary (lowercase hex). Re-verified at apply
606    /// time so a partial overwrite between sessions (e.g. disk full)
607    /// doesn't install a corrupted file.
608    pub sha256: String,
609    /// Size in bytes; cheap sanity check before we recompute sha256.
610    pub size: u64,
611    /// RFC3339 timestamp for audit / debug. Not load-bearing.
612    pub created_at: String,
613    /// How many times we've attempted apply and failed. When this hits
614    /// `MAX_APPLY_ATTEMPTS`, the staged file is discarded instead of
615    /// retried again on the following startup.
616    #[serde(default)]
617    pub attempts: u32,
618}
619
620/// Successful apply result — fed into the re-exec handoff so the new
621/// process can render a one-time "✓ Upgraded" banner on the welcome screen.
622#[derive(Debug, Clone)]
623pub struct AppliedUpgrade {
624    pub version: String,
625    pub backup: PathBuf,
626    pub exe: PathBuf,
627}
628
629/// `~/.atomcode/staged/` (or equivalent on Windows). Created on demand by
630/// `prepare_deferred_upgrade`; safe to treat as possibly-missing at read
631/// time. Kept under the same root as `history` / `recent_dirs` so nothing
632/// new appears in `$HOME`.
633pub 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
641/// Where a prepared (downloaded + verified) binary lives while it waits
642/// to be promoted. Filename mirrors the release artifact so the same
643/// `binary_filename` helper round-trips.
644fn staged_binary_path(version: &str, target: &str) -> PathBuf {
645    staged_dir().join(binary_filename(version, target))
646}
647
648/// Read `pending.json` if present. Absent file → `Ok(None)`. Corrupt JSON
649/// returns an error so callers can delete it and retry; `apply_pending_upgrade`
650/// does exactly that.
651pub 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
675/// Quiet variant of `check_latest` used by the hourly background poll.
676/// Returns the remote `(version, manifest)` only when strictly newer than
677/// `current_version`. Separate from `version_check::check_latest` because
678/// that one only gives back the version string; here we need the full
679/// manifest to know the per-target sha256 / size.
680pub 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
689/// Download + verify a new release into `staged_dir()` without touching
690/// the live binary. Writes `pending.json` as the final step so a partial
691/// download (crashed mid-stream) doesn't masquerade as "ready to apply" —
692/// the pointer only appears if sha256 passed.
693///
694/// Returns `Ok(None)` when we're already on the latest version (or newer).
695/// Idempotent: calling twice with the same manifest is a no-op after the
696/// first success (file + pointer already in place).
697pub 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 a staged upgrade for this exact version already exists and the
720    // on-disk bytes still match the manifest's sha256, reuse it. Saves a
721    // redownload when two sessions both polled and landed here.
722    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
760/// Bootstrap entry point: called once at the very top of `main()` BEFORE
761/// the tokio runtime, TUI, or any heavy init. Three outcomes:
762///
763///   * `Ok(None)`                  — no pending upgrade, continue normally.
764///   * `Ok(Some(AppliedUpgrade))`  — staged binary is now live; caller must
765///                                   `re_exec_self` to hand control over.
766///   * `Err(e)`                    — apply failed; caller should log and
767///                                   continue with the OLD binary. We've
768///                                   already bumped the attempt counter
769///                                   (or discarded the stage past the cap).
770///
771/// SHA256 is re-verified here even though we verified at download time:
772/// a session-external process (backup tool, AV software, buggy sync)
773/// could have touched the file between sessions. Verification is cheap
774/// compared to installing a corrupted binary.
775pub 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            // Corrupt pointer — nuke it, we can't do anything safe with it.
781            clear_pending_pointer();
782            return Ok(None);
783        }
784    };
785
786    if pending.attempts >= MAX_APPLY_ATTEMPTS {
787        // Circuit-break: this staged upgrade has failed too many times.
788        // Discard everything so we stop trying, fall back to the old
789        // binary, and let the next successful prepare_deferred_upgrade
790        // supersede it.
791        let _ = std::fs::remove_file(&pending.staged_path);
792        clear_pending_pointer();
793        return Ok(None);
794    }
795
796    // Bump attempt counter up-front so a crash mid-apply doesn't leave us
797    // in an unbounded retry loop.
798    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    // Success — pointer is done, file moved into place by replace_binary.
840    clear_pending_pointer();
841
842    Ok(Some(AppliedUpgrade {
843        version: pending.version,
844        backup: backup_path(&exe),
845        exe,
846    }))
847}
848
849/// Replace the current process with a fresh invocation of the live binary,
850/// preserving argv, cwd, and env. On Unix this is `execv` (same PID, old
851/// process image gone). On Windows we spawn a child and exit the parent —
852/// a separate PID, but terminal stdio is shared so the user still sees
853/// one continuous "session" from their perspective.
854///
855/// **Important on Windows:** After `replace_binary` renames the running exe
856/// (e.g. `atomcode.exe` → `.atomcode.rolling`), `std::env::current_exe()`
857/// may return the *renamed* path (`.atomcode.rolling`) instead of the
858/// original one (`atomcode.exe`). This is because `GetModuleFileNameW`
859/// tracks the on-disk filename. If `override_exe` is provided, it is used
860/// instead of `current_exe()` — callers should capture the exe path
861/// *before* calling `replace_binary` and pass it here.
862///
863/// Never returns on the happy path. An `Err` return means the handoff
864/// failed (e.g., new binary missing execute bit under unusual filesystem
865/// constraints); caller should surface the error and keep running with
866/// the old binary rather than exiting silently.
867pub 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            // Fallback: if we can't resolve current_exe AND no override,
872            // try argv[0] as a last resort.
873            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        // `exec` only returns on failure.
884        Err(anyhow!("re-exec failed: {}", err))
885    }
886
887    #[cfg(windows)]
888    {
889        // Windows has no exec(). Spawn the child with shared stdio so
890        // the user's terminal stays connected to the new process, then
891        // exit ourselves. The replacement PID shift is invisible in
892        // a terminal context (the shell tracks the parent's exit).
893        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
903/// Parse and compare two `vMAJOR.MINOR.PATCH` strings. Returns true
904/// when `latest > current`. Malformed inputs fall back to a byte-wise
905/// `!=` so we *do* proceed with reinstall when version strings are
906/// shaped unexpectedly — safer than silently refusing to upgrade.
907fn 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
927/// Three-way swap between the live binary and `.bak`, leaving `.bak`
928/// pointing at what was previously live. Calling rollback twice in a
929/// row returns you to the original state — intentional, so users can
930/// toggle between last-two versions without redownloading.
931pub 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    // Step 1: live -> rolling
948    std::fs::rename(&exe, &rolling).with_context(|| {
949        format!(
950            "renaming {} -> {} (swap step 1)",
951            exe.display(),
952            rolling.display()
953        )
954    })?;
955    // Step 2: backup -> live
956    if let Err(e) = std::fs::rename(&backup, &exe) {
957        // Best-effort unwind of step 1.
958        let _ = std::fs::rename(&rolling, &exe);
959        return Err(anyhow!("rollback failed at step 2 ({}); state restored", e));
960    }
961    // Step 3: rolling -> backup
962    if let Err(e) = std::fs::rename(&rolling, &backup) {
963        // Can't cleanly unwind — the live file is already the old
964        // version, which is the user-visible outcome they asked for.
965        // Surface the orphan so they can clean up manually.
966        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        // We can't assert an exact value (depends on host), but every
1017        // supported dev platform should resolve to Some.
1018        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        // On Windows the readonly flag would block remove_file outright.
1065        // On Unix it doesn't, but the helper still happens to clean up.
1066        // Either way, the file must be gone after this call.
1067        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        // On Unix, an open file can still be unlinked, so true locking
1074        // is hard to simulate. Instead we test the "nonexistent path"
1075        // case — `try_remove_stale` correctly returns true because the
1076        // file doesn't exist (nothing to remove). To verify the false
1077        // return, we'd need a platform-specific lock (Windows HANDLE),
1078        // which isn't feasible in a cross-platform unit test. The
1079        // important contract is: returns true when nothing needs doing.
1080        let bogus = std::path::PathBuf::from("/no/such/dir/atomcode.exe.bak");
1081        assert!(!bogus.exists());
1082        // A path that doesn't exist is "already removed" → true
1083        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        // If we can't parse, err on the side of allowing reinstall
1112        // when strings differ (user may have a custom channel).
1113        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        // A path inside tempdir must pass; a path whose parent doesn't
1154        // exist must fail with a clear message.
1155        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        // No leftover .rolling file on success
1179        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        // No leftover .rolling file
1198        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        // Simulate the Windows ACCESS_DENIED scenario: .bak is locked
1223        // (here we can't truly lock it, but we make it read-only in a
1224        // non-writable parent — on Unix this still works because unlink
1225        // doesn't care about file permissions. The test verifies the
1226        // three-way swap completes successfully regardless.)
1227        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        // Core outcome: upgrade succeeded
1239        assert_eq!(std::fs::read(&exe).unwrap(), b"V3");
1240        // On this platform .bak is removable so we get V2 as backup.
1241        // On Windows with a locked .bak, the .rolling file would linger
1242        // instead, but the upgrade still succeeds.
1243        assert!(bak.exists() || rolling.exists());
1244    }
1245
1246    #[test]
1247    fn rollback_swaps_live_and_bak() {
1248        let tmp = tempfile::tempdir().unwrap();
1249        // Use a fake exe name that won't collide with the real test
1250        // binary. run_rollback uses current_exe() which we cannot
1251        // redirect, so we test the primitive by calling replace_binary
1252        // first then rename logic directly — model the three-way swap.
1253        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        // Manual three-way swap mirroring run_rollback's body.
1259        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        // Older pointer files written before the attempts field existed
1287        // must still deserialize — `#[serde(default)]` covers that.
1288        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    // ── download_and_verify integration tests (issue #380) ──────────
1316
1317    /// Helper: compute the SHA256 hex string for a byte slice.
1318    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    /// download_and_verify succeeds when the server omits Content-Length
1326    /// (chunked transfer encoding).  In production, Content-Length may
1327    /// differ from the manifest's `size` due to CDN/proxy rewrites,
1328    /// manifest-server sync lag, or redirect hops — the old code
1329    /// aborted immediately on such mismatches (issue #380).
1330    ///
1331    /// This test validates that when Content-Length is absent (chunked),
1332    /// the function proceeds to download and relies on the post-download
1333    /// size + SHA256 checks for integrity, which is the correct behavior
1334    /// after the fix.
1335    #[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        // Simulate a CDN that uses chunked transfer (no Content-Length).
1343        // This is what a gzip-compressed response looks like after
1344        // reqwest transparently decompresses it.
1345        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                    // No explicit Content-Length → chunked transfer encoding.
1354                    .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        // With the fix, this should succeed because the actual downloaded
1373        // bytes match expected_size and expected_sha256.  The old code
1374        // would also have succeeded here (Content-Length was absent), but
1375        // the key point is that we no longer gate on Content-Length at all.
1376        assert!(result.is_ok(), "download should succeed, got: {:?}", result);
1377        assert_eq!(std::fs::read(&dest).unwrap(), payload);
1378    }
1379
1380    /// download_and_verify still rejects a download whose actual byte
1381    /// count doesn't match the manifest size (post-download size check).
1382    #[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        // Server sends 80 bytes, but manifest says 100.
1390        let payload = vec![0xAB_u8; 80];
1391        let expected_sha = sha256_hex(&payload);
1392        let expected_size: u64 = 100; // mismatch!
1393
1394        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        // Temp file must be cleaned up on failure.
1424        assert!(!dest.exists(), "partial download should be removed");
1425    }
1426
1427    /// download_and_verify still rejects a download whose SHA256
1428    /// doesn't match the manifest, even if the size is correct.
1429    #[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, // intentionally wrong
1456            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        // Temp file must be cleaned up on failure.
1470        assert!(!dest.exists(), "corrupted download should be removed");
1471    }
1472
1473    /// download_and_verify succeeds when everything matches perfectly
1474    /// (Content-Length present and correct, size + SHA256 match).
1475    #[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    /// download_and_verify rejects non-2xx HTTP responses.
1513    #[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}