1use 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
17const 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
28pub 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
66pub 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
76pub 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
91pub 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
98pub fn decide(releases: &[GithubRelease], current: &Version, prerelease_ok: bool) -> CheckOutcome {
101 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
152pub 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
161pub 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
172fn 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
190pub fn apply(feed_url: &str, latest: &Version) -> Result<()> {
193 apply_with(feed_url, latest, &RealRunner)
194}
195
196pub trait UpdateRunner {
200 fn download(&self, url: &str, dest: &Path) -> Result<()>;
201 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 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 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
289const ALLOWED_INSTALLER_HOSTS: [&str; 2] = ["github.com", "objects.githubusercontent.com"];
296
297fn validate_installer_download_url(raw: &str) -> Result<()> {
298 crate::net::validate_download_url(raw, "installer")?;
300 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
321pub 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
331pub 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
352pub 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
364pub 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#[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
393pub struct ExeReplaceGuard {
401 original: PathBuf,
402 parked: PathBuf,
403}
404
405impl ExeReplaceGuard {
406 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 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 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 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 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 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 }
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
569pub 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#[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 #[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 assert!(guard.confirm_replaced().is_err());
673
674 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 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 cleanup_parked_artifact(&exe);
735 }
736
737 #[test]
738 fn park_surfaces_a_rename_failure_with_actionable_context() {
739 let dir = tempdir().unwrap();
746 let missing = dir.path().join("studio-worker.exe");
747 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 #[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 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 #[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 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 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 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 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 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 #[test]
1338 fn apply_with_errors_when_release_missing() {
1339 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 #[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}