1use anyhow::{Context, Result};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18use std::fs;
19use std::io::Write;
20use std::path::{Path, PathBuf};
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28pub struct VersionInfo {
29 pub version: String,
30 pub schema_version: u32,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35pub struct ReleaseInfo {
36 pub version: String,
37 pub download_url: String,
38 pub checksum: String,
39 pub min_runtime_version: String,
41 pub schema_version: u32,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct DownloadResult {
53 pub binary_data: Vec<u8>,
54 pub integrity_verified: bool,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
59pub struct UpgradeResult {
60 pub previous_version: String,
61 pub new_version: String,
62 pub status: UpgradeStatus,
63 pub backup_path: PathBuf,
65}
66
67#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
69#[serde(rename_all = "snake_case")]
70pub enum UpgradeStatus {
71 Success,
73 RolledBack,
75 Refused,
77}
78
79#[derive(Debug, Clone, Default)]
81pub struct UpgradeOptions {
82 pub non_interactive: bool,
84 pub binary_path: Option<PathBuf>,
86 pub backup_dir: Option<PathBuf>,
88 pub cached_manifest_path: Option<PathBuf>,
90}
91
92#[derive(Debug, Clone, PartialEq)]
98pub enum UpgradeError {
99 IncompatibleRuntime { current: String, required: String },
101 RuntimeTooOld {
103 current_schema: u32,
104 manifest_schema: u32,
105 },
106 IntegrityCheckFailed { expected: String, actual: String },
108 SmokeTestFailed { reason: String },
110 DownloadFailed { reason: String },
112 Declined,
114 NoUpgradeAvailable { current: String, latest: String },
116 IoError { path: String, reason: String },
118}
119
120impl std::fmt::Display for UpgradeError {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 match self {
123 UpgradeError::IncompatibleRuntime { current, required } => {
124 write!(
125 f,
126 "incompatible runtime: current {current} does not meet required {required}"
127 )
128 }
129 UpgradeError::RuntimeTooOld {
130 current_schema,
131 manifest_schema,
132 } => {
133 write!(
134 f,
135 "runtime too old: current schema v{current_schema} cannot parse manifest schema v{manifest_schema}; manual upgrade required"
136 )
137 }
138 UpgradeError::IntegrityCheckFailed { expected, actual } => {
139 write!(
140 f,
141 "integrity check failed: expected checksum {expected}, got {actual}"
142 )
143 }
144 UpgradeError::SmokeTestFailed { reason } => {
145 write!(f, "smoke test failed: {reason}")
146 }
147 UpgradeError::DownloadFailed { reason } => {
148 write!(f, "download failed: {reason}")
149 }
150 UpgradeError::Declined => {
151 write!(f, "upgrade declined by user")
152 }
153 UpgradeError::NoUpgradeAvailable { current, latest } => {
154 write!(
155 f,
156 "no upgrade available: current version {current} is already >= latest {latest}"
157 )
158 }
159 UpgradeError::IoError { path, reason } => {
160 write!(f, "I/O error at {path}: {reason}")
161 }
162 }
163 }
164}
165
166impl std::error::Error for UpgradeError {}
167
168pub trait ReleaseProvider {
175 fn fetch_latest_release(&self) -> std::result::Result<ReleaseInfo, UpgradeError>;
177}
178
179pub trait BinaryDownloader {
182 fn download(&self, release: &ReleaseInfo) -> std::result::Result<DownloadResult, UpgradeError>;
184}
185
186pub trait SmokeTester {
189 fn test(&self, binary_path: &Path) -> std::result::Result<(), UpgradeError>;
192}
193
194pub trait UserInteractor {
197 fn confirm_upgrade(&self, current: &str, target: &str) -> bool;
200}
201
202fn parse_version_parts(version: &str) -> Option<(u32, u32, u32)> {
208 let base = version.split('-').next()?;
209 let parts: Vec<&str> = base.split('.').collect();
210 if parts.len() != 3 {
211 return None;
212 }
213 Some((
214 parts[0].parse().ok()?,
215 parts[1].parse().ok()?,
216 parts[2].parse().ok()?,
217 ))
218}
219
220pub fn check_version_compatibility(
223 current: &str,
224 required: &str,
225) -> std::result::Result<(), UpgradeError> {
226 let cur = parse_version_parts(current).unwrap_or((0, 0, 0));
227 let req = parse_version_parts(required).unwrap_or((0, 0, 0));
228
229 if cur < req {
230 return Err(UpgradeError::IncompatibleRuntime {
231 current: current.to_string(),
232 required: required.to_string(),
233 });
234 }
235 Ok(())
236}
237
238pub fn check_schema_compatibility(
241 current_schema: u32,
242 manifest_schema: u32,
243) -> std::result::Result<(), UpgradeError> {
244 if current_schema < manifest_schema {
247 return Err(UpgradeError::RuntimeTooOld {
248 current_schema,
249 manifest_schema,
250 });
251 }
252 Ok(())
253}
254
255pub fn compute_checksum(data: &[u8]) -> String {
261 let mut hasher = Sha256::new();
262 hasher.update(data);
263 let result = hasher.finalize();
264 result
265 .iter()
266 .map(|b| format!("{:02x}", b))
267 .collect::<String>()
268}
269
270pub fn verify_integrity(
272 data: &[u8],
273 expected_checksum: &str,
274) -> std::result::Result<(), UpgradeError> {
275 let actual = compute_checksum(data);
276 if actual == expected_checksum {
277 Ok(())
278 } else {
279 Err(UpgradeError::IntegrityCheckFailed {
280 expected: expected_checksum.to_string(),
281 actual,
282 })
283 }
284}
285
286pub fn default_backup_dir() -> PathBuf {
292 dirs::home_dir()
293 .unwrap_or_else(|| PathBuf::from("/tmp"))
294 .join(".mlstack")
295 .join("backups")
296}
297
298pub fn default_cached_manifest_path() -> PathBuf {
300 dirs::home_dir()
301 .unwrap_or_else(|| PathBuf::from("/tmp"))
302 .join(".mlstack")
303 .join("cache")
304 .join("remote_manifest.json")
305}
306
307pub fn current_binary_path() -> Result<PathBuf> {
309 std::env::current_exe().context("failed to determine current executable path")
310}
311
312pub fn create_backup(source: &Path, backup_dir: &Path, version: &str) -> Result<PathBuf> {
317 fs::create_dir_all(backup_dir).with_context(|| {
318 format!(
319 "failed to create backup directory: {}",
320 backup_dir.display()
321 )
322 })?;
323
324 let backup_name = format!("rusty-stack-{version}.bak");
325 let backup_path = backup_dir.join(&backup_name);
326
327 fs::copy(source, &backup_path).with_context(|| {
328 format!(
329 "failed to backup binary from {} to {}",
330 source.display(),
331 backup_path.display()
332 )
333 })?;
334
335 Ok(backup_path)
336}
337
338pub fn swap_binary(binary_path: &Path, new_data: &[u8]) -> Result<()> {
345 let old_path = binary_path.with_extension("old");
346
347 if binary_path.exists() {
349 fs::rename(binary_path, &old_path).with_context(|| {
350 format!(
351 "failed to rename {} to {}",
352 binary_path.display(),
353 old_path.display()
354 )
355 })?;
356 }
357
358 let write_result = (|| -> Result<()> {
360 let mut f = fs::File::create(binary_path)
361 .with_context(|| format!("failed to create new binary at {}", binary_path.display()))?;
362 f.write_all(new_data)
363 .with_context(|| format!("failed to write new binary at {}", binary_path.display()))?;
364
365 #[cfg(unix)]
367 {
368 use std::os::unix::fs::PermissionsExt;
369 let mut perms = fs::metadata(binary_path)?.permissions();
370 perms.set_mode(0o755);
371 fs::set_permissions(binary_path, perms)?;
372 }
373
374 Ok(())
375 })();
376
377 if let Err(e) = write_result {
378 if old_path.exists() {
380 let _ = fs::rename(&old_path, binary_path);
381 }
382 return Err(e);
383 }
384
385 if old_path.exists() {
387 let _ = fs::remove_file(&old_path);
388 }
389
390 Ok(())
391}
392
393pub fn rollback(binary_path: &Path, backup_path: &Path) -> Result<()> {
397 fs::copy(backup_path, binary_path).with_context(|| {
398 format!(
399 "failed to rollback binary from {} to {}",
400 backup_path.display(),
401 binary_path.display()
402 )
403 })?;
404
405 #[cfg(unix)]
407 {
408 use std::os::unix::fs::PermissionsExt;
409 let mut perms = fs::metadata(binary_path)?.permissions();
410 perms.set_mode(0o755);
411 fs::set_permissions(binary_path, perms)?;
412 }
413
414 Ok(())
415}
416
417pub fn read_cached_manifest(path: &Path) -> Option<Vec<u8>> {
423 fs::read(path).ok()
424}
425
426pub fn write_cached_manifest(path: &Path, data: &[u8]) -> Result<()> {
428 if let Some(parent) = path.parent() {
429 fs::create_dir_all(parent)
430 .with_context(|| format!("failed to create cache directory: {}", parent.display()))?;
431 }
432 fs::write(path, data)
433 .with_context(|| format!("failed to write cached manifest to {}", path.display()))
434}
435
436pub fn run_upgrade(
454 current_version: &VersionInfo,
455 options: &UpgradeOptions,
456 release_provider: &dyn ReleaseProvider,
457 downloader: &dyn BinaryDownloader,
458 smoke_tester: &dyn SmokeTester,
459 interactor: &dyn UserInteractor,
460) -> std::result::Result<UpgradeResult, UpgradeError> {
461 let release = release_provider.fetch_latest_release()?;
463
464 if !is_release_newer(¤t_version.version, &release.version) {
466 return Err(UpgradeError::NoUpgradeAvailable {
467 current: current_version.version.clone(),
468 latest: release.version.clone(),
469 });
470 }
471
472 check_schema_compatibility(current_version.schema_version, release.schema_version)?;
474
475 check_version_compatibility(¤t_version.version, &release.min_runtime_version)?;
477
478 if !options.non_interactive {
480 let confirmed = interactor.confirm_upgrade(¤t_version.version, &release.version);
481 if !confirmed {
482 return Err(UpgradeError::Declined);
483 }
484 }
485
486 let download = downloader.download(&release)?;
488
489 if !download.integrity_verified {
491 verify_integrity(&download.binary_data, &release.checksum)?;
492 }
493 let new_binary_data = download.binary_data;
494
495 let cached_manifest_path = options
497 .cached_manifest_path
498 .clone()
499 .unwrap_or_else(default_cached_manifest_path);
500 let cached_manifest_data = read_cached_manifest(&cached_manifest_path);
501
502 let binary_path = options
504 .binary_path
505 .clone()
506 .unwrap_or_else(|| current_binary_path().unwrap_or_else(|_| PathBuf::from("rusty-stack")));
507 let backup_dir = options
508 .backup_dir
509 .clone()
510 .unwrap_or_else(default_backup_dir);
511
512 let backup_path =
514 create_backup(&binary_path, &backup_dir, ¤t_version.version).map_err(|e| {
515 UpgradeError::IoError {
516 path: binary_path.display().to_string(),
517 reason: e.to_string(),
518 }
519 })?;
520
521 swap_binary(&binary_path, &new_binary_data).map_err(|e| UpgradeError::IoError {
523 path: binary_path.display().to_string(),
524 reason: e.to_string(),
525 })?;
526
527 match smoke_tester.test(&binary_path) {
529 Ok(()) => {
530 if let Some(data) = cached_manifest_data {
532 let _ = write_cached_manifest(&cached_manifest_path, &data);
533 }
534
535 Ok(UpgradeResult {
536 previous_version: current_version.version.clone(),
537 new_version: release.version.clone(),
538 status: UpgradeStatus::Success,
539 backup_path,
540 })
541 }
542 Err(smoke_err) => {
543 let _ = rollback(&binary_path, &backup_path);
545
546 if let Some(data) = cached_manifest_data {
548 let _ = write_cached_manifest(&cached_manifest_path, &data);
549 }
550
551 Err(UpgradeError::SmokeTestFailed {
552 reason: smoke_err.to_string(),
553 })
554 }
555 }
556}
557
558fn is_release_newer(current: &str, latest: &str) -> bool {
559 let cur = parse_version_parts(current).unwrap_or((0, 0, 0));
560 let lat = parse_version_parts(latest).unwrap_or((0, 0, 0));
561 lat > cur
562}
563
564#[cfg(test)]
569mod tests {
570 use super::*;
571 use std::sync::atomic::{AtomicBool, Ordering};
572
573 #[test]
576 fn test_version_compat_equal_versions() {
577 assert!(check_version_compatibility("1.0.0", "1.0.0").is_ok());
578 }
579
580 #[test]
581 fn test_version_compat_newer_current() {
582 assert!(check_version_compatibility("2.0.0", "1.0.0").is_ok());
583 }
584
585 #[test]
586 fn test_version_compat_older_current_refused() {
587 let result = check_version_compatibility("1.0.0", "2.0.0");
588 assert!(result.is_err());
589 match result.unwrap_err() {
590 UpgradeError::IncompatibleRuntime { current, required } => {
591 assert_eq!(current, "1.0.0");
592 assert_eq!(required, "2.0.0");
593 }
594 other => panic!("expected IncompatibleRuntime, got {other:?}"),
595 }
596 }
597
598 #[test]
599 fn test_version_compat_minor_older_refused() {
600 let result = check_version_compatibility("1.0.0", "1.1.0");
601 assert!(result.is_err());
602 }
603
604 #[test]
605 fn test_version_compat_patch_newer_ok() {
606 assert!(check_version_compatibility("1.0.1", "1.0.0").is_ok());
607 }
608
609 #[test]
610 fn test_schema_compat_equal() {
611 assert!(check_schema_compatibility(2, 2).is_ok());
612 }
613
614 #[test]
615 fn test_schema_compat_newer_runtime_ok() {
616 assert!(check_schema_compatibility(3, 2).is_ok());
617 }
618
619 #[test]
620 fn test_schema_compat_older_runtime_refused() {
621 let result = check_schema_compatibility(1, 3);
622 assert!(result.is_err());
623 match result.unwrap_err() {
624 UpgradeError::RuntimeTooOld {
625 current_schema,
626 manifest_schema,
627 } => {
628 assert_eq!(current_schema, 1);
629 assert_eq!(manifest_schema, 3);
630 }
631 other => panic!("expected RuntimeTooOld, got {other:?}"),
632 }
633 }
634
635 #[test]
638 fn test_checksum_valid() {
639 let data = b"hello world";
640 let expected = compute_checksum(data);
641 assert!(verify_integrity(data, &expected).is_ok());
642 }
643
644 #[test]
645 fn test_checksum_tampered_binary_rejected() {
646 let data = b"original binary content";
647 let expected = compute_checksum(data);
648 let tampered = b"tampered binary content";
649 let result = verify_integrity(tampered, &expected);
650 assert!(result.is_err());
651 match result.unwrap_err() {
652 UpgradeError::IntegrityCheckFailed { .. } => {}
653 other => panic!("expected IntegrityCheckFailed, got {other:?}"),
654 }
655 }
656
657 #[test]
658 fn test_checksum_empty_data() {
659 let data = b"";
660 let expected = compute_checksum(data);
661 assert!(verify_integrity(data, &expected).is_ok());
662 }
663
664 #[test]
667 fn test_backup_creates_file() {
668 let dir = tempfile::tempdir().unwrap();
669 let source = dir.path().join("rusty-stack");
670 fs::write(&source, b"binary content").unwrap();
671
672 let backup_dir = dir.path().join("backups");
673 let backup = create_backup(&source, &backup_dir, "0.1.0").unwrap();
674
675 assert!(backup.exists());
676 assert_eq!(fs::read(&backup).unwrap(), b"binary content".to_vec());
677 assert_eq!(backup.file_name().unwrap(), "rusty-stack-0.1.0.bak");
678 }
679
680 #[test]
681 fn test_backup_preserves_content() {
682 let dir = tempfile::tempdir().unwrap();
683 let source = dir.path().join("rusty-stack");
684 let content = b"\x00\x01\x02\xff binary data with null bytes";
685 fs::write(&source, content).unwrap();
686
687 let backup_dir = dir.path().join("backups");
688 let backup = create_backup(&source, &backup_dir, "1.2.3").unwrap();
689
690 let backup_content = fs::read(&backup).unwrap();
691 assert_eq!(backup_content, content.to_vec());
692 }
693
694 #[test]
695 fn test_rollback_restores_binary() {
696 let dir = tempfile::tempdir().unwrap();
697 let binary_path = dir.path().join("rusty-stack");
698 let backup_path = dir.path().join("rusty-stack-0.1.0.bak");
699
700 let original_content = b"original binary";
701 let new_content = b"new binary that fails smoke test";
702
703 fs::write(&binary_path, new_content).unwrap();
704 fs::write(&backup_path, original_content).unwrap();
705
706 rollback(&binary_path, &backup_path).unwrap();
707
708 let restored = fs::read(&binary_path).unwrap();
709 assert_eq!(restored, original_content.to_vec());
710 }
711
712 #[test]
715 fn test_swap_binary_replaces_content() {
716 let dir = tempfile::tempdir().unwrap();
717 let binary_path = dir.path().join("rusty-stack");
718 fs::write(&binary_path, b"old binary").unwrap();
719
720 let new_data = b"new binary";
721 swap_binary(&binary_path, new_data).unwrap();
722
723 let content = fs::read(&binary_path).unwrap();
724 assert_eq!(content, new_data.to_vec());
725 }
726
727 #[test]
728 fn test_swap_binary_creates_if_missing() {
729 let dir = tempfile::tempdir().unwrap();
730 let binary_path = dir.path().join("rusty-stack");
731 let new_data = b"brand new binary";
734 swap_binary(&binary_path, new_data).unwrap();
735
736 let content = fs::read(&binary_path).unwrap();
737 assert_eq!(content, new_data.to_vec());
738 }
739
740 #[test]
743 fn test_cached_manifest_roundtrip() {
744 let dir = tempfile::tempdir().unwrap();
745 let path = dir.path().join("cache").join("remote_manifest.json");
746 let data = b"{\"schema_version\": 2, \"components\": []}";
747
748 write_cached_manifest(&path, data).unwrap();
749 let read_data = read_cached_manifest(&path).unwrap();
750
751 assert_eq!(read_data, data.to_vec());
752 }
753
754 #[test]
755 fn test_cached_manifest_missing_returns_none() {
756 let path = PathBuf::from("/nonexistent/path/manifest.json");
757 assert!(read_cached_manifest(&path).is_none());
758 }
759
760 #[test]
761 fn test_cached_manifest_preserves_exact_bytes() {
762 let dir = tempfile::tempdir().unwrap();
763 let path = dir.path().join("manifest.json");
764
765 let original = b"{\n \"schema_version\": 2,\n \"components\": []\n}";
767 write_cached_manifest(&path, original).unwrap();
768
769 let restored = read_cached_manifest(&path).unwrap();
770 assert_eq!(restored, original.to_vec());
771 }
772
773 struct MockReleaseProvider {
777 release: std::result::Result<ReleaseInfo, UpgradeError>,
778 }
779
780 impl ReleaseProvider for MockReleaseProvider {
781 fn fetch_latest_release(&self) -> std::result::Result<ReleaseInfo, UpgradeError> {
782 self.release.clone()
783 }
784 }
785
786 struct MockDownloader {
788 data: std::result::Result<DownloadResult, UpgradeError>,
789 }
790
791 impl BinaryDownloader for MockDownloader {
792 fn download(&self, _release: &ReleaseInfo) -> std::result::Result<DownloadResult, UpgradeError> {
793 self.data.clone()
794 }
795 }
796
797 struct MockSmokeTester {
799 result: std::result::Result<(), UpgradeError>,
800 }
801
802 impl SmokeTester for MockSmokeTester {
803 fn test(&self, _binary_path: &Path) -> std::result::Result<(), UpgradeError> {
804 self.result.clone()
805 }
806 }
807
808 struct MockInteractor {
810 confirmed: bool,
811 }
812
813 impl UserInteractor for MockInteractor {
814 fn confirm_upgrade(&self, _current: &str, _target: &str) -> bool {
815 self.confirmed
816 }
817 }
818
819 #[allow(dead_code)]
821 struct RecordingSmokeTester {
822 called: AtomicBool,
823 result: std::result::Result<(), UpgradeError>,
824 }
825
826 impl SmokeTester for RecordingSmokeTester {
827 fn test(&self, _binary_path: &Path) -> std::result::Result<(), UpgradeError> {
828 self.called.store(true, Ordering::SeqCst);
829 self.result.clone()
830 }
831 }
832
833 fn make_release_info(version: &str, min_runtime: &str, schema: u32) -> ReleaseInfo {
834 ReleaseInfo {
835 version: version.to_string(),
836 download_url: format!("https://example.com/rusty-stack-{version}"),
837 checksum: String::new(), min_runtime_version: min_runtime.to_string(),
839 schema_version: schema,
840 }
841 }
842
843 #[test]
844 fn test_full_upgrade_success() {
845 let dir = tempfile::tempdir().unwrap();
846 let binary_path = dir.path().join("rusty-stack");
847 let backup_dir = dir.path().join("backups");
848 let cache_dir = dir.path().join("cache");
849 let cached_manifest = cache_dir.join("remote_manifest.json");
850
851 fs::write(&binary_path, b"old binary v0.1.0").unwrap();
853
854 let manifest_data = b"{\"schema_version\": 2}";
856 write_cached_manifest(&cached_manifest, manifest_data).unwrap();
857
858 let new_binary = b"new binary v0.2.0";
859 let checksum = compute_checksum(new_binary);
860
861 let mut release = make_release_info("0.2.0", "0.1.0", 2);
862 release.checksum = checksum;
863
864 let result = run_upgrade(
865 &VersionInfo {
866 version: "0.1.0".to_string(),
867 schema_version: 2,
868 },
869 &UpgradeOptions {
870 non_interactive: true,
871 binary_path: Some(binary_path.clone()),
872 backup_dir: Some(backup_dir.clone()),
873 cached_manifest_path: Some(cached_manifest.clone()),
874 },
875 &MockReleaseProvider {
876 release: Ok(release),
877 },
878 &MockDownloader {
879 data: Ok(DownloadResult {
880 binary_data: new_binary.to_vec(),
881 integrity_verified: false,
882 }),
883 },
884 &MockSmokeTester { result: Ok(()) },
885 &MockInteractor { confirmed: true },
886 );
887
888 assert!(result.is_ok(), "upgrade should succeed");
889 let upgrade_result = result.unwrap();
890 assert_eq!(upgrade_result.previous_version, "0.1.0");
891 assert_eq!(upgrade_result.new_version, "0.2.0");
892 assert_eq!(upgrade_result.status, UpgradeStatus::Success);
893 assert!(upgrade_result.backup_path.exists());
894
895 let content = fs::read(&binary_path).unwrap();
897 assert_eq!(content, new_binary.to_vec());
898
899 let restored_manifest = read_cached_manifest(&cached_manifest).unwrap();
901 assert_eq!(restored_manifest, manifest_data.to_vec());
902 }
903
904 #[test]
905 fn test_upgrade_rollback_on_smoke_test_failure() {
906 let dir = tempfile::tempdir().unwrap();
907 let binary_path = dir.path().join("rusty-stack");
908 let backup_dir = dir.path().join("backups");
909
910 let original_content = b"original binary v0.1.0";
911 fs::write(&binary_path, original_content).unwrap();
912
913 let new_binary = b"broken new binary";
914 let checksum = compute_checksum(new_binary);
915
916 let mut release = make_release_info("0.2.0", "0.1.0", 2);
917 release.checksum = checksum;
918
919 let result = run_upgrade(
920 &VersionInfo {
921 version: "0.1.0".to_string(),
922 schema_version: 2,
923 },
924 &UpgradeOptions {
925 non_interactive: true,
926 binary_path: Some(binary_path.clone()),
927 backup_dir: Some(backup_dir),
928 cached_manifest_path: None,
929 },
930 &MockReleaseProvider {
931 release: Ok(release),
932 },
933 &MockDownloader {
934 data: Ok(DownloadResult {
935 binary_data: new_binary.to_vec(),
936 integrity_verified: false,
937 }),
938 },
939 &MockSmokeTester {
940 result: Err(UpgradeError::SmokeTestFailed {
941 reason: "binary exited with code 1".to_string(),
942 }),
943 },
944 &MockInteractor { confirmed: true },
945 );
946
947 assert!(result.is_err(), "upgrade should fail on smoke test");
948 match result.unwrap_err() {
949 UpgradeError::SmokeTestFailed { reason } => {
950 assert!(reason.contains("binary exited with code 1"));
951 }
952 other => panic!("expected SmokeTestFailed, got {other:?}"),
953 }
954
955 let content = fs::read(&binary_path).unwrap();
957 assert_eq!(content, original_content.to_vec());
958 }
959
960 #[test]
961 fn test_upgrade_refused_incompatible_runtime() {
962 let dir = tempfile::tempdir().unwrap();
963 let binary_path = dir.path().join("rusty-stack");
964 fs::write(&binary_path, b"old binary").unwrap();
965
966 let release = make_release_info("2.0.0", "2.0.0", 2);
967
968 let result = run_upgrade(
969 &VersionInfo {
970 version: "1.0.0".to_string(),
971 schema_version: 2,
972 },
973 &UpgradeOptions {
974 non_interactive: true,
975 binary_path: Some(binary_path.clone()),
976 backup_dir: Some(dir.path().join("backups")),
977 cached_manifest_path: None,
978 },
979 &MockReleaseProvider {
980 release: Ok(release),
981 },
982 &MockDownloader {
983 data: Ok(DownloadResult {
984 binary_data: vec![],
985 integrity_verified: false,
986 }),
987 },
988 &MockSmokeTester { result: Ok(()) },
989 &MockInteractor { confirmed: true },
990 );
991
992 assert!(result.is_err());
993 match result.unwrap_err() {
994 UpgradeError::IncompatibleRuntime { .. } => {}
995 other => panic!("expected IncompatibleRuntime, got {other:?}"),
996 }
997
998 let content = fs::read(&binary_path).unwrap();
1000 assert_eq!(content, b"old binary".to_vec());
1001 }
1002
1003 #[test]
1004 fn test_upgrade_refused_runtime_too_old_for_manifest() {
1005 let dir = tempfile::tempdir().unwrap();
1006 let binary_path = dir.path().join("rusty-stack");
1007 fs::write(&binary_path, b"old binary v1 schema").unwrap();
1008
1009 let release = make_release_info("0.2.0", "0.1.0", 3);
1010
1011 let result = run_upgrade(
1012 &VersionInfo {
1013 version: "0.1.0".to_string(),
1014 schema_version: 1,
1015 },
1016 &UpgradeOptions {
1017 non_interactive: true,
1018 binary_path: Some(binary_path.clone()),
1019 backup_dir: Some(dir.path().join("backups")),
1020 cached_manifest_path: None,
1021 },
1022 &MockReleaseProvider {
1023 release: Ok(release),
1024 },
1025 &MockDownloader {
1026 data: Ok(DownloadResult {
1027 binary_data: vec![],
1028 integrity_verified: false,
1029 }),
1030 },
1031 &MockSmokeTester { result: Ok(()) },
1032 &MockInteractor { confirmed: true },
1033 );
1034
1035 assert!(result.is_err());
1036 match result.unwrap_err() {
1037 UpgradeError::RuntimeTooOld {
1038 current_schema,
1039 manifest_schema,
1040 } => {
1041 assert_eq!(current_schema, 1);
1042 assert_eq!(manifest_schema, 3);
1043 }
1044 other => panic!("expected RuntimeTooOld, got {other:?}"),
1045 }
1046 }
1047
1048 #[test]
1049 fn test_upgrade_declined_in_interactive_mode() {
1050 let dir = tempfile::tempdir().unwrap();
1051 let binary_path = dir.path().join("rusty-stack");
1052 let original_content = b"original binary";
1053 fs::write(&binary_path, original_content).unwrap();
1054
1055 let new_binary = b"new binary";
1056 let checksum = compute_checksum(new_binary);
1057
1058 let mut release = make_release_info("0.2.0", "0.1.0", 2);
1059 release.checksum = checksum;
1060
1061 let result = run_upgrade(
1062 &VersionInfo {
1063 version: "0.1.0".to_string(),
1064 schema_version: 2,
1065 },
1066 &UpgradeOptions {
1067 non_interactive: false, binary_path: Some(binary_path.clone()),
1069 backup_dir: Some(dir.path().join("backups")),
1070 cached_manifest_path: None,
1071 },
1072 &MockReleaseProvider {
1073 release: Ok(release),
1074 },
1075 &MockDownloader {
1076 data: Ok(DownloadResult {
1077 binary_data: new_binary.to_vec(),
1078 integrity_verified: false,
1079 }),
1080 },
1081 &MockSmokeTester { result: Ok(()) },
1082 &MockInteractor { confirmed: false }, );
1084
1085 assert!(result.is_err());
1086 match result.unwrap_err() {
1087 UpgradeError::Declined => {}
1088 other => panic!("expected Declined, got {other:?}"),
1089 }
1090
1091 let content = fs::read(&binary_path).unwrap();
1093 assert_eq!(content, original_content.to_vec());
1094 }
1095
1096 #[test]
1097 fn test_upgrade_integrity_failure_preserves_binary() {
1098 let dir = tempfile::tempdir().unwrap();
1099 let binary_path = dir.path().join("rusty-stack");
1100 let original_content = b"original binary";
1101 fs::write(&binary_path, original_content).unwrap();
1102
1103 let new_binary = b"corrupted new binary";
1104
1105 let mut release = make_release_info("0.2.0", "0.1.0", 2);
1106 release.checksum = "wrong_checksum_value".to_string();
1107
1108 let result = run_upgrade(
1109 &VersionInfo {
1110 version: "0.1.0".to_string(),
1111 schema_version: 2,
1112 },
1113 &UpgradeOptions {
1114 non_interactive: true,
1115 binary_path: Some(binary_path.clone()),
1116 backup_dir: Some(dir.path().join("backups")),
1117 cached_manifest_path: None,
1118 },
1119 &MockReleaseProvider {
1120 release: Ok(release),
1121 },
1122 &MockDownloader {
1123 data: Ok(DownloadResult {
1124 binary_data: new_binary.to_vec(),
1125 integrity_verified: false,
1126 }),
1127 },
1128 &MockSmokeTester { result: Ok(()) },
1129 &MockInteractor { confirmed: true },
1130 );
1131
1132 assert!(result.is_err());
1133 match result.unwrap_err() {
1134 UpgradeError::IntegrityCheckFailed { .. } => {}
1135 other => panic!("expected IntegrityCheckFailed, got {other:?}"),
1136 }
1137
1138 let content = fs::read(&binary_path).unwrap();
1140 assert_eq!(content, original_content.to_vec());
1141 }
1142
1143 #[test]
1146 fn test_parse_version_parts_standard() {
1147 assert_eq!(parse_version_parts("1.2.3"), Some((1, 2, 3)));
1148 }
1149
1150 #[test]
1151 fn test_parse_version_parts_prerelease() {
1152 assert_eq!(parse_version_parts("1.2.3-rc1"), Some((1, 2, 3)));
1153 }
1154
1155 #[test]
1156 fn test_parse_version_parts_invalid() {
1157 assert_eq!(parse_version_parts("invalid"), None);
1158 assert_eq!(parse_version_parts("1.2"), None);
1159 assert_eq!(parse_version_parts(""), None);
1160 }
1161
1162 #[test]
1165 fn test_upgrade_result_serializes_to_json() {
1166 let result = UpgradeResult {
1167 previous_version: "0.1.0".to_string(),
1168 new_version: "0.2.0".to_string(),
1169 status: UpgradeStatus::Success,
1170 backup_path: PathBuf::from("/home/user/.mlstack/backups/rusty-stack-0.1.0.bak"),
1171 };
1172
1173 let json = serde_json::to_string(&result).unwrap();
1174 assert!(json.contains("\"previous_version\":\"0.1.0\""));
1175 assert!(json.contains("\"new_version\":\"0.2.0\""));
1176 assert!(json.contains("\"status\":\"success\""));
1177
1178 let back: UpgradeResult = serde_json::from_str(&json).unwrap();
1180 assert_eq!(back, result);
1181 }
1182
1183 #[test]
1184 fn test_upgrade_result_rolled_back_serializes() {
1185 let result = UpgradeResult {
1186 previous_version: "0.1.0".to_string(),
1187 new_version: "0.2.0".to_string(),
1188 status: UpgradeStatus::RolledBack,
1189 backup_path: PathBuf::from("/tmp/backup"),
1190 };
1191
1192 let json = serde_json::to_string(&result).unwrap();
1193 assert!(json.contains("\"status\":\"rolled_back\""));
1194 }
1195
1196 #[test]
1197 fn test_upgrade_result_refused_serializes() {
1198 let result = UpgradeResult {
1199 previous_version: "0.1.0".to_string(),
1200 new_version: "0.2.0".to_string(),
1201 status: UpgradeStatus::Refused,
1202 backup_path: PathBuf::from("/tmp/backup"),
1203 };
1204
1205 let json = serde_json::to_string(&result).unwrap();
1206 assert!(json.contains("\"status\":\"refused\""));
1207 }
1208}