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 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 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 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
285pub 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
297pub 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#[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
326pub struct ExeReplaceGuard {
334 original: PathBuf,
335 parked: PathBuf,
336}
337
338impl ExeReplaceGuard {
339 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 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 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 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 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 }
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
474pub 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#[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 #[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 assert!(guard.confirm_replaced().is_err());
578
579 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 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 cleanup_parked_artifact(&exe);
640 }
641
642 #[test]
643 fn park_surfaces_a_rename_failure_with_actionable_context() {
644 let dir = tempdir().unwrap();
651 let missing = dir.path().join("studio-worker.exe");
652 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 #[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 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 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 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 #[test]
1145 fn apply_with_errors_when_release_missing() {
1146 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 #[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}