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(
793 &self,
794 _release: &ReleaseInfo,
795 ) -> std::result::Result<DownloadResult, UpgradeError> {
796 self.data.clone()
797 }
798 }
799
800 struct MockSmokeTester {
802 result: std::result::Result<(), UpgradeError>,
803 }
804
805 impl SmokeTester for MockSmokeTester {
806 fn test(&self, _binary_path: &Path) -> std::result::Result<(), UpgradeError> {
807 self.result.clone()
808 }
809 }
810
811 struct MockInteractor {
813 confirmed: bool,
814 }
815
816 impl UserInteractor for MockInteractor {
817 fn confirm_upgrade(&self, _current: &str, _target: &str) -> bool {
818 self.confirmed
819 }
820 }
821
822 #[allow(dead_code)]
824 struct RecordingSmokeTester {
825 called: AtomicBool,
826 result: std::result::Result<(), UpgradeError>,
827 }
828
829 impl SmokeTester for RecordingSmokeTester {
830 fn test(&self, _binary_path: &Path) -> std::result::Result<(), UpgradeError> {
831 self.called.store(true, Ordering::SeqCst);
832 self.result.clone()
833 }
834 }
835
836 fn make_release_info(version: &str, min_runtime: &str, schema: u32) -> ReleaseInfo {
837 ReleaseInfo {
838 version: version.to_string(),
839 download_url: format!("https://example.com/rusty-stack-{version}"),
840 checksum: String::new(), min_runtime_version: min_runtime.to_string(),
842 schema_version: schema,
843 }
844 }
845
846 #[test]
847 fn test_full_upgrade_success() {
848 let dir = tempfile::tempdir().unwrap();
849 let binary_path = dir.path().join("rusty-stack");
850 let backup_dir = dir.path().join("backups");
851 let cache_dir = dir.path().join("cache");
852 let cached_manifest = cache_dir.join("remote_manifest.json");
853
854 fs::write(&binary_path, b"old binary v0.1.0").unwrap();
856
857 let manifest_data = b"{\"schema_version\": 2}";
859 write_cached_manifest(&cached_manifest, manifest_data).unwrap();
860
861 let new_binary = b"new binary v0.2.0";
862 let checksum = compute_checksum(new_binary);
863
864 let mut release = make_release_info("0.2.0", "0.1.0", 2);
865 release.checksum = checksum;
866
867 let result = run_upgrade(
868 &VersionInfo {
869 version: "0.1.0".to_string(),
870 schema_version: 2,
871 },
872 &UpgradeOptions {
873 non_interactive: true,
874 binary_path: Some(binary_path.clone()),
875 backup_dir: Some(backup_dir.clone()),
876 cached_manifest_path: Some(cached_manifest.clone()),
877 },
878 &MockReleaseProvider {
879 release: Ok(release),
880 },
881 &MockDownloader {
882 data: Ok(DownloadResult {
883 binary_data: new_binary.to_vec(),
884 integrity_verified: false,
885 }),
886 },
887 &MockSmokeTester { result: Ok(()) },
888 &MockInteractor { confirmed: true },
889 );
890
891 assert!(result.is_ok(), "upgrade should succeed");
892 let upgrade_result = result.unwrap();
893 assert_eq!(upgrade_result.previous_version, "0.1.0");
894 assert_eq!(upgrade_result.new_version, "0.2.0");
895 assert_eq!(upgrade_result.status, UpgradeStatus::Success);
896 assert!(upgrade_result.backup_path.exists());
897
898 let content = fs::read(&binary_path).unwrap();
900 assert_eq!(content, new_binary.to_vec());
901
902 let restored_manifest = read_cached_manifest(&cached_manifest).unwrap();
904 assert_eq!(restored_manifest, manifest_data.to_vec());
905 }
906
907 #[test]
908 fn test_upgrade_rollback_on_smoke_test_failure() {
909 let dir = tempfile::tempdir().unwrap();
910 let binary_path = dir.path().join("rusty-stack");
911 let backup_dir = dir.path().join("backups");
912
913 let original_content = b"original binary v0.1.0";
914 fs::write(&binary_path, original_content).unwrap();
915
916 let new_binary = b"broken new binary";
917 let checksum = compute_checksum(new_binary);
918
919 let mut release = make_release_info("0.2.0", "0.1.0", 2);
920 release.checksum = checksum;
921
922 let result = run_upgrade(
923 &VersionInfo {
924 version: "0.1.0".to_string(),
925 schema_version: 2,
926 },
927 &UpgradeOptions {
928 non_interactive: true,
929 binary_path: Some(binary_path.clone()),
930 backup_dir: Some(backup_dir),
931 cached_manifest_path: None,
932 },
933 &MockReleaseProvider {
934 release: Ok(release),
935 },
936 &MockDownloader {
937 data: Ok(DownloadResult {
938 binary_data: new_binary.to_vec(),
939 integrity_verified: false,
940 }),
941 },
942 &MockSmokeTester {
943 result: Err(UpgradeError::SmokeTestFailed {
944 reason: "binary exited with code 1".to_string(),
945 }),
946 },
947 &MockInteractor { confirmed: true },
948 );
949
950 assert!(result.is_err(), "upgrade should fail on smoke test");
951 match result.unwrap_err() {
952 UpgradeError::SmokeTestFailed { reason } => {
953 assert!(reason.contains("binary exited with code 1"));
954 }
955 other => panic!("expected SmokeTestFailed, got {other:?}"),
956 }
957
958 let content = fs::read(&binary_path).unwrap();
960 assert_eq!(content, original_content.to_vec());
961 }
962
963 #[test]
964 fn test_upgrade_refused_incompatible_runtime() {
965 let dir = tempfile::tempdir().unwrap();
966 let binary_path = dir.path().join("rusty-stack");
967 fs::write(&binary_path, b"old binary").unwrap();
968
969 let release = make_release_info("2.0.0", "2.0.0", 2);
970
971 let result = run_upgrade(
972 &VersionInfo {
973 version: "1.0.0".to_string(),
974 schema_version: 2,
975 },
976 &UpgradeOptions {
977 non_interactive: true,
978 binary_path: Some(binary_path.clone()),
979 backup_dir: Some(dir.path().join("backups")),
980 cached_manifest_path: None,
981 },
982 &MockReleaseProvider {
983 release: Ok(release),
984 },
985 &MockDownloader {
986 data: Ok(DownloadResult {
987 binary_data: vec![],
988 integrity_verified: false,
989 }),
990 },
991 &MockSmokeTester { result: Ok(()) },
992 &MockInteractor { confirmed: true },
993 );
994
995 assert!(result.is_err());
996 match result.unwrap_err() {
997 UpgradeError::IncompatibleRuntime { .. } => {}
998 other => panic!("expected IncompatibleRuntime, got {other:?}"),
999 }
1000
1001 let content = fs::read(&binary_path).unwrap();
1003 assert_eq!(content, b"old binary".to_vec());
1004 }
1005
1006 #[test]
1007 fn test_upgrade_refused_runtime_too_old_for_manifest() {
1008 let dir = tempfile::tempdir().unwrap();
1009 let binary_path = dir.path().join("rusty-stack");
1010 fs::write(&binary_path, b"old binary v1 schema").unwrap();
1011
1012 let release = make_release_info("0.2.0", "0.1.0", 3);
1013
1014 let result = run_upgrade(
1015 &VersionInfo {
1016 version: "0.1.0".to_string(),
1017 schema_version: 1,
1018 },
1019 &UpgradeOptions {
1020 non_interactive: true,
1021 binary_path: Some(binary_path.clone()),
1022 backup_dir: Some(dir.path().join("backups")),
1023 cached_manifest_path: None,
1024 },
1025 &MockReleaseProvider {
1026 release: Ok(release),
1027 },
1028 &MockDownloader {
1029 data: Ok(DownloadResult {
1030 binary_data: vec![],
1031 integrity_verified: false,
1032 }),
1033 },
1034 &MockSmokeTester { result: Ok(()) },
1035 &MockInteractor { confirmed: true },
1036 );
1037
1038 assert!(result.is_err());
1039 match result.unwrap_err() {
1040 UpgradeError::RuntimeTooOld {
1041 current_schema,
1042 manifest_schema,
1043 } => {
1044 assert_eq!(current_schema, 1);
1045 assert_eq!(manifest_schema, 3);
1046 }
1047 other => panic!("expected RuntimeTooOld, got {other:?}"),
1048 }
1049 }
1050
1051 #[test]
1052 fn test_upgrade_declined_in_interactive_mode() {
1053 let dir = tempfile::tempdir().unwrap();
1054 let binary_path = dir.path().join("rusty-stack");
1055 let original_content = b"original binary";
1056 fs::write(&binary_path, original_content).unwrap();
1057
1058 let new_binary = b"new binary";
1059 let checksum = compute_checksum(new_binary);
1060
1061 let mut release = make_release_info("0.2.0", "0.1.0", 2);
1062 release.checksum = checksum;
1063
1064 let result = run_upgrade(
1065 &VersionInfo {
1066 version: "0.1.0".to_string(),
1067 schema_version: 2,
1068 },
1069 &UpgradeOptions {
1070 non_interactive: false, binary_path: Some(binary_path.clone()),
1072 backup_dir: Some(dir.path().join("backups")),
1073 cached_manifest_path: None,
1074 },
1075 &MockReleaseProvider {
1076 release: Ok(release),
1077 },
1078 &MockDownloader {
1079 data: Ok(DownloadResult {
1080 binary_data: new_binary.to_vec(),
1081 integrity_verified: false,
1082 }),
1083 },
1084 &MockSmokeTester { result: Ok(()) },
1085 &MockInteractor { confirmed: false }, );
1087
1088 assert!(result.is_err());
1089 match result.unwrap_err() {
1090 UpgradeError::Declined => {}
1091 other => panic!("expected Declined, got {other:?}"),
1092 }
1093
1094 let content = fs::read(&binary_path).unwrap();
1096 assert_eq!(content, original_content.to_vec());
1097 }
1098
1099 #[test]
1100 fn test_upgrade_integrity_failure_preserves_binary() {
1101 let dir = tempfile::tempdir().unwrap();
1102 let binary_path = dir.path().join("rusty-stack");
1103 let original_content = b"original binary";
1104 fs::write(&binary_path, original_content).unwrap();
1105
1106 let new_binary = b"corrupted new binary";
1107
1108 let mut release = make_release_info("0.2.0", "0.1.0", 2);
1109 release.checksum = "wrong_checksum_value".to_string();
1110
1111 let result = run_upgrade(
1112 &VersionInfo {
1113 version: "0.1.0".to_string(),
1114 schema_version: 2,
1115 },
1116 &UpgradeOptions {
1117 non_interactive: true,
1118 binary_path: Some(binary_path.clone()),
1119 backup_dir: Some(dir.path().join("backups")),
1120 cached_manifest_path: None,
1121 },
1122 &MockReleaseProvider {
1123 release: Ok(release),
1124 },
1125 &MockDownloader {
1126 data: Ok(DownloadResult {
1127 binary_data: new_binary.to_vec(),
1128 integrity_verified: false,
1129 }),
1130 },
1131 &MockSmokeTester { result: Ok(()) },
1132 &MockInteractor { confirmed: true },
1133 );
1134
1135 assert!(result.is_err());
1136 match result.unwrap_err() {
1137 UpgradeError::IntegrityCheckFailed { .. } => {}
1138 other => panic!("expected IntegrityCheckFailed, got {other:?}"),
1139 }
1140
1141 let content = fs::read(&binary_path).unwrap();
1143 assert_eq!(content, original_content.to_vec());
1144 }
1145
1146 #[test]
1149 fn test_parse_version_parts_standard() {
1150 assert_eq!(parse_version_parts("1.2.3"), Some((1, 2, 3)));
1151 }
1152
1153 #[test]
1154 fn test_parse_version_parts_prerelease() {
1155 assert_eq!(parse_version_parts("1.2.3-rc1"), Some((1, 2, 3)));
1156 }
1157
1158 #[test]
1159 fn test_parse_version_parts_invalid() {
1160 assert_eq!(parse_version_parts("invalid"), None);
1161 assert_eq!(parse_version_parts("1.2"), None);
1162 assert_eq!(parse_version_parts(""), None);
1163 }
1164
1165 #[test]
1168 fn test_upgrade_result_serializes_to_json() {
1169 let result = UpgradeResult {
1170 previous_version: "0.1.0".to_string(),
1171 new_version: "0.2.0".to_string(),
1172 status: UpgradeStatus::Success,
1173 backup_path: PathBuf::from("/home/user/.mlstack/backups/rusty-stack-0.1.0.bak"),
1174 };
1175
1176 let json = serde_json::to_string(&result).unwrap();
1177 assert!(json.contains("\"previous_version\":\"0.1.0\""));
1178 assert!(json.contains("\"new_version\":\"0.2.0\""));
1179 assert!(json.contains("\"status\":\"success\""));
1180
1181 let back: UpgradeResult = serde_json::from_str(&json).unwrap();
1183 assert_eq!(back, result);
1184 }
1185
1186 #[test]
1187 fn test_upgrade_result_rolled_back_serializes() {
1188 let result = UpgradeResult {
1189 previous_version: "0.1.0".to_string(),
1190 new_version: "0.2.0".to_string(),
1191 status: UpgradeStatus::RolledBack,
1192 backup_path: PathBuf::from("/tmp/backup"),
1193 };
1194
1195 let json = serde_json::to_string(&result).unwrap();
1196 assert!(json.contains("\"status\":\"rolled_back\""));
1197 }
1198
1199 #[test]
1200 fn test_upgrade_result_refused_serializes() {
1201 let result = UpgradeResult {
1202 previous_version: "0.1.0".to_string(),
1203 new_version: "0.2.0".to_string(),
1204 status: UpgradeStatus::Refused,
1205 backup_path: PathBuf::from("/tmp/backup"),
1206 };
1207
1208 let json = serde_json::to_string(&result).unwrap();
1209 assert!(json.contains("\"status\":\"refused\""));
1210 }
1211}