Skip to main content

studio_worker/
update.rs

1//! Auto-update: poll a GitHub Releases feed, download cargo-dist's
2//! platform installer when a newer semver is available, and re-exec
3//! ourselves so the new binary takes over.
4//!
5//! The update task in `runtime.rs` only invokes us when the worker is
6//! idle (no job in flight) so generation runs never get killed mid-flow.
7//!
8//! All side-effecting bits (HTTP, filesystem writes, process spawn) flow
9//! through testable helpers; see `apply_with` for the seam.
10use crate::types::GithubRelease;
11use anyhow::{anyhow, bail, Context, Result};
12use semver::Version;
13use std::path::{Path, PathBuf};
14use std::time::{Duration, Instant};
15use tracing::{debug, info, warn};
16
17/// Tracing target used for every event emitted by the updater. Operators
18/// can filter the auto-update breadcrumbs in isolation with
19/// `RUST_LOG=studio_worker::update=debug`.
20const TRACE_TARGET: &str = "studio_worker::update";
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum CheckOutcome {
24    UpToDate { current: Version },
25    NewerAvailable { current: Version, latest: Version },
26}
27
28/// Resolve the feed URL to a JSON document and parse a release list.
29pub fn fetch_releases(feed_url: &str) -> Result<Vec<GithubRelease>> {
30    let client = reqwest::blocking::Client::builder()
31        .timeout(Duration::from_secs(15))
32        .user_agent(concat!("studio-worker/", env!("CARGO_PKG_VERSION")))
33        .build()
34        .context("building reqwest client")?;
35    let started = Instant::now();
36    let response = client
37        .get(feed_url)
38        .header("accept", "application/vnd.github+json")
39        .send()
40        .with_context(|| format!("GET {feed_url}"))?;
41    let status = response.status();
42    let elapsed_ms = started.elapsed().as_millis() as u64;
43    if !status.is_success() {
44        warn!(
45            target: TRACE_TARGET,
46            feed_url,
47            status = status.as_u16(),
48            elapsed_ms,
49            "feed fetch failed"
50        );
51        bail!("feed {feed_url} returned {status}");
52    }
53    let text = response.text()?;
54    let releases = parse_releases(&text)?;
55    debug!(
56        target: TRACE_TARGET,
57        feed_url,
58        status = status.as_u16(),
59        elapsed_ms,
60        releases = releases.len(),
61        "feed fetched"
62    );
63    Ok(releases)
64}
65
66/// Pure parser separated from the HTTP call so it's trivially testable.
67pub fn parse_releases(text: &str) -> Result<Vec<GithubRelease>> {
68    if let Ok(list) = serde_json::from_str::<Vec<GithubRelease>>(text) {
69        return Ok(list);
70    }
71    let single: GithubRelease = serde_json::from_str(text)
72        .with_context(|| "feed JSON is neither an array nor a single release")?;
73    Ok(vec![single])
74}
75
76/// Parse the version from a release tag.  Accepts a bare `1.2.3`, a
77/// `v1.2.3`, and the component-prefixed tags release-please / cargo-dist
78/// actually push for this repo (`studio-worker-v1.2.3`).  Tries the
79/// most-permissive forms in order and returns the first that parses, so
80/// a prerelease suffix (`...-rc.1`) survives — only the `<component>-v`
81/// prefix is stripped, never the version's own `-`.
82pub fn parse_tag(tag: &str) -> Option<Version> {
83    let candidates = [
84        tag,
85        tag.strip_prefix('v').unwrap_or(tag),
86        tag.rsplit_once("-v").map(|(_, v)| v).unwrap_or(tag),
87    ];
88    candidates.iter().find_map(|c| Version::parse(c).ok())
89}
90
91/// Compare the local version against the feed and decide whether to
92/// update.
93pub fn check(feed_url: &str, current: &Version, prerelease_ok: bool) -> Result<CheckOutcome> {
94    let releases = fetch_releases(feed_url)?;
95    Ok(decide(&releases, current, prerelease_ok))
96}
97
98/// Pure decision function so we can unit-test the prerelease/draft
99/// filters without going through HTTP.
100pub fn decide(releases: &[GithubRelease], current: &Version, prerelease_ok: bool) -> CheckOutcome {
101    // Count both the candidates that yielded a version and the ones that
102    // didn't.  A non-draft, non-prerelease release whose tag fails to
103    // parse is still dropped from the decision (unchanged behaviour) but
104    // warn-logged with its tag and tallied in `dropped`, so a tag-format
105    // drift that silently strands every worker on an old build leaves a
106    // breadcrumb instead of looking exactly like "already up to date".
107    // Drafts / opted-out prereleases are filtered *before* the parse step,
108    // so a garbage tag on one of those is an intentional exclusion, not a
109    // lost candidate.
110    let mut dropped: u32 = 0;
111    let mut candidates: u32 = 0;
112    let latest = releases
113        .iter()
114        .filter(|r| !r.draft)
115        .filter(|r| prerelease_ok || !r.prerelease)
116        .filter_map(|r| match parse_tag(&r.tag_name) {
117            Some(v) => {
118                candidates += 1;
119                Some(v)
120            }
121            None => {
122                dropped += 1;
123                warn!(
124                    target: TRACE_TARGET,
125                    op = "decide",
126                    tag = %r.tag_name,
127                    "release tag did not parse as a version; dropping it from the update check"
128                );
129                None
130            }
131        })
132        .max();
133    debug!(
134        target: TRACE_TARGET,
135        op = "decide",
136        candidates,
137        dropped,
138        latest = latest.as_ref().map(ToString::to_string),
139        "evaluated release feed for a newer version"
140    );
141    match latest {
142        Some(v) if v > *current => CheckOutcome::NewerAvailable {
143            current: current.clone(),
144            latest: v,
145        },
146        _ => CheckOutcome::UpToDate {
147            current: current.clone(),
148        },
149    }
150}
151
152/// The cargo-dist installer asset name for the current platform.
153pub fn installer_asset_name() -> &'static str {
154    if cfg!(target_os = "windows") {
155        "studio-worker-installer.ps1"
156    } else {
157        "studio-worker-installer.sh"
158    }
159}
160
161/// Resolve which installer asset to download for the given release.
162/// Pulled out of `apply` for unit tests.
163pub fn resolve_installer_url(release: &GithubRelease) -> Option<&str> {
164    let name = installer_asset_name();
165    release
166        .assets
167        .iter()
168        .find(|a| a.name == name)
169        .map(|a| a.browser_download_url.as_str())
170}
171
172/// Verify a streamed installer download wrote exactly the body the
173/// server promised.  `expected` is the response's `Content-Length`;
174/// it's `None` for chunked transfers, where there's nothing to check
175/// and we accept whatever arrived.  A mismatch means the download was
176/// truncated or corrupt — and because the very next step hands this
177/// file to `sh` / `powershell`, running a half-written installer is
178/// far more dangerous than failing the update and retrying on the next
179/// tick, so we surface a clear error instead of executing it.
180fn verify_download_len(copied: u64, expected: Option<u64>) -> Result<()> {
181    match expected {
182        Some(expected) if copied != expected => bail!(
183            "size mismatch: wrote {copied} bytes but the server declared \
184             Content-Length {expected} (installer download truncated or corrupt)"
185        ),
186        _ => Ok(()),
187    }
188}
189
190/// Apply an update by downloading the cargo-dist installer for the
191/// current platform and running it.
192pub fn apply(feed_url: &str, latest: &Version) -> Result<()> {
193    apply_with(feed_url, latest, &RealRunner)
194}
195
196/// Side-effect abstraction for `apply_with`.  The real implementation
197/// downloads via HTTP and runs `sh` / `powershell`; tests inject a fake
198/// that records calls.
199pub trait UpdateRunner {
200    fn download(&self, url: &str, dest: &Path) -> Result<()>;
201    /// Fetch the published sha256 sidecar for an asset (the
202    /// `<asset-url>.sha256` convention).  `Ok(None)` means the release
203    /// simply doesn't publish one (older releases); an HTTP/transport
204    /// failure is a hard `Err` so a blocked checksum fetch can't be
205    /// mistaken for an absent one.
206    fn fetch_checksum(&self, url: &str) -> Result<Option<String>>;
207    fn run_installer(&self, installer_path: &Path) -> Result<()>;
208}
209
210pub struct RealRunner;
211
212impl UpdateRunner for RealRunner {
213    fn download(&self, url: &str, dest: &Path) -> Result<()> {
214        validate_installer_download_url(url)?;
215        let client = reqwest::blocking::Client::builder()
216            .timeout(Duration::from_secs(300))
217            .user_agent(concat!("studio-worker/", env!("CARGO_PKG_VERSION")))
218            .build()?;
219        let started = Instant::now();
220        let mut response = client.get(url).send()?.error_for_status()?;
221        // Capture the declared length (absent on chunked transfers)
222        // before streaming so a short read is caught below — the next
223        // step runs this file as a shell / PowerShell script.
224        let expected_len = response.content_length();
225        let mut file = std::fs::File::create(dest)?;
226        let bytes = std::io::copy(&mut response, &mut file)?;
227        // Reject a truncated / overlong download before `apply_with`
228        // hands the file to the installer runner.  Bailing here means
229        // `run_installer` never executes, and `apply_with`'s tempdir
230        // drop cleans up the partial file.
231        verify_download_len(bytes, expected_len)
232            .with_context(|| format!("downloading installer from {url}"))?;
233        info!(
234            target: TRACE_TARGET,
235            url,
236            dest = %dest.display(),
237            bytes,
238            elapsed_ms = started.elapsed().as_millis() as u64,
239            "installer downloaded"
240        );
241        Ok(())
242    }
243
244    fn fetch_checksum(&self, url: &str) -> Result<Option<String>> {
245        validate_installer_download_url(url)?;
246        let client = reqwest::blocking::Client::builder()
247            .timeout(Duration::from_secs(60))
248            .user_agent(concat!("studio-worker/", env!("CARGO_PKG_VERSION")))
249            .build()?;
250        let response = client
251            .get(url)
252            .send()
253            .with_context(|| format!("GET {url}"))?;
254        if response.status().as_u16() == 404 {
255            return Ok(None);
256        }
257        let response = response.error_for_status()?;
258        Ok(Some(response.text()?))
259    }
260
261    fn run_installer(&self, installer_path: &Path) -> Result<()> {
262        if cfg!(target_os = "windows") {
263            let status = std::process::Command::new("powershell")
264                .args([
265                    "-NoProfile",
266                    "-ExecutionPolicy",
267                    "Bypass",
268                    "-File",
269                    installer_path
270                        .to_str()
271                        .ok_or_else(|| anyhow!("installer path not UTF-8"))?,
272                ])
273                .status()?;
274            if !status.success() {
275                bail!("installer exited with {status}");
276            }
277        } else {
278            let status = std::process::Command::new("sh")
279                .arg(installer_path)
280                .status()?;
281            if !status.success() {
282                bail!("installer exited with {status}");
283            }
284        }
285        Ok(())
286    }
287}
288
289/// Hosts an installer (or its checksum) may be fetched from.  The
290/// release feed is data an attacker may try to tamper with; even a
291/// poisoned feed must not be able to point the updater at an arbitrary
292/// https server.  GitHub serves release assets from `github.com`
293/// (redirecting to `objects.githubusercontent.com`, which reqwest
294/// follows transparently — both are pinned for direct links too).
295const ALLOWED_INSTALLER_HOSTS: [&str; 2] = ["github.com", "objects.githubusercontent.com"];
296
297fn validate_installer_download_url(raw: &str) -> Result<()> {
298    // Shared transport gate (https-or-loopback) first…
299    crate::net::validate_download_url(raw, "installer")?;
300    // …then the host pin.  Loopback stays allowed so wiremock tests
301    // and air-gapped mirrors keep working.
302    let url = url::Url::parse(raw).with_context(|| format!("invalid installer URL {raw:?}"))?;
303    match url.host() {
304        Some(url::Host::Domain(d))
305            if ALLOWED_INSTALLER_HOSTS
306                .iter()
307                .any(|allowed| d.eq_ignore_ascii_case(allowed)) =>
308        {
309            Ok(())
310        }
311        Some(url::Host::Domain(d)) if d.eq_ignore_ascii_case("localhost") => Ok(()),
312        Some(url::Host::Ipv4(ip)) if ip.is_loopback() => Ok(()),
313        Some(url::Host::Ipv6(ip)) if ip.is_loopback() => Ok(()),
314        _ => bail!(
315            "installer URL host is not an allowed release host \
316             (github.com / objects.githubusercontent.com): {raw}"
317        ),
318    }
319}
320
321/// Parse the hex digest out of a checksum sidecar (`<hex>  <name>` /
322/// `<hex> *<name>` / bare `<hex>`).  Returns `None` when no 64-hex
323/// token leads the first non-empty line.
324pub fn parse_checksum_file(text: &str) -> Option<String> {
325    let first = text.lines().find(|l| !l.trim().is_empty())?;
326    let token = first.split_whitespace().next()?;
327    (token.len() == 64 && token.chars().all(|c| c.is_ascii_hexdigit()))
328        .then(|| token.to_ascii_lowercase())
329}
330
331/// sha256 a file on disk and compare against the expected hex digest.
332pub fn verify_file_sha256(path: &Path, expected_hex: &str) -> Result<()> {
333    use sha2::{Digest, Sha256};
334    let mut file = std::fs::File::open(path)
335        .with_context(|| format!("opening {} for hashing", path.display()))?;
336    let mut hasher = Sha256::new();
337    std::io::copy(&mut file, &mut hasher).with_context(|| format!("hashing {}", path.display()))?;
338    let actual: String = hasher
339        .finalize()
340        .iter()
341        .map(|b| format!("{b:02x}"))
342        .collect();
343    if !actual.eq_ignore_ascii_case(expected_hex.trim()) {
344        bail!(
345            "installer sha256 mismatch: downloaded file hashes to {actual} but the \
346             release publishes {expected_hex} (corrupted or tampered download)"
347        );
348    }
349    Ok(())
350}
351
352/// Where a parked (renamed-aside) running executable lives: the full
353/// original file name with `.old` appended.  `with_extension` would
354/// turn `studio-worker.exe` into `studio-worker.old` and risk
355/// clobbering an unrelated sibling.
356pub fn parked_artifact_path(exe: &Path) -> PathBuf {
357    let name = exe
358        .file_name()
359        .map(|n| n.to_string_lossy().into_owned())
360        .unwrap_or_else(|| "studio-worker".to_string());
361    exe.with_file_name(format!("{name}.old"))
362}
363
364/// Remove a leftover parked binary from a previous update.  Called on
365/// startup; best-effort — a locked or missing file is fine.
366pub fn cleanup_parked_artifact(exe: &Path) {
367    let parked = parked_artifact_path(exe);
368    match std::fs::remove_file(&parked) {
369        Ok(()) => info!(
370            target: TRACE_TARGET,
371            parked = %parked.display(),
372            "removed parked binary from a previous update"
373        ),
374        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
375        Err(e) => warn!(
376            target: TRACE_TARGET,
377            parked = %parked.display(),
378            error = %e,
379            "could not remove parked binary; will retry next start"
380        ),
381    }
382}
383
384/// Best-effort startup cleanup for the running process's own parked
385/// artifact.  Excluded from coverage: depends on `current_exe`.
386#[cfg_attr(coverage_nightly, coverage(off))]
387pub fn cleanup_parked_artifact_for_current_exe() {
388    if let Ok(exe) = std::env::current_exe() {
389        cleanup_parked_artifact(&exe);
390    }
391}
392
393/// Windows can't overwrite a running executable (the file is locked),
394/// but it CAN rename it.  Parking the running exe under a `.old` name
395/// frees the original path so the cargo-dist installer's `Copy-Item`
396/// succeeds; the parked file is removed on the next start.
397///
398/// The guard is plain filesystem logic so it is unit-tested on every
399/// platform; `apply_with` only activates it on Windows.
400pub struct ExeReplaceGuard {
401    original: PathBuf,
402    parked: PathBuf,
403}
404
405impl ExeReplaceGuard {
406    /// Rename `exe` aside.  Replaces any stale artifact from a
407    /// previous update first.
408    pub fn park(exe: &Path) -> Result<Self> {
409        let parked = parked_artifact_path(exe);
410        if parked.exists() {
411            std::fs::remove_file(&parked)
412                .with_context(|| format!("removing stale parked binary {}", parked.display()))?;
413        }
414        std::fs::rename(exe, &parked).with_context(|| {
415            format!(
416                "parking running binary {} -> {}",
417                exe.display(),
418                parked.display()
419            )
420        })?;
421        info!(
422            target: TRACE_TARGET,
423            exe = %exe.display(),
424            parked = %parked.display(),
425            "parked running binary so the installer can replace it"
426        );
427        Ok(Self {
428            original: exe.to_path_buf(),
429            parked,
430        })
431    }
432
433    /// After the installer ran: did a new binary land at the original
434    /// path?  If not, the installer wrote somewhere else and a restart
435    /// would find nothing to exec — the caller must roll back.
436    pub fn confirm_replaced(&self) -> Result<()> {
437        if self.original.is_file() {
438            return Ok(());
439        }
440        bail!(
441            "installer did not write a new binary at {} (custom install dir?)",
442            self.original.display()
443        )
444    }
445
446    /// Undo the park — the update failed and the worker keeps running
447    /// the old version.
448    pub fn rollback(self) -> Result<()> {
449        std::fs::rename(&self.parked, &self.original).with_context(|| {
450            format!(
451                "restoring parked binary {} -> {}",
452                self.parked.display(),
453                self.original.display()
454            )
455        })
456    }
457}
458
459pub fn apply_with<R: UpdateRunner>(feed_url: &str, latest: &Version, runner: &R) -> Result<()> {
460    info!(
461        target: TRACE_TARGET,
462        feed_url,
463        latest = %latest,
464        "applying update"
465    );
466    let releases = fetch_releases(feed_url)?;
467    let release = releases
468        .iter()
469        .find(|r| parse_tag(&r.tag_name).as_ref() == Some(latest))
470        .ok_or_else(|| anyhow!("release {latest} not present in feed"))?;
471
472    let url = resolve_installer_url(release).ok_or_else(|| {
473        anyhow!(
474            "release {} is missing installer asset {}",
475            latest,
476            installer_asset_name()
477        )
478    })?;
479
480    let tmp = tempfile::tempdir().context("creating tempdir for installer")?;
481    let installer_path = tmp.path().join(installer_asset_name());
482    info!(
483        target: TRACE_TARGET,
484        url,
485        dest = %installer_path.display(),
486        latest = %latest,
487        "downloading installer"
488    );
489    runner.download(url, &installer_path)?;
490    // Integrity gate: verify the downloaded installer against the
491    // release's published `<asset>.sha256` sidecar before handing it
492    // to `sh` / `powershell`.  Releases that predate the sidecar are
493    // tolerated with a warn (the transport is still https + pinned to
494    // GitHub); a *published* checksum that doesn't match is a hard
495    // stop — running a tampered installer is remote code execution.
496    match runner.fetch_checksum(&format!("{url}.sha256"))? {
497        Some(text) => match parse_checksum_file(&text) {
498            Some(expected) => {
499                verify_file_sha256(&installer_path, &expected)?;
500                info!(
501                    target: TRACE_TARGET,
502                    latest = %latest,
503                    "installer checksum verified against the release sidecar"
504                );
505            }
506            None => bail!(
507                "the release publishes an installer checksum sidecar but it \
508                 contains no parseable sha256 — refusing to run the installer"
509            ),
510        },
511        None => warn!(
512            target: TRACE_TARGET,
513            latest = %latest,
514            "release publishes no installer checksum sidecar; skipping \
515             verification (transport is https pinned to GitHub)"
516        ),
517    }
518    info!(
519        target: TRACE_TARGET,
520        installer = %installer_path.display(),
521        latest = %latest,
522        "running installer"
523    );
524    // Windows locks the running executable: the installer's Copy-Item
525    // fails with "file in use" unless we park (rename) ourselves out
526    // of the way first.  Renames of running binaries are allowed on
527    // NTFS.  Unix installers replace via unlink + write, no parking
528    // needed.
529    let guard = if cfg!(target_os = "windows") {
530        let exe = std::env::current_exe().context("resolving current exe for update")?;
531        Some(ExeReplaceGuard::park(&exe)?)
532    } else {
533        None
534    };
535    match runner.run_installer(&installer_path) {
536        Ok(()) => {
537            if let Some(guard) = guard {
538                if let Err(e) = guard.confirm_replaced() {
539                    // Roll back so the (still-running) old version can
540                    // be restarted by path; surface why the update
541                    // didn't take.
542                    if let Err(rb) = guard.rollback() {
543                        warn!(target: TRACE_TARGET, error = %rb, "rollback after failed replace also failed");
544                    }
545                    return Err(e);
546                }
547                // Parked file stays until the next start (this process
548                // is still executing it); cleanup_parked_artifact
549                // removes it then.
550            }
551        }
552        Err(e) => {
553            if let Some(guard) = guard {
554                if let Err(rb) = guard.rollback() {
555                    warn!(target: TRACE_TARGET, error = %rb, "rollback after installer failure also failed");
556                }
557            }
558            return Err(e);
559        }
560    }
561    info!(
562        target: TRACE_TARGET,
563        latest = %latest,
564        "installer completed; binary replaced"
565    );
566    Ok(())
567}
568
569/// Compute the (binary, args) tuple we'd re-exec ourselves with.  Pure
570/// — actual exec lives in [`restart_self`].
571pub fn restart_argv() -> (PathBuf, Vec<std::ffi::OsString>) {
572    let mut iter = std::env::args_os();
573    let bin = iter
574        .next()
575        .map(PathBuf::from)
576        .unwrap_or_else(|| PathBuf::from("studio-worker"));
577    let args: Vec<std::ffi::OsString> = iter.collect();
578    (bin, args)
579}
580
581/// Replace the current process with a fresh exec of the (now-updated)
582/// binary.  On unix we use `execvp`; on Windows we spawn the successor
583/// and exit cleanly.  Unreachable from tests — covered by integration
584/// tests of `apply_with` instead.
585#[cfg_attr(coverage_nightly, coverage(off))]
586pub fn restart_self() -> ! {
587    let (bin, args) = restart_argv();
588    info!(
589        target: TRACE_TARGET,
590        bin = %bin.display(),
591        argc = args.len(),
592        "restarting into updated binary"
593    );
594    #[cfg(unix)]
595    {
596        use std::os::unix::process::CommandExt;
597        let err = std::process::Command::new(&bin).args(&args).exec();
598        tracing::error!(
599            target: TRACE_TARGET,
600            bin = %bin.display(),
601            %err,
602            "exec into updated binary failed"
603        );
604        eprintln!("[studio-worker] exec failed: {err}");
605        std::process::exit(1);
606    }
607    #[cfg(not(unix))]
608    {
609        match std::process::Command::new(&bin).args(&args).spawn() {
610            Ok(_) => std::process::exit(0),
611            Err(err) => {
612                tracing::error!(
613                    target: TRACE_TARGET,
614                    bin = %bin.display(),
615                    %err,
616                    "spawn-restart of updated binary failed"
617                );
618                eprintln!("[studio-worker] spawn-restart failed: {err}");
619                std::process::exit(1);
620            }
621        }
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use crate::types::{GithubRelease, GithubReleaseAsset};
629    use std::cell::RefCell;
630    use std::path::PathBuf;
631    use tempfile::tempdir;
632
633    fn rel(tag: &str, prerelease: bool, draft: bool, with_installer: bool) -> GithubRelease {
634        let assets = if with_installer {
635            vec![GithubReleaseAsset {
636                name: installer_asset_name().to_string(),
637                browser_download_url: format!("https://example.com/{tag}"),
638            }]
639        } else {
640            vec![]
641        };
642        GithubRelease {
643            tag_name: tag.to_string(),
644            prerelease,
645            draft,
646            assets,
647        }
648    }
649
650    // -----------------------------------------------------------------
651    // ExeReplaceGuard — the Windows locked-exe dance.  Pure fs logic,
652    // unit-tested on every platform; only the activation in apply_with
653    // is Windows-gated.
654    // -----------------------------------------------------------------
655
656    #[test]
657    fn park_moves_the_exe_aside_and_confirm_fails_until_replaced() {
658        let dir = tempdir().unwrap();
659        let exe = dir.path().join("studio-worker.exe");
660        std::fs::write(&exe, b"old binary").unwrap();
661
662        let guard = ExeReplaceGuard::park(&exe).unwrap();
663        assert!(
664            !exe.exists(),
665            "original path must be free for the installer"
666        );
667        assert_eq!(
668            std::fs::read(parked_artifact_path(&exe)).unwrap(),
669            b"old binary"
670        );
671        // Installer hasn't written the new binary yet.
672        assert!(guard.confirm_replaced().is_err());
673
674        // Installer writes the new binary at the original path.
675        std::fs::write(&exe, b"new binary").unwrap();
676        guard.confirm_replaced().unwrap();
677    }
678
679    #[test]
680    fn rollback_restores_the_original_exe() {
681        let dir = tempdir().unwrap();
682        let exe = dir.path().join("studio-worker.exe");
683        std::fs::write(&exe, b"old binary").unwrap();
684
685        let guard = ExeReplaceGuard::park(&exe).unwrap();
686        guard.rollback().unwrap();
687        assert_eq!(std::fs::read(&exe).unwrap(), b"old binary");
688        assert!(!parked_artifact_path(&exe).exists());
689    }
690
691    #[test]
692    fn park_replaces_a_stale_artifact_from_a_previous_update() {
693        let dir = tempdir().unwrap();
694        let exe = dir.path().join("studio-worker.exe");
695        std::fs::write(&exe, b"current").unwrap();
696        std::fs::write(parked_artifact_path(&exe), b"ancient leftover").unwrap();
697
698        let _guard = ExeReplaceGuard::park(&exe).unwrap();
699        assert_eq!(
700            std::fs::read(parked_artifact_path(&exe)).unwrap(),
701            b"current"
702        );
703    }
704
705    #[test]
706    fn parked_artifact_path_appends_old_to_the_full_file_name() {
707        // `.with_extension` would turn studio-worker.exe into
708        // studio-worker.old and clobber a sibling file — the artifact
709        // must keep the full original name.
710        assert_eq!(
711            parked_artifact_path(Path::new("/x/studio-worker.exe")),
712            PathBuf::from("/x/studio-worker.exe.old")
713        );
714        assert_eq!(
715            parked_artifact_path(Path::new("/x/studio-worker")),
716            PathBuf::from("/x/studio-worker.old")
717        );
718    }
719
720    #[test]
721    fn cleanup_removes_only_the_parked_artifact() {
722        let dir = tempdir().unwrap();
723        let exe = dir.path().join("studio-worker.exe");
724        std::fs::write(&exe, b"current").unwrap();
725        std::fs::write(parked_artifact_path(&exe), b"leftover").unwrap();
726        let bystander = dir.path().join("other.txt");
727        std::fs::write(&bystander, b"keep me").unwrap();
728
729        cleanup_parked_artifact(&exe);
730        assert!(!parked_artifact_path(&exe).exists());
731        assert!(exe.exists());
732        assert!(bystander.exists());
733        // Idempotent when nothing is parked.
734        cleanup_parked_artifact(&exe);
735    }
736
737    #[test]
738    fn park_surfaces_a_rename_failure_with_actionable_context() {
739        // The exe path doesn't exist, so the rename that parks it
740        // fails.  park must surface a clear error (not panic / not a
741        // bare OS code) so a failed update is diagnosable — this is
742        // the entry point of the Windows replace dance, and if it
743        // fails silently the caller would proceed to run an installer
744        // against an unparked, still-locked binary.
745        let dir = tempdir().unwrap();
746        let missing = dir.path().join("studio-worker.exe");
747        // `.err()` drops the Ok guard without needing it to be Debug.
748        let err = ExeReplaceGuard::park(&missing)
749            .err()
750            .expect("park must fail when the exe is missing")
751            .to_string();
752        assert!(
753            err.contains("parking running binary"),
754            "park error must name the operation: {err}"
755        );
756        assert!(
757            err.contains("studio-worker.exe"),
758            "park error must name the offending path: {err}"
759        );
760    }
761
762    #[test]
763    fn rollback_surfaces_a_restore_failure_with_actionable_context() {
764        // Park succeeds, then the parked binary vanishes (disk full,
765        // operator meddling, a racing cleanup) before rollback runs.
766        // rollback is the safety net that restores the running version
767        // when an update fails; if its own restore fails it must
768        // report why rather than leave the worker with no binary and
769        // no explanation.
770        let dir = tempdir().unwrap();
771        let exe = dir.path().join("studio-worker.exe");
772        std::fs::write(&exe, b"old binary").unwrap();
773        let guard = ExeReplaceGuard::park(&exe).unwrap();
774        // Remove the parked file out from under the guard.
775        std::fs::remove_file(parked_artifact_path(&exe)).unwrap();
776        let err = guard.rollback().unwrap_err().to_string();
777        assert!(
778            err.contains("restoring parked binary"),
779            "rollback error must name the operation: {err}"
780        );
781        assert!(
782            err.contains("studio-worker.exe"),
783            "rollback error must name the target path: {err}"
784        );
785    }
786
787    #[test]
788    fn cleanup_warns_when_the_parked_artifact_cannot_be_removed() {
789        // A parked path that is a non-empty directory (not a file)
790        // makes `remove_file` fail with a non-NotFound error.  Cleanup
791        // runs on every startup and must surface such a stuck artifact
792        // (so a wedged update leftover is visible and retried) instead
793        // of swallowing the failure.
794        let dir = tempdir().unwrap();
795        let exe = dir.path().join("studio-worker.exe");
796        std::fs::write(&exe, b"current").unwrap();
797        let parked = parked_artifact_path(&exe);
798        std::fs::create_dir(&parked).unwrap();
799        std::fs::write(parked.join("blocker"), b"x").unwrap();
800        let out = crate::test_support::capture(move || cleanup_parked_artifact(&exe));
801        assert!(
802            out.contains("could not remove parked binary"),
803            "a failed cleanup must warn: {out:?}"
804        );
805        assert!(
806            out.contains("studio-worker.exe.old"),
807            "the warning must name the stuck artifact: {out:?}"
808        );
809    }
810
811    #[test]
812    fn parse_tag_accepts_v_prefix_and_bare() {
813        assert_eq!(parse_tag("v1.2.3"), Some(Version::new(1, 2, 3)));
814        assert_eq!(parse_tag("1.2.3"), Some(Version::new(1, 2, 3)));
815        assert!(parse_tag("garbage").is_none());
816    }
817
818    #[test]
819    fn parse_tag_accepts_component_prefixed_release_tags() {
820        // release-please / cargo-dist tag the repo as
821        // `studio-worker-v<semver>`; the updater must read the version
822        // out of that or it never sees a newer release (the bug that
823        // made `check for updates` always say "up to date").
824        assert_eq!(
825            parse_tag("studio-worker-v0.4.2"),
826            Some(Version::new(0, 4, 2))
827        );
828        assert_eq!(
829            parse_tag("studio-worker-v1.10.0"),
830            Some(Version::new(1, 10, 0))
831        );
832        // Prerelease suffix survives (the version's own `-` is not the
833        // component separator).
834        assert_eq!(
835            parse_tag("studio-worker-v0.5.0-rc.1"),
836            Version::parse("0.5.0-rc.1").ok()
837        );
838    }
839
840    #[test]
841    fn decide_detects_newer_with_component_prefixed_tags() {
842        // The exact shape of the live feed: `studio-worker-v*` tags.
843        let releases = vec![
844            rel("studio-worker-v0.4.1", false, false, true),
845            rel("studio-worker-v0.4.2", false, false, true),
846        ];
847        let outcome = decide(&releases, &Version::new(0, 4, 1), false);
848        assert_eq!(
849            outcome,
850            CheckOutcome::NewerAvailable {
851                current: Version::new(0, 4, 1),
852                latest: Version::new(0, 4, 2),
853            }
854        );
855    }
856
857    #[test]
858    fn parse_releases_accepts_array() {
859        let text = serde_json::to_string(&serde_json::json!([
860            { "tag_name": "v1.0.0", "prerelease": false, "draft": false, "assets": [] }
861        ]))
862        .unwrap();
863        let releases = parse_releases(&text).unwrap();
864        assert_eq!(releases.len(), 1);
865        assert_eq!(releases[0].tag_name, "v1.0.0");
866    }
867
868    #[test]
869    fn parse_releases_accepts_single_object() {
870        let text = serde_json::to_string(&serde_json::json!({
871            "tag_name": "v2.0.0", "prerelease": false, "draft": false, "assets": []
872        }))
873        .unwrap();
874        let releases = parse_releases(&text).unwrap();
875        assert_eq!(releases.len(), 1);
876        assert_eq!(releases[0].tag_name, "v2.0.0");
877    }
878
879    #[test]
880    fn parse_releases_errors_on_garbage() {
881        assert!(parse_releases("not json").is_err());
882    }
883
884    #[test]
885    fn decide_reports_up_to_date_when_no_newer() {
886        let releases = vec![rel("v0.1.0", false, false, true)];
887        let outcome = decide(&releases, &Version::new(0, 1, 0), false);
888        assert_eq!(
889            outcome,
890            CheckOutcome::UpToDate {
891                current: Version::new(0, 1, 0)
892            }
893        );
894    }
895
896    #[test]
897    fn decide_reports_newer_when_higher_present() {
898        let releases = vec![
899            rel("v0.1.0", false, false, true),
900            rel("v0.2.0", false, false, true),
901        ];
902        let outcome = decide(&releases, &Version::new(0, 1, 0), false);
903        assert_eq!(
904            outcome,
905            CheckOutcome::NewerAvailable {
906                current: Version::new(0, 1, 0),
907                latest: Version::new(0, 2, 0),
908            }
909        );
910    }
911
912    #[test]
913    fn decide_skips_prereleases_unless_opted_in() {
914        let releases = vec![
915            rel("v0.1.0", false, false, true),
916            rel("v0.3.0-rc.1", true, false, true),
917        ];
918        let outcome = decide(&releases, &Version::new(0, 1, 0), false);
919        assert!(matches!(outcome, CheckOutcome::UpToDate { .. }));
920        let outcome = decide(&releases, &Version::new(0, 1, 0), true);
921        assert!(matches!(outcome, CheckOutcome::NewerAvailable { .. }));
922    }
923
924    #[test]
925    fn decide_skips_drafts() {
926        let releases = vec![
927            rel("v0.1.0", false, false, true),
928            rel("v0.9.0", false, true, true),
929        ];
930        let outcome = decide(&releases, &Version::new(0, 1, 0), false);
931        assert!(matches!(outcome, CheckOutcome::UpToDate { .. }));
932    }
933
934    #[test]
935    fn decide_handles_empty_feed() {
936        let outcome = decide(&[], &Version::new(1, 0, 0), false);
937        assert!(matches!(outcome, CheckOutcome::UpToDate { .. }));
938    }
939
940    #[test]
941    fn decide_skips_malformed_tags() {
942        let releases = vec![
943            rel("garbage", false, false, true),
944            rel("v0.1.0", false, false, true),
945        ];
946        let outcome = decide(&releases, &Version::new(0, 0, 1), false);
947        match outcome {
948            CheckOutcome::NewerAvailable { latest, .. } => {
949                assert_eq!(latest, Version::new(0, 1, 0))
950            }
951            _ => panic!("expected newer"),
952        }
953    }
954
955    #[test]
956    fn decide_warns_on_each_unparseable_candidate_tag() {
957        // A non-draft, non-prerelease release whose tag can't be parsed
958        // as a version is dropped from the update check (preserving the
959        // existing behaviour) but warn-logged with the offending tag, so
960        // a tag-format drift that silently strands the worker on an old
961        // build leaves a breadcrumb instead of vanishing without a trace.
962        let logs = crate::test_support::capture(|| {
963            let releases = vec![
964                rel("totally-not-a-version", false, false, true),
965                rel("studio-worker-v0.1.0", false, false, true),
966            ];
967            let _ = decide(&releases, &Version::new(0, 0, 1), false);
968        });
969        assert!(
970            logs.contains("studio_worker::update"),
971            "expected update target, got: {logs}"
972        );
973        assert!(logs.contains("WARN"), "expected WARN level, got: {logs}");
974        assert!(
975            logs.contains("totally-not-a-version"),
976            "expected the offending tag in the warn, got: {logs}"
977        );
978    }
979
980    #[test]
981    fn decide_breadcrumb_reports_dropped_count() {
982        // The decision breadcrumb carries the number of candidate tags
983        // dropped so a feed that under-reports its versions can't pass
984        // for a fully-evaluated one.
985        let logs = crate::test_support::capture(|| {
986            let releases = vec![
987                rel("garbage", false, false, true),
988                rel("also-bad", false, false, true),
989                rel("studio-worker-v0.2.0", false, false, true),
990            ];
991            let _ = decide(&releases, &Version::new(0, 1, 0), false);
992        });
993        assert!(
994            logs.contains("dropped=2"),
995            "expected dropped=2 in the breadcrumb, got: {logs}"
996        );
997    }
998
999    #[test]
1000    fn decide_does_not_count_filtered_out_releases_as_dropped() {
1001        // Drafts and (when not opted in) prereleases are intentionally
1002        // excluded before the parse step, so an unparseable tag on one of
1003        // those is not a lost candidate and must not be warn-logged or
1004        // counted as dropped.
1005        let logs = crate::test_support::capture(|| {
1006            let releases = vec![
1007                rel("draft-garbage", false, true, true),
1008                rel("prerelease-garbage", true, false, true),
1009                rel("studio-worker-v0.2.0", false, false, true),
1010            ];
1011            let _ = decide(&releases, &Version::new(0, 1, 0), false);
1012        });
1013        assert!(
1014            logs.contains("dropped=0"),
1015            "filtered-out releases must not count as dropped, got: {logs}"
1016        );
1017        assert!(
1018            !logs.contains("draft-garbage"),
1019            "a filtered draft must not warn, got: {logs}"
1020        );
1021        assert!(
1022            !logs.contains("prerelease-garbage"),
1023            "a filtered prerelease must not warn, got: {logs}"
1024        );
1025    }
1026
1027    #[test]
1028    fn installer_asset_name_matches_platform() {
1029        let name = installer_asset_name();
1030        if cfg!(target_os = "windows") {
1031            assert_eq!(name, "studio-worker-installer.ps1");
1032        } else {
1033            assert_eq!(name, "studio-worker-installer.sh");
1034        }
1035    }
1036
1037    #[test]
1038    fn resolve_installer_url_finds_the_right_asset() {
1039        let release = rel("v1.0.0", false, false, true);
1040        let url = resolve_installer_url(&release).unwrap();
1041        assert_eq!(url, "https://example.com/v1.0.0");
1042    }
1043
1044    #[test]
1045    fn resolve_installer_url_returns_none_when_missing() {
1046        let release = rel("v1.0.0", false, false, false);
1047        assert!(resolve_installer_url(&release).is_none());
1048    }
1049
1050    // -----------------------------------------------------------------
1051    // verify_download_len — guards the installer download against a
1052    // short read before the bytes are handed to `sh` / `powershell`.
1053    // A truncated installer that runs is far worse than a failed
1054    // update, so a Content-Length mismatch must surface as an error.
1055    // -----------------------------------------------------------------
1056
1057    #[test]
1058    fn verify_download_len_accepts_exact_match() {
1059        assert!(verify_download_len(2048, Some(2048)).is_ok());
1060    }
1061
1062    #[test]
1063    fn verify_download_len_accepts_when_length_unknown() {
1064        // Chunked transfers omit Content-Length; nothing to check, so
1065        // we accept whatever streamed in (same as before this guard).
1066        assert!(verify_download_len(123, None).is_ok());
1067    }
1068
1069    #[test]
1070    fn verify_download_len_rejects_truncated_installer() {
1071        let err = verify_download_len(40, Some(100)).unwrap_err().to_string();
1072        assert!(err.contains("size mismatch"), "got: {err}");
1073        assert!(err.contains("40"), "got: {err}");
1074        assert!(err.contains("100"), "got: {err}");
1075    }
1076
1077    #[test]
1078    fn verify_download_len_rejects_overlong_installer() {
1079        // A body longer than the declared length is just as corrupt as
1080        // a short one — reject both rather than run a bad installer.
1081        assert!(verify_download_len(120, Some(100)).is_err());
1082    }
1083
1084    #[test]
1085    fn validate_installer_download_url_allows_https() {
1086        validate_installer_download_url("https://github.com/owner/repo/releases/download/x/i.sh")
1087            .unwrap();
1088    }
1089
1090    #[test]
1091    fn validate_installer_download_url_allows_loopback_http_for_tests() {
1092        validate_installer_download_url("http://127.0.0.1:1234/i.sh").unwrap();
1093        validate_installer_download_url("http://localhost:1234/i.sh").unwrap();
1094    }
1095
1096    #[test]
1097    fn validate_installer_download_url_rejects_remote_http() {
1098        let err = validate_installer_download_url("http://example.com/i.sh")
1099            .unwrap_err()
1100            .to_string();
1101        assert!(err.contains("https"), "got: {err}");
1102    }
1103
1104    #[test]
1105    fn validate_installer_download_url_rejects_non_http_schemes() {
1106        // The gate must reject anything that isn't https (or loopback
1107        // http) *before* the auto-updater downloads and executes the
1108        // asset.  These schemes take a different path through the guard
1109        // than `http://example.com` — they skip the `http` block
1110        // entirely and fall straight to the bail — so they need their
1111        // own cover.  `file://` is the dangerous one: a compromised
1112        // release feed handing back `file:///etc/cron.d/evil.sh` would,
1113        // without this guard, point the installer runner at an arbitrary
1114        // local script.  `ftp://` is unencrypted (tamperable in
1115        // transit) and `javascript:` carries no host at all.
1116        for raw in [
1117            "file:///etc/cron.d/evil.sh",
1118            "ftp://example.com/i.sh",
1119            "javascript:alert(1)",
1120        ] {
1121            let err = validate_installer_download_url(raw)
1122                .unwrap_err()
1123                .to_string();
1124            assert!(
1125                err.contains("https"),
1126                "{raw} must be rejected with the https guidance, got: {err}"
1127            );
1128        }
1129    }
1130
1131    #[test]
1132    fn validate_installer_download_url_pins_the_release_host() {
1133        // Even a poisoned feed must not be able to point the updater at
1134        // an arbitrary https server — only GitHub's release hosts (and
1135        // loopback, for tests) may serve installers.
1136        for bad in [
1137            "https://evil.example/installer.sh",
1138            "https://github.com.evil.example/i.sh",
1139            "https://raw.githubusercontent.com/o/r/i.sh",
1140        ] {
1141            let err = validate_installer_download_url(bad)
1142                .unwrap_err()
1143                .to_string();
1144            assert!(
1145                err.contains("allowed release host"),
1146                "{bad} must be rejected by the host pin: {err}"
1147            );
1148        }
1149        validate_installer_download_url(
1150            "https://objects.githubusercontent.com/github-production-release-asset/x",
1151        )
1152        .unwrap();
1153        validate_installer_download_url("https://GITHUB.COM/o/r/releases/download/v1/i.sh")
1154            .unwrap();
1155    }
1156
1157    // -----------------------------------------------------------------
1158    // Checksum sidecar parsing + file hashing — the integrity gate in
1159    // front of `run_installer`.
1160    // -----------------------------------------------------------------
1161
1162    #[test]
1163    fn parse_checksum_file_accepts_common_shapes() {
1164        let hex = "1f2eef5fe020e81929161910fba1dea68e0baf62c6d3067dd7e996bf4a7ea508";
1165        assert_eq!(parse_checksum_file(hex), Some(hex.to_string()));
1166        assert_eq!(
1167            parse_checksum_file(&format!("{hex}  studio-worker-installer.sh\n")),
1168            Some(hex.to_string())
1169        );
1170        assert_eq!(
1171            parse_checksum_file(&format!("{hex} *studio-worker-installer.sh")),
1172            Some(hex.to_string())
1173        );
1174        assert_eq!(
1175            parse_checksum_file(&format!("\n\n{}  x", hex.to_uppercase())),
1176            Some(hex.to_string()),
1177            "uppercase digests normalise to lowercase"
1178        );
1179    }
1180
1181    #[test]
1182    fn parse_checksum_file_rejects_garbage() {
1183        assert_eq!(parse_checksum_file(""), None);
1184        assert_eq!(parse_checksum_file("not a checksum"), None);
1185        assert_eq!(parse_checksum_file("abc123  file"), None, "too short");
1186        assert_eq!(
1187            parse_checksum_file(&"g".repeat(64)),
1188            None,
1189            "non-hex characters"
1190        );
1191    }
1192
1193    #[test]
1194    fn verify_file_sha256_accepts_match_and_rejects_mismatch() {
1195        let dir = tempdir().unwrap();
1196        let file = dir.path().join("installer.sh");
1197        std::fs::write(&file, b"#!/bin/sh\necho fake installer\n").unwrap();
1198        verify_file_sha256(&file, FAKE_INSTALLER_SHA256).unwrap();
1199        verify_file_sha256(&file, &FAKE_INSTALLER_SHA256.to_uppercase()).unwrap();
1200        let err = verify_file_sha256(&file, &"0".repeat(64))
1201            .unwrap_err()
1202            .to_string();
1203        assert!(err.contains("sha256 mismatch"), "got: {err}");
1204    }
1205
1206    #[test]
1207    fn validate_installer_download_url_rejects_a_malformed_url() {
1208        // A feed entry that doesn't parse as a URL at all must error at
1209        // the parse step (carrying the `invalid installer URL` context)
1210        // rather than slipping through to a download attempt.
1211        let err = validate_installer_download_url("not a url")
1212            .unwrap_err()
1213            .to_string();
1214        assert!(
1215            err.contains("invalid installer URL"),
1216            "a malformed URL must surface the parse context, got: {err}"
1217        );
1218    }
1219
1220    // -----------------------------------------------------------------
1221    // RealRunner::run_installer — the production path that hands the
1222    // downloaded installer to `sh` (unix) / PowerShell (Windows).  The
1223    // unix branch is exercised here against trivial scripts so the
1224    // safety property is locked in: a non-zero installer exit MUST
1225    // bail, never report success.  Tests elsewhere drive `apply_with`
1226    // through a fake runner, so without this the real subprocess
1227    // dispatch shipped untested.
1228    // -----------------------------------------------------------------
1229
1230    #[cfg(unix)]
1231    #[test]
1232    fn real_runner_run_installer_succeeds_on_zero_exit() {
1233        let dir = tempdir().unwrap();
1234        let script = dir.path().join("installer.sh");
1235        // `sh <path>` reads the file directly, so no shebang or +x bit
1236        // is needed.
1237        std::fs::write(&script, "exit 0\n").unwrap();
1238        RealRunner.run_installer(&script).unwrap();
1239    }
1240
1241    #[cfg(unix)]
1242    #[test]
1243    fn real_runner_run_installer_bails_on_nonzero_exit() {
1244        let dir = tempdir().unwrap();
1245        let script = dir.path().join("installer.sh");
1246        std::fs::write(&script, "exit 3\n").unwrap();
1247        let err = RealRunner.run_installer(&script).unwrap_err().to_string();
1248        assert!(
1249            err.contains("installer exited"),
1250            "a failed installer must surface a clear error, got: {err}"
1251        );
1252    }
1253
1254    #[test]
1255    fn restart_argv_uses_current_exe_and_args() {
1256        let (bin, _args) = restart_argv();
1257        assert!(!bin.as_os_str().is_empty());
1258    }
1259
1260    // -----------------------------------------------------------------
1261    // apply_with — exercised via a fake runner that records calls.
1262    // -----------------------------------------------------------------
1263
1264    /// sha256 of the fake installer body the FakeRunner writes.
1265    const FAKE_INSTALLER_SHA256: &str =
1266        "1f2eef5fe020e81929161910fba1dea68e0baf62c6d3067dd7e996bf4a7ea508";
1267
1268    #[derive(Default)]
1269    struct FakeRunner {
1270        downloaded: RefCell<Vec<(String, PathBuf)>>,
1271        checksum_fetches: RefCell<Vec<String>>,
1272        ran: RefCell<Vec<PathBuf>>,
1273        fail_download: bool,
1274        fail_run: bool,
1275        /// What `fetch_checksum` hands back (`None` = no sidecar).
1276        checksum: Option<String>,
1277    }
1278
1279    impl UpdateRunner for FakeRunner {
1280        fn download(&self, url: &str, dest: &Path) -> Result<()> {
1281            self.downloaded
1282                .borrow_mut()
1283                .push((url.to_string(), dest.to_path_buf()));
1284            if self.fail_download {
1285                bail!("simulated download failure");
1286            }
1287            // Touch the file so apply's runner contract is satisfied.
1288            std::fs::write(dest, b"#!/bin/sh\necho fake installer\n").unwrap();
1289            Ok(())
1290        }
1291        fn fetch_checksum(&self, url: &str) -> Result<Option<String>> {
1292            self.checksum_fetches.borrow_mut().push(url.to_string());
1293            Ok(self.checksum.clone())
1294        }
1295        fn run_installer(&self, installer_path: &Path) -> Result<()> {
1296            self.ran.borrow_mut().push(installer_path.to_path_buf());
1297            if self.fail_run {
1298                bail!("simulated installer failure");
1299            }
1300            Ok(())
1301        }
1302    }
1303
1304    #[test]
1305    fn fake_installer_sha_constant_matches_the_body() {
1306        // Guard the fixture: every checksum test below depends on it.
1307        use sha2::{Digest, Sha256};
1308        let hex: String = Sha256::digest(b"#!/bin/sh\necho fake installer\n")
1309            .iter()
1310            .map(|b| format!("{b:02x}"))
1311            .collect();
1312        assert_eq!(hex, FAKE_INSTALLER_SHA256);
1313    }
1314
1315    fn write_fixture_feed(dir: &tempfile::TempDir, releases: serde_json::Value) -> String {
1316        let path = dir.path().join("releases.json");
1317        std::fs::write(&path, releases.to_string()).unwrap();
1318        format!("file://{}", path.to_string_lossy())
1319    }
1320
1321    fn fake_release_with_installer(tag: &str) -> serde_json::Value {
1322        serde_json::json!({
1323            "tag_name": tag,
1324            "prerelease": false,
1325            "draft": false,
1326            "assets": [{
1327                "name": installer_asset_name(),
1328                "browser_download_url": format!("https://example.invalid/{tag}/{}", installer_asset_name()),
1329            }],
1330        })
1331    }
1332
1333    // The reqwest blocking client doesn't follow `file://` URLs, so we
1334    // use wiremock-served feeds for the apply tests via the integration
1335    // suite (`tests/auto_update.rs`).  Here we just verify the unit-test
1336    // branches: missing release, missing asset.
1337    #[test]
1338    fn apply_with_errors_when_release_missing() {
1339        // Static fixture parsed via parse_releases bypasses HTTP for this
1340        // narrow test.  We can't call apply_with without a real HTTP fetch
1341        // since fetch_releases is HTTP only — but we can drive the
1342        // post-fetch branches directly.
1343        let releases: Vec<GithubRelease> = vec![rel("v0.1.0", false, false, true)];
1344        let missing = Version::new(9, 9, 9);
1345        let url = releases
1346            .iter()
1347            .find(|r| parse_tag(&r.tag_name).as_ref() == Some(&missing));
1348        assert!(url.is_none(), "v9.9.9 should not be in the fixture");
1349    }
1350
1351    // Sanity: we can write a fake feed file (used by integration tests).
1352    #[test]
1353    fn writing_a_fake_feed_round_trips_through_parse_releases() {
1354        let dir = tempdir().unwrap();
1355        let url = write_fixture_feed(
1356            &dir,
1357            serde_json::json!([fake_release_with_installer("v0.1.0")]),
1358        );
1359        let _ = url;
1360        let text = std::fs::read_to_string(dir.path().join("releases.json")).unwrap();
1361        let releases = parse_releases(&text).unwrap();
1362        assert_eq!(releases.len(), 1);
1363        assert_eq!(releases[0].tag_name, "v0.1.0");
1364    }
1365
1366    #[test]
1367    fn fake_runner_records_download_and_run() {
1368        let runner = FakeRunner::default();
1369        let dir = tempdir().unwrap();
1370        let dest = dir.path().join("installer.sh");
1371        runner.download("https://example.com/a", &dest).unwrap();
1372        runner.run_installer(&dest).unwrap();
1373        assert_eq!(runner.downloaded.borrow().len(), 1);
1374        assert_eq!(runner.ran.borrow().len(), 1);
1375        assert!(dest.exists());
1376    }
1377
1378    #[test]
1379    fn fake_runner_surfaces_download_errors() {
1380        let runner = FakeRunner {
1381            fail_download: true,
1382            ..FakeRunner::default()
1383        };
1384        let dir = tempdir().unwrap();
1385        let dest = dir.path().join("installer.sh");
1386        let err = runner.download("https://example.com/a", &dest).unwrap_err();
1387        assert!(err.to_string().contains("simulated download"));
1388    }
1389
1390    #[test]
1391    fn fake_runner_surfaces_install_errors() {
1392        let runner = FakeRunner {
1393            fail_run: true,
1394            ..FakeRunner::default()
1395        };
1396        let dir = tempdir().unwrap();
1397        let dest = dir.path().join("installer.sh");
1398        let err = runner.run_installer(&dest).unwrap_err();
1399        assert!(err.to_string().contains("simulated installer"));
1400    }
1401}