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    fn run_installer(&self, installer_path: &Path) -> Result<()>;
202}
203
204pub struct RealRunner;
205
206impl UpdateRunner for RealRunner {
207    fn download(&self, url: &str, dest: &Path) -> Result<()> {
208        validate_installer_download_url(url)?;
209        let client = reqwest::blocking::Client::builder()
210            .timeout(Duration::from_secs(300))
211            .user_agent(concat!("studio-worker/", env!("CARGO_PKG_VERSION")))
212            .build()?;
213        let started = Instant::now();
214        let mut response = client.get(url).send()?.error_for_status()?;
215        // Capture the declared length (absent on chunked transfers)
216        // before streaming so a short read is caught below — the next
217        // step runs this file as a shell / PowerShell script.
218        let expected_len = response.content_length();
219        let mut file = std::fs::File::create(dest)?;
220        let bytes = std::io::copy(&mut response, &mut file)?;
221        // Reject a truncated / overlong download before `apply_with`
222        // hands the file to the installer runner.  Bailing here means
223        // `run_installer` never executes, and `apply_with`'s tempdir
224        // drop cleans up the partial file.
225        verify_download_len(bytes, expected_len)
226            .with_context(|| format!("downloading installer from {url}"))?;
227        info!(
228            target: TRACE_TARGET,
229            url,
230            dest = %dest.display(),
231            bytes,
232            elapsed_ms = started.elapsed().as_millis() as u64,
233            "installer downloaded"
234        );
235        Ok(())
236    }
237
238    fn run_installer(&self, installer_path: &Path) -> Result<()> {
239        if cfg!(target_os = "windows") {
240            let status = std::process::Command::new("powershell")
241                .args([
242                    "-NoProfile",
243                    "-ExecutionPolicy",
244                    "Bypass",
245                    "-File",
246                    installer_path
247                        .to_str()
248                        .ok_or_else(|| anyhow!("installer path not UTF-8"))?,
249                ])
250                .status()?;
251            if !status.success() {
252                bail!("installer exited with {status}");
253            }
254        } else {
255            let status = std::process::Command::new("sh")
256                .arg(installer_path)
257                .status()?;
258            if !status.success() {
259                bail!("installer exited with {status}");
260            }
261        }
262        Ok(())
263    }
264}
265
266fn validate_installer_download_url(raw: &str) -> Result<()> {
267    let url = url::Url::parse(raw).with_context(|| format!("invalid installer URL {raw:?}"))?;
268    if url.scheme() == "https" {
269        return Ok(());
270    }
271    if url.scheme() == "http" {
272        if let Some(host) = url.host_str() {
273            if host == "localhost"
274                || host
275                    .parse::<std::net::IpAddr>()
276                    .is_ok_and(|ip| ip.is_loopback())
277            {
278                return Ok(());
279            }
280        }
281    }
282    bail!("installer URL must use https (loopback http is allowed for tests): {raw}");
283}
284
285/// Where a parked (renamed-aside) running executable lives: the full
286/// original file name with `.old` appended.  `with_extension` would
287/// turn `studio-worker.exe` into `studio-worker.old` and risk
288/// clobbering an unrelated sibling.
289pub fn parked_artifact_path(exe: &Path) -> PathBuf {
290    let name = exe
291        .file_name()
292        .map(|n| n.to_string_lossy().into_owned())
293        .unwrap_or_else(|| "studio-worker".to_string());
294    exe.with_file_name(format!("{name}.old"))
295}
296
297/// Remove a leftover parked binary from a previous update.  Called on
298/// startup; best-effort — a locked or missing file is fine.
299pub fn cleanup_parked_artifact(exe: &Path) {
300    let parked = parked_artifact_path(exe);
301    match std::fs::remove_file(&parked) {
302        Ok(()) => info!(
303            target: TRACE_TARGET,
304            parked = %parked.display(),
305            "removed parked binary from a previous update"
306        ),
307        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
308        Err(e) => warn!(
309            target: TRACE_TARGET,
310            parked = %parked.display(),
311            error = %e,
312            "could not remove parked binary; will retry next start"
313        ),
314    }
315}
316
317/// Best-effort startup cleanup for the running process's own parked
318/// artifact.  Excluded from coverage: depends on `current_exe`.
319#[cfg_attr(coverage_nightly, coverage(off))]
320pub fn cleanup_parked_artifact_for_current_exe() {
321    if let Ok(exe) = std::env::current_exe() {
322        cleanup_parked_artifact(&exe);
323    }
324}
325
326/// Windows can't overwrite a running executable (the file is locked),
327/// but it CAN rename it.  Parking the running exe under a `.old` name
328/// frees the original path so the cargo-dist installer's `Copy-Item`
329/// succeeds; the parked file is removed on the next start.
330///
331/// The guard is plain filesystem logic so it is unit-tested on every
332/// platform; `apply_with` only activates it on Windows.
333pub struct ExeReplaceGuard {
334    original: PathBuf,
335    parked: PathBuf,
336}
337
338impl ExeReplaceGuard {
339    /// Rename `exe` aside.  Replaces any stale artifact from a
340    /// previous update first.
341    pub fn park(exe: &Path) -> Result<Self> {
342        let parked = parked_artifact_path(exe);
343        if parked.exists() {
344            std::fs::remove_file(&parked)
345                .with_context(|| format!("removing stale parked binary {}", parked.display()))?;
346        }
347        std::fs::rename(exe, &parked).with_context(|| {
348            format!(
349                "parking running binary {} -> {}",
350                exe.display(),
351                parked.display()
352            )
353        })?;
354        info!(
355            target: TRACE_TARGET,
356            exe = %exe.display(),
357            parked = %parked.display(),
358            "parked running binary so the installer can replace it"
359        );
360        Ok(Self {
361            original: exe.to_path_buf(),
362            parked,
363        })
364    }
365
366    /// After the installer ran: did a new binary land at the original
367    /// path?  If not, the installer wrote somewhere else and a restart
368    /// would find nothing to exec — the caller must roll back.
369    pub fn confirm_replaced(&self) -> Result<()> {
370        if self.original.is_file() {
371            return Ok(());
372        }
373        bail!(
374            "installer did not write a new binary at {} (custom install dir?)",
375            self.original.display()
376        )
377    }
378
379    /// Undo the park — the update failed and the worker keeps running
380    /// the old version.
381    pub fn rollback(self) -> Result<()> {
382        std::fs::rename(&self.parked, &self.original).with_context(|| {
383            format!(
384                "restoring parked binary {} -> {}",
385                self.parked.display(),
386                self.original.display()
387            )
388        })
389    }
390}
391
392pub fn apply_with<R: UpdateRunner>(feed_url: &str, latest: &Version, runner: &R) -> Result<()> {
393    info!(
394        target: TRACE_TARGET,
395        feed_url,
396        latest = %latest,
397        "applying update"
398    );
399    let releases = fetch_releases(feed_url)?;
400    let release = releases
401        .iter()
402        .find(|r| parse_tag(&r.tag_name).as_ref() == Some(latest))
403        .ok_or_else(|| anyhow!("release {latest} not present in feed"))?;
404
405    let url = resolve_installer_url(release).ok_or_else(|| {
406        anyhow!(
407            "release {} is missing installer asset {}",
408            latest,
409            installer_asset_name()
410        )
411    })?;
412
413    let tmp = tempfile::tempdir().context("creating tempdir for installer")?;
414    let installer_path = tmp.path().join(installer_asset_name());
415    info!(
416        target: TRACE_TARGET,
417        url,
418        dest = %installer_path.display(),
419        latest = %latest,
420        "downloading installer"
421    );
422    runner.download(url, &installer_path)?;
423    info!(
424        target: TRACE_TARGET,
425        installer = %installer_path.display(),
426        latest = %latest,
427        "running installer"
428    );
429    // Windows locks the running executable: the installer's Copy-Item
430    // fails with "file in use" unless we park (rename) ourselves out
431    // of the way first.  Renames of running binaries are allowed on
432    // NTFS.  Unix installers replace via unlink + write, no parking
433    // needed.
434    let guard = if cfg!(target_os = "windows") {
435        let exe = std::env::current_exe().context("resolving current exe for update")?;
436        Some(ExeReplaceGuard::park(&exe)?)
437    } else {
438        None
439    };
440    match runner.run_installer(&installer_path) {
441        Ok(()) => {
442            if let Some(guard) = guard {
443                if let Err(e) = guard.confirm_replaced() {
444                    // Roll back so the (still-running) old version can
445                    // be restarted by path; surface why the update
446                    // didn't take.
447                    if let Err(rb) = guard.rollback() {
448                        warn!(target: TRACE_TARGET, error = %rb, "rollback after failed replace also failed");
449                    }
450                    return Err(e);
451                }
452                // Parked file stays until the next start (this process
453                // is still executing it); cleanup_parked_artifact
454                // removes it then.
455            }
456        }
457        Err(e) => {
458            if let Some(guard) = guard {
459                if let Err(rb) = guard.rollback() {
460                    warn!(target: TRACE_TARGET, error = %rb, "rollback after installer failure also failed");
461                }
462            }
463            return Err(e);
464        }
465    }
466    info!(
467        target: TRACE_TARGET,
468        latest = %latest,
469        "installer completed; binary replaced"
470    );
471    Ok(())
472}
473
474/// Compute the (binary, args) tuple we'd re-exec ourselves with.  Pure
475/// — actual exec lives in [`restart_self`].
476pub fn restart_argv() -> (PathBuf, Vec<std::ffi::OsString>) {
477    let mut iter = std::env::args_os();
478    let bin = iter
479        .next()
480        .map(PathBuf::from)
481        .unwrap_or_else(|| PathBuf::from("studio-worker"));
482    let args: Vec<std::ffi::OsString> = iter.collect();
483    (bin, args)
484}
485
486/// Replace the current process with a fresh exec of the (now-updated)
487/// binary.  On unix we use `execvp`; on Windows we spawn the successor
488/// and exit cleanly.  Unreachable from tests — covered by integration
489/// tests of `apply_with` instead.
490#[cfg_attr(coverage_nightly, coverage(off))]
491pub fn restart_self() -> ! {
492    let (bin, args) = restart_argv();
493    info!(
494        target: TRACE_TARGET,
495        bin = %bin.display(),
496        argc = args.len(),
497        "restarting into updated binary"
498    );
499    #[cfg(unix)]
500    {
501        use std::os::unix::process::CommandExt;
502        let err = std::process::Command::new(&bin).args(&args).exec();
503        tracing::error!(
504            target: TRACE_TARGET,
505            bin = %bin.display(),
506            %err,
507            "exec into updated binary failed"
508        );
509        eprintln!("[studio-worker] exec failed: {err}");
510        std::process::exit(1);
511    }
512    #[cfg(not(unix))]
513    {
514        match std::process::Command::new(&bin).args(&args).spawn() {
515            Ok(_) => std::process::exit(0),
516            Err(err) => {
517                tracing::error!(
518                    target: TRACE_TARGET,
519                    bin = %bin.display(),
520                    %err,
521                    "spawn-restart of updated binary failed"
522                );
523                eprintln!("[studio-worker] spawn-restart failed: {err}");
524                std::process::exit(1);
525            }
526        }
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use crate::types::{GithubRelease, GithubReleaseAsset};
534    use std::cell::RefCell;
535    use std::path::PathBuf;
536    use tempfile::tempdir;
537
538    fn rel(tag: &str, prerelease: bool, draft: bool, with_installer: bool) -> GithubRelease {
539        let assets = if with_installer {
540            vec![GithubReleaseAsset {
541                name: installer_asset_name().to_string(),
542                browser_download_url: format!("https://example.com/{tag}"),
543            }]
544        } else {
545            vec![]
546        };
547        GithubRelease {
548            tag_name: tag.to_string(),
549            prerelease,
550            draft,
551            assets,
552        }
553    }
554
555    // -----------------------------------------------------------------
556    // ExeReplaceGuard — the Windows locked-exe dance.  Pure fs logic,
557    // unit-tested on every platform; only the activation in apply_with
558    // is Windows-gated.
559    // -----------------------------------------------------------------
560
561    #[test]
562    fn park_moves_the_exe_aside_and_confirm_fails_until_replaced() {
563        let dir = tempdir().unwrap();
564        let exe = dir.path().join("studio-worker.exe");
565        std::fs::write(&exe, b"old binary").unwrap();
566
567        let guard = ExeReplaceGuard::park(&exe).unwrap();
568        assert!(
569            !exe.exists(),
570            "original path must be free for the installer"
571        );
572        assert_eq!(
573            std::fs::read(parked_artifact_path(&exe)).unwrap(),
574            b"old binary"
575        );
576        // Installer hasn't written the new binary yet.
577        assert!(guard.confirm_replaced().is_err());
578
579        // Installer writes the new binary at the original path.
580        std::fs::write(&exe, b"new binary").unwrap();
581        guard.confirm_replaced().unwrap();
582    }
583
584    #[test]
585    fn rollback_restores_the_original_exe() {
586        let dir = tempdir().unwrap();
587        let exe = dir.path().join("studio-worker.exe");
588        std::fs::write(&exe, b"old binary").unwrap();
589
590        let guard = ExeReplaceGuard::park(&exe).unwrap();
591        guard.rollback().unwrap();
592        assert_eq!(std::fs::read(&exe).unwrap(), b"old binary");
593        assert!(!parked_artifact_path(&exe).exists());
594    }
595
596    #[test]
597    fn park_replaces_a_stale_artifact_from_a_previous_update() {
598        let dir = tempdir().unwrap();
599        let exe = dir.path().join("studio-worker.exe");
600        std::fs::write(&exe, b"current").unwrap();
601        std::fs::write(parked_artifact_path(&exe), b"ancient leftover").unwrap();
602
603        let _guard = ExeReplaceGuard::park(&exe).unwrap();
604        assert_eq!(
605            std::fs::read(parked_artifact_path(&exe)).unwrap(),
606            b"current"
607        );
608    }
609
610    #[test]
611    fn parked_artifact_path_appends_old_to_the_full_file_name() {
612        // `.with_extension` would turn studio-worker.exe into
613        // studio-worker.old and clobber a sibling file — the artifact
614        // must keep the full original name.
615        assert_eq!(
616            parked_artifact_path(Path::new("/x/studio-worker.exe")),
617            PathBuf::from("/x/studio-worker.exe.old")
618        );
619        assert_eq!(
620            parked_artifact_path(Path::new("/x/studio-worker")),
621            PathBuf::from("/x/studio-worker.old")
622        );
623    }
624
625    #[test]
626    fn cleanup_removes_only_the_parked_artifact() {
627        let dir = tempdir().unwrap();
628        let exe = dir.path().join("studio-worker.exe");
629        std::fs::write(&exe, b"current").unwrap();
630        std::fs::write(parked_artifact_path(&exe), b"leftover").unwrap();
631        let bystander = dir.path().join("other.txt");
632        std::fs::write(&bystander, b"keep me").unwrap();
633
634        cleanup_parked_artifact(&exe);
635        assert!(!parked_artifact_path(&exe).exists());
636        assert!(exe.exists());
637        assert!(bystander.exists());
638        // Idempotent when nothing is parked.
639        cleanup_parked_artifact(&exe);
640    }
641
642    #[test]
643    fn park_surfaces_a_rename_failure_with_actionable_context() {
644        // The exe path doesn't exist, so the rename that parks it
645        // fails.  park must surface a clear error (not panic / not a
646        // bare OS code) so a failed update is diagnosable — this is
647        // the entry point of the Windows replace dance, and if it
648        // fails silently the caller would proceed to run an installer
649        // against an unparked, still-locked binary.
650        let dir = tempdir().unwrap();
651        let missing = dir.path().join("studio-worker.exe");
652        // `.err()` drops the Ok guard without needing it to be Debug.
653        let err = ExeReplaceGuard::park(&missing)
654            .err()
655            .expect("park must fail when the exe is missing")
656            .to_string();
657        assert!(
658            err.contains("parking running binary"),
659            "park error must name the operation: {err}"
660        );
661        assert!(
662            err.contains("studio-worker.exe"),
663            "park error must name the offending path: {err}"
664        );
665    }
666
667    #[test]
668    fn rollback_surfaces_a_restore_failure_with_actionable_context() {
669        // Park succeeds, then the parked binary vanishes (disk full,
670        // operator meddling, a racing cleanup) before rollback runs.
671        // rollback is the safety net that restores the running version
672        // when an update fails; if its own restore fails it must
673        // report why rather than leave the worker with no binary and
674        // no explanation.
675        let dir = tempdir().unwrap();
676        let exe = dir.path().join("studio-worker.exe");
677        std::fs::write(&exe, b"old binary").unwrap();
678        let guard = ExeReplaceGuard::park(&exe).unwrap();
679        // Remove the parked file out from under the guard.
680        std::fs::remove_file(parked_artifact_path(&exe)).unwrap();
681        let err = guard.rollback().unwrap_err().to_string();
682        assert!(
683            err.contains("restoring parked binary"),
684            "rollback error must name the operation: {err}"
685        );
686        assert!(
687            err.contains("studio-worker.exe"),
688            "rollback error must name the target path: {err}"
689        );
690    }
691
692    #[test]
693    fn cleanup_warns_when_the_parked_artifact_cannot_be_removed() {
694        // A parked path that is a non-empty directory (not a file)
695        // makes `remove_file` fail with a non-NotFound error.  Cleanup
696        // runs on every startup and must surface such a stuck artifact
697        // (so a wedged update leftover is visible and retried) instead
698        // of swallowing the failure.
699        let dir = tempdir().unwrap();
700        let exe = dir.path().join("studio-worker.exe");
701        std::fs::write(&exe, b"current").unwrap();
702        let parked = parked_artifact_path(&exe);
703        std::fs::create_dir(&parked).unwrap();
704        std::fs::write(parked.join("blocker"), b"x").unwrap();
705        let out = crate::test_support::capture(move || cleanup_parked_artifact(&exe));
706        assert!(
707            out.contains("could not remove parked binary"),
708            "a failed cleanup must warn: {out:?}"
709        );
710        assert!(
711            out.contains("studio-worker.exe.old"),
712            "the warning must name the stuck artifact: {out:?}"
713        );
714    }
715
716    #[test]
717    fn parse_tag_accepts_v_prefix_and_bare() {
718        assert_eq!(parse_tag("v1.2.3"), Some(Version::new(1, 2, 3)));
719        assert_eq!(parse_tag("1.2.3"), Some(Version::new(1, 2, 3)));
720        assert!(parse_tag("garbage").is_none());
721    }
722
723    #[test]
724    fn parse_tag_accepts_component_prefixed_release_tags() {
725        // release-please / cargo-dist tag the repo as
726        // `studio-worker-v<semver>`; the updater must read the version
727        // out of that or it never sees a newer release (the bug that
728        // made `check for updates` always say "up to date").
729        assert_eq!(
730            parse_tag("studio-worker-v0.4.2"),
731            Some(Version::new(0, 4, 2))
732        );
733        assert_eq!(
734            parse_tag("studio-worker-v1.10.0"),
735            Some(Version::new(1, 10, 0))
736        );
737        // Prerelease suffix survives (the version's own `-` is not the
738        // component separator).
739        assert_eq!(
740            parse_tag("studio-worker-v0.5.0-rc.1"),
741            Version::parse("0.5.0-rc.1").ok()
742        );
743    }
744
745    #[test]
746    fn decide_detects_newer_with_component_prefixed_tags() {
747        // The exact shape of the live feed: `studio-worker-v*` tags.
748        let releases = vec![
749            rel("studio-worker-v0.4.1", false, false, true),
750            rel("studio-worker-v0.4.2", false, false, true),
751        ];
752        let outcome = decide(&releases, &Version::new(0, 4, 1), false);
753        assert_eq!(
754            outcome,
755            CheckOutcome::NewerAvailable {
756                current: Version::new(0, 4, 1),
757                latest: Version::new(0, 4, 2),
758            }
759        );
760    }
761
762    #[test]
763    fn parse_releases_accepts_array() {
764        let text = serde_json::to_string(&serde_json::json!([
765            { "tag_name": "v1.0.0", "prerelease": false, "draft": false, "assets": [] }
766        ]))
767        .unwrap();
768        let releases = parse_releases(&text).unwrap();
769        assert_eq!(releases.len(), 1);
770        assert_eq!(releases[0].tag_name, "v1.0.0");
771    }
772
773    #[test]
774    fn parse_releases_accepts_single_object() {
775        let text = serde_json::to_string(&serde_json::json!({
776            "tag_name": "v2.0.0", "prerelease": false, "draft": false, "assets": []
777        }))
778        .unwrap();
779        let releases = parse_releases(&text).unwrap();
780        assert_eq!(releases.len(), 1);
781        assert_eq!(releases[0].tag_name, "v2.0.0");
782    }
783
784    #[test]
785    fn parse_releases_errors_on_garbage() {
786        assert!(parse_releases("not json").is_err());
787    }
788
789    #[test]
790    fn decide_reports_up_to_date_when_no_newer() {
791        let releases = vec![rel("v0.1.0", false, false, true)];
792        let outcome = decide(&releases, &Version::new(0, 1, 0), false);
793        assert_eq!(
794            outcome,
795            CheckOutcome::UpToDate {
796                current: Version::new(0, 1, 0)
797            }
798        );
799    }
800
801    #[test]
802    fn decide_reports_newer_when_higher_present() {
803        let releases = vec![
804            rel("v0.1.0", false, false, true),
805            rel("v0.2.0", false, false, true),
806        ];
807        let outcome = decide(&releases, &Version::new(0, 1, 0), false);
808        assert_eq!(
809            outcome,
810            CheckOutcome::NewerAvailable {
811                current: Version::new(0, 1, 0),
812                latest: Version::new(0, 2, 0),
813            }
814        );
815    }
816
817    #[test]
818    fn decide_skips_prereleases_unless_opted_in() {
819        let releases = vec![
820            rel("v0.1.0", false, false, true),
821            rel("v0.3.0-rc.1", true, false, true),
822        ];
823        let outcome = decide(&releases, &Version::new(0, 1, 0), false);
824        assert!(matches!(outcome, CheckOutcome::UpToDate { .. }));
825        let outcome = decide(&releases, &Version::new(0, 1, 0), true);
826        assert!(matches!(outcome, CheckOutcome::NewerAvailable { .. }));
827    }
828
829    #[test]
830    fn decide_skips_drafts() {
831        let releases = vec![
832            rel("v0.1.0", false, false, true),
833            rel("v0.9.0", false, true, true),
834        ];
835        let outcome = decide(&releases, &Version::new(0, 1, 0), false);
836        assert!(matches!(outcome, CheckOutcome::UpToDate { .. }));
837    }
838
839    #[test]
840    fn decide_handles_empty_feed() {
841        let outcome = decide(&[], &Version::new(1, 0, 0), false);
842        assert!(matches!(outcome, CheckOutcome::UpToDate { .. }));
843    }
844
845    #[test]
846    fn decide_skips_malformed_tags() {
847        let releases = vec![
848            rel("garbage", false, false, true),
849            rel("v0.1.0", false, false, true),
850        ];
851        let outcome = decide(&releases, &Version::new(0, 0, 1), false);
852        match outcome {
853            CheckOutcome::NewerAvailable { latest, .. } => {
854                assert_eq!(latest, Version::new(0, 1, 0))
855            }
856            _ => panic!("expected newer"),
857        }
858    }
859
860    #[test]
861    fn decide_warns_on_each_unparseable_candidate_tag() {
862        // A non-draft, non-prerelease release whose tag can't be parsed
863        // as a version is dropped from the update check (preserving the
864        // existing behaviour) but warn-logged with the offending tag, so
865        // a tag-format drift that silently strands the worker on an old
866        // build leaves a breadcrumb instead of vanishing without a trace.
867        let logs = crate::test_support::capture(|| {
868            let releases = vec![
869                rel("totally-not-a-version", false, false, true),
870                rel("studio-worker-v0.1.0", false, false, true),
871            ];
872            let _ = decide(&releases, &Version::new(0, 0, 1), false);
873        });
874        assert!(
875            logs.contains("studio_worker::update"),
876            "expected update target, got: {logs}"
877        );
878        assert!(logs.contains("WARN"), "expected WARN level, got: {logs}");
879        assert!(
880            logs.contains("totally-not-a-version"),
881            "expected the offending tag in the warn, got: {logs}"
882        );
883    }
884
885    #[test]
886    fn decide_breadcrumb_reports_dropped_count() {
887        // The decision breadcrumb carries the number of candidate tags
888        // dropped so a feed that under-reports its versions can't pass
889        // for a fully-evaluated one.
890        let logs = crate::test_support::capture(|| {
891            let releases = vec![
892                rel("garbage", false, false, true),
893                rel("also-bad", false, false, true),
894                rel("studio-worker-v0.2.0", false, false, true),
895            ];
896            let _ = decide(&releases, &Version::new(0, 1, 0), false);
897        });
898        assert!(
899            logs.contains("dropped=2"),
900            "expected dropped=2 in the breadcrumb, got: {logs}"
901        );
902    }
903
904    #[test]
905    fn decide_does_not_count_filtered_out_releases_as_dropped() {
906        // Drafts and (when not opted in) prereleases are intentionally
907        // excluded before the parse step, so an unparseable tag on one of
908        // those is not a lost candidate and must not be warn-logged or
909        // counted as dropped.
910        let logs = crate::test_support::capture(|| {
911            let releases = vec![
912                rel("draft-garbage", false, true, true),
913                rel("prerelease-garbage", true, false, true),
914                rel("studio-worker-v0.2.0", false, false, true),
915            ];
916            let _ = decide(&releases, &Version::new(0, 1, 0), false);
917        });
918        assert!(
919            logs.contains("dropped=0"),
920            "filtered-out releases must not count as dropped, got: {logs}"
921        );
922        assert!(
923            !logs.contains("draft-garbage"),
924            "a filtered draft must not warn, got: {logs}"
925        );
926        assert!(
927            !logs.contains("prerelease-garbage"),
928            "a filtered prerelease must not warn, got: {logs}"
929        );
930    }
931
932    #[test]
933    fn installer_asset_name_matches_platform() {
934        let name = installer_asset_name();
935        if cfg!(target_os = "windows") {
936            assert_eq!(name, "studio-worker-installer.ps1");
937        } else {
938            assert_eq!(name, "studio-worker-installer.sh");
939        }
940    }
941
942    #[test]
943    fn resolve_installer_url_finds_the_right_asset() {
944        let release = rel("v1.0.0", false, false, true);
945        let url = resolve_installer_url(&release).unwrap();
946        assert_eq!(url, "https://example.com/v1.0.0");
947    }
948
949    #[test]
950    fn resolve_installer_url_returns_none_when_missing() {
951        let release = rel("v1.0.0", false, false, false);
952        assert!(resolve_installer_url(&release).is_none());
953    }
954
955    // -----------------------------------------------------------------
956    // verify_download_len — guards the installer download against a
957    // short read before the bytes are handed to `sh` / `powershell`.
958    // A truncated installer that runs is far worse than a failed
959    // update, so a Content-Length mismatch must surface as an error.
960    // -----------------------------------------------------------------
961
962    #[test]
963    fn verify_download_len_accepts_exact_match() {
964        assert!(verify_download_len(2048, Some(2048)).is_ok());
965    }
966
967    #[test]
968    fn verify_download_len_accepts_when_length_unknown() {
969        // Chunked transfers omit Content-Length; nothing to check, so
970        // we accept whatever streamed in (same as before this guard).
971        assert!(verify_download_len(123, None).is_ok());
972    }
973
974    #[test]
975    fn verify_download_len_rejects_truncated_installer() {
976        let err = verify_download_len(40, Some(100)).unwrap_err().to_string();
977        assert!(err.contains("size mismatch"), "got: {err}");
978        assert!(err.contains("40"), "got: {err}");
979        assert!(err.contains("100"), "got: {err}");
980    }
981
982    #[test]
983    fn verify_download_len_rejects_overlong_installer() {
984        // A body longer than the declared length is just as corrupt as
985        // a short one — reject both rather than run a bad installer.
986        assert!(verify_download_len(120, Some(100)).is_err());
987    }
988
989    #[test]
990    fn validate_installer_download_url_allows_https() {
991        validate_installer_download_url("https://github.com/owner/repo/releases/download/x/i.sh")
992            .unwrap();
993    }
994
995    #[test]
996    fn validate_installer_download_url_allows_loopback_http_for_tests() {
997        validate_installer_download_url("http://127.0.0.1:1234/i.sh").unwrap();
998        validate_installer_download_url("http://localhost:1234/i.sh").unwrap();
999    }
1000
1001    #[test]
1002    fn validate_installer_download_url_rejects_remote_http() {
1003        let err = validate_installer_download_url("http://example.com/i.sh")
1004            .unwrap_err()
1005            .to_string();
1006        assert!(err.contains("https"), "got: {err}");
1007    }
1008
1009    #[test]
1010    fn validate_installer_download_url_rejects_non_http_schemes() {
1011        // The gate must reject anything that isn't https (or loopback
1012        // http) *before* the auto-updater downloads and executes the
1013        // asset.  These schemes take a different path through the guard
1014        // than `http://example.com` — they skip the `http` block
1015        // entirely and fall straight to the bail — so they need their
1016        // own cover.  `file://` is the dangerous one: a compromised
1017        // release feed handing back `file:///etc/cron.d/evil.sh` would,
1018        // without this guard, point the installer runner at an arbitrary
1019        // local script.  `ftp://` is unencrypted (tamperable in
1020        // transit) and `javascript:` carries no host at all.
1021        for raw in [
1022            "file:///etc/cron.d/evil.sh",
1023            "ftp://example.com/i.sh",
1024            "javascript:alert(1)",
1025        ] {
1026            let err = validate_installer_download_url(raw)
1027                .unwrap_err()
1028                .to_string();
1029            assert!(
1030                err.contains("https"),
1031                "{raw} must be rejected with the https guidance, got: {err}"
1032            );
1033        }
1034    }
1035
1036    #[test]
1037    fn validate_installer_download_url_rejects_a_malformed_url() {
1038        // A feed entry that doesn't parse as a URL at all must error at
1039        // the parse step (carrying the `invalid installer URL` context)
1040        // rather than slipping through to a download attempt.
1041        let err = validate_installer_download_url("not a url")
1042            .unwrap_err()
1043            .to_string();
1044        assert!(
1045            err.contains("invalid installer URL"),
1046            "a malformed URL must surface the parse context, got: {err}"
1047        );
1048    }
1049
1050    // -----------------------------------------------------------------
1051    // RealRunner::run_installer — the production path that hands the
1052    // downloaded installer to `sh` (unix) / PowerShell (Windows).  The
1053    // unix branch is exercised here against trivial scripts so the
1054    // safety property is locked in: a non-zero installer exit MUST
1055    // bail, never report success.  Tests elsewhere drive `apply_with`
1056    // through a fake runner, so without this the real subprocess
1057    // dispatch shipped untested.
1058    // -----------------------------------------------------------------
1059
1060    #[cfg(unix)]
1061    #[test]
1062    fn real_runner_run_installer_succeeds_on_zero_exit() {
1063        let dir = tempdir().unwrap();
1064        let script = dir.path().join("installer.sh");
1065        // `sh <path>` reads the file directly, so no shebang or +x bit
1066        // is needed.
1067        std::fs::write(&script, "exit 0\n").unwrap();
1068        RealRunner.run_installer(&script).unwrap();
1069    }
1070
1071    #[cfg(unix)]
1072    #[test]
1073    fn real_runner_run_installer_bails_on_nonzero_exit() {
1074        let dir = tempdir().unwrap();
1075        let script = dir.path().join("installer.sh");
1076        std::fs::write(&script, "exit 3\n").unwrap();
1077        let err = RealRunner.run_installer(&script).unwrap_err().to_string();
1078        assert!(
1079            err.contains("installer exited"),
1080            "a failed installer must surface a clear error, got: {err}"
1081        );
1082    }
1083
1084    #[test]
1085    fn restart_argv_uses_current_exe_and_args() {
1086        let (bin, _args) = restart_argv();
1087        assert!(!bin.as_os_str().is_empty());
1088    }
1089
1090    // -----------------------------------------------------------------
1091    // apply_with — exercised via a fake runner that records calls.
1092    // -----------------------------------------------------------------
1093
1094    struct FakeRunner {
1095        downloaded: RefCell<Vec<(String, PathBuf)>>,
1096        ran: RefCell<Vec<PathBuf>>,
1097        fail_download: bool,
1098        fail_run: bool,
1099    }
1100
1101    impl UpdateRunner for FakeRunner {
1102        fn download(&self, url: &str, dest: &Path) -> Result<()> {
1103            self.downloaded
1104                .borrow_mut()
1105                .push((url.to_string(), dest.to_path_buf()));
1106            if self.fail_download {
1107                bail!("simulated download failure");
1108            }
1109            // Touch the file so apply's runner contract is satisfied.
1110            std::fs::write(dest, b"#!/bin/sh\necho fake installer\n").unwrap();
1111            Ok(())
1112        }
1113        fn run_installer(&self, installer_path: &Path) -> Result<()> {
1114            self.ran.borrow_mut().push(installer_path.to_path_buf());
1115            if self.fail_run {
1116                bail!("simulated installer failure");
1117            }
1118            Ok(())
1119        }
1120    }
1121
1122    fn write_fixture_feed(dir: &tempfile::TempDir, releases: serde_json::Value) -> String {
1123        let path = dir.path().join("releases.json");
1124        std::fs::write(&path, releases.to_string()).unwrap();
1125        format!("file://{}", path.to_string_lossy())
1126    }
1127
1128    fn fake_release_with_installer(tag: &str) -> serde_json::Value {
1129        serde_json::json!({
1130            "tag_name": tag,
1131            "prerelease": false,
1132            "draft": false,
1133            "assets": [{
1134                "name": installer_asset_name(),
1135                "browser_download_url": format!("https://example.invalid/{tag}/{}", installer_asset_name()),
1136            }],
1137        })
1138    }
1139
1140    // The reqwest blocking client doesn't follow `file://` URLs, so we
1141    // use wiremock-served feeds for the apply tests via the integration
1142    // suite (`tests/auto_update.rs`).  Here we just verify the unit-test
1143    // branches: missing release, missing asset.
1144    #[test]
1145    fn apply_with_errors_when_release_missing() {
1146        // Static fixture parsed via parse_releases bypasses HTTP for this
1147        // narrow test.  We can't call apply_with without a real HTTP fetch
1148        // since fetch_releases is HTTP only — but we can drive the
1149        // post-fetch branches directly.
1150        let releases: Vec<GithubRelease> = vec![rel("v0.1.0", false, false, true)];
1151        let missing = Version::new(9, 9, 9);
1152        let url = releases
1153            .iter()
1154            .find(|r| parse_tag(&r.tag_name).as_ref() == Some(&missing));
1155        assert!(url.is_none(), "v9.9.9 should not be in the fixture");
1156    }
1157
1158    // Sanity: we can write a fake feed file (used by integration tests).
1159    #[test]
1160    fn writing_a_fake_feed_round_trips_through_parse_releases() {
1161        let dir = tempdir().unwrap();
1162        let url = write_fixture_feed(
1163            &dir,
1164            serde_json::json!([fake_release_with_installer("v0.1.0")]),
1165        );
1166        let _ = url;
1167        let text = std::fs::read_to_string(dir.path().join("releases.json")).unwrap();
1168        let releases = parse_releases(&text).unwrap();
1169        assert_eq!(releases.len(), 1);
1170        assert_eq!(releases[0].tag_name, "v0.1.0");
1171    }
1172
1173    #[test]
1174    fn fake_runner_records_download_and_run() {
1175        let runner = FakeRunner {
1176            downloaded: RefCell::new(Vec::new()),
1177            ran: RefCell::new(Vec::new()),
1178            fail_download: false,
1179            fail_run: false,
1180        };
1181        let dir = tempdir().unwrap();
1182        let dest = dir.path().join("installer.sh");
1183        runner.download("https://example.com/a", &dest).unwrap();
1184        runner.run_installer(&dest).unwrap();
1185        assert_eq!(runner.downloaded.borrow().len(), 1);
1186        assert_eq!(runner.ran.borrow().len(), 1);
1187        assert!(dest.exists());
1188    }
1189
1190    #[test]
1191    fn fake_runner_surfaces_download_errors() {
1192        let runner = FakeRunner {
1193            downloaded: RefCell::new(Vec::new()),
1194            ran: RefCell::new(Vec::new()),
1195            fail_download: true,
1196            fail_run: false,
1197        };
1198        let dir = tempdir().unwrap();
1199        let dest = dir.path().join("installer.sh");
1200        let err = runner.download("https://example.com/a", &dest).unwrap_err();
1201        assert!(err.to_string().contains("simulated download"));
1202    }
1203
1204    #[test]
1205    fn fake_runner_surfaces_install_errors() {
1206        let runner = FakeRunner {
1207            downloaded: RefCell::new(Vec::new()),
1208            ran: RefCell::new(Vec::new()),
1209            fail_download: false,
1210            fail_run: true,
1211        };
1212        let dir = tempdir().unwrap();
1213        let dest = dir.path().join("installer.sh");
1214        let err = runner.run_installer(&dest).unwrap_err();
1215        assert!(err.to_string().contains("simulated installer"));
1216    }
1217}