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
572 #[test]
575 fn test_version_compat_equal_versions() {
576 assert!(check_version_compatibility("1.0.0", "1.0.0").is_ok());
577 }
578
579 #[test]
580 fn test_version_compat_newer_current() {
581 assert!(check_version_compatibility("2.0.0", "1.0.0").is_ok());
582 }
583
584 #[test]
585 fn test_version_compat_older_current_refused() {
586 let result = check_version_compatibility("1.0.0", "2.0.0");
587 assert!(result.is_err());
588 match result.unwrap_err() {
589 UpgradeError::IncompatibleRuntime { current, required } => {
590 assert_eq!(current, "1.0.0");
591 assert_eq!(required, "2.0.0");
592 }
593 other => panic!("expected IncompatibleRuntime, got {other:?}"),
594 }
595 }
596
597 #[test]
598 fn test_version_compat_minor_older_refused() {
599 let result = check_version_compatibility("1.0.0", "1.1.0");
600 assert!(result.is_err());
601 }
602
603 #[test]
604 fn test_version_compat_patch_newer_ok() {
605 assert!(check_version_compatibility("1.0.1", "1.0.0").is_ok());
606 }
607
608 #[test]
609 fn test_schema_compat_equal() {
610 assert!(check_schema_compatibility(2, 2).is_ok());
611 }
612
613 #[test]
614 fn test_schema_compat_newer_runtime_ok() {
615 assert!(check_schema_compatibility(3, 2).is_ok());
616 }
617
618 #[test]
619 fn test_schema_compat_older_runtime_refused() {
620 let result = check_schema_compatibility(1, 3);
621 assert!(result.is_err());
622 match result.unwrap_err() {
623 UpgradeError::RuntimeTooOld {
624 current_schema,
625 manifest_schema,
626 } => {
627 assert_eq!(current_schema, 1);
628 assert_eq!(manifest_schema, 3);
629 }
630 other => panic!("expected RuntimeTooOld, got {other:?}"),
631 }
632 }
633
634 #[test]
637 fn test_checksum_valid() {
638 let data = b"hello world";
639 let expected = compute_checksum(data);
640 assert!(verify_integrity(data, &expected).is_ok());
641 }
642
643 #[test]
644 fn test_checksum_tampered_binary_rejected() {
645 let data = b"original binary content";
646 let expected = compute_checksum(data);
647 let tampered = b"tampered binary content";
648 let result = verify_integrity(tampered, &expected);
649 assert!(result.is_err());
650 match result.unwrap_err() {
651 UpgradeError::IntegrityCheckFailed { .. } => {}
652 other => panic!("expected IntegrityCheckFailed, got {other:?}"),
653 }
654 }
655
656 #[test]
657 fn test_checksum_empty_data() {
658 let data = b"";
659 let expected = compute_checksum(data);
660 assert!(verify_integrity(data, &expected).is_ok());
661 }
662
663 #[test]
666 fn test_backup_creates_file() {
667 let dir = tempfile::tempdir().unwrap();
668 let source = dir.path().join("rusty-stack");
669 fs::write(&source, b"binary content").unwrap();
670
671 let backup_dir = dir.path().join("backups");
672 let backup = create_backup(&source, &backup_dir, "0.1.0").unwrap();
673
674 assert!(backup.exists());
675 assert_eq!(fs::read(&backup).unwrap(), b"binary content".to_vec());
676 assert_eq!(backup.file_name().unwrap(), "rusty-stack-0.1.0.bak");
677 }
678
679 #[test]
680 fn test_backup_preserves_content() {
681 let dir = tempfile::tempdir().unwrap();
682 let source = dir.path().join("rusty-stack");
683 let content = b"\x00\x01\x02\xff binary data with null bytes";
684 fs::write(&source, content).unwrap();
685
686 let backup_dir = dir.path().join("backups");
687 let backup = create_backup(&source, &backup_dir, "1.2.3").unwrap();
688
689 let backup_content = fs::read(&backup).unwrap();
690 assert_eq!(backup_content, content.to_vec());
691 }
692
693 #[test]
694 fn test_rollback_restores_binary() {
695 let dir = tempfile::tempdir().unwrap();
696 let binary_path = dir.path().join("rusty-stack");
697 let backup_path = dir.path().join("rusty-stack-0.1.0.bak");
698
699 let original_content = b"original binary";
700 let new_content = b"new binary that fails smoke test";
701
702 fs::write(&binary_path, new_content).unwrap();
703 fs::write(&backup_path, original_content).unwrap();
704
705 rollback(&binary_path, &backup_path).unwrap();
706
707 let restored = fs::read(&binary_path).unwrap();
708 assert_eq!(restored, original_content.to_vec());
709 }
710
711 #[test]
714 fn test_swap_binary_replaces_content() {
715 let dir = tempfile::tempdir().unwrap();
716 let binary_path = dir.path().join("rusty-stack");
717 fs::write(&binary_path, b"old binary").unwrap();
718
719 let new_data = b"new binary";
720 swap_binary(&binary_path, new_data).unwrap();
721
722 let content = fs::read(&binary_path).unwrap();
723 assert_eq!(content, new_data.to_vec());
724 }
725
726 #[test]
727 fn test_swap_binary_creates_if_missing() {
728 let dir = tempfile::tempdir().unwrap();
729 let binary_path = dir.path().join("rusty-stack");
730 let new_data = b"brand new binary";
733 swap_binary(&binary_path, new_data).unwrap();
734
735 let content = fs::read(&binary_path).unwrap();
736 assert_eq!(content, new_data.to_vec());
737 }
738
739 #[test]
742 fn test_cached_manifest_roundtrip() {
743 let dir = tempfile::tempdir().unwrap();
744 let path = dir.path().join("cache").join("remote_manifest.json");
745 let data = b"{\"schema_version\": 2, \"components\": []}";
746
747 write_cached_manifest(&path, data).unwrap();
748 let read_data = read_cached_manifest(&path).unwrap();
749
750 assert_eq!(read_data, data.to_vec());
751 }
752
753 #[test]
754 fn test_cached_manifest_missing_returns_none() {
755 let path = PathBuf::from("/nonexistent/path/manifest.json");
756 assert!(read_cached_manifest(&path).is_none());
757 }
758
759 #[test]
760 fn test_cached_manifest_preserves_exact_bytes() {
761 let dir = tempfile::tempdir().unwrap();
762 let path = dir.path().join("manifest.json");
763
764 let original = b"{\n \"schema_version\": 2,\n \"components\": []\n}";
766 write_cached_manifest(&path, original).unwrap();
767
768 let restored = read_cached_manifest(&path).unwrap();
769 assert_eq!(restored, original.to_vec());
770 }
771
772 struct MockReleaseProvider {
776 release: std::result::Result<ReleaseInfo, UpgradeError>,
777 }
778
779 impl ReleaseProvider for MockReleaseProvider {
780 fn fetch_latest_release(&self) -> std::result::Result<ReleaseInfo, UpgradeError> {
781 self.release.clone()
782 }
783 }
784
785 struct MockDownloader {
787 data: std::result::Result<DownloadResult, UpgradeError>,
788 }
789
790 impl BinaryDownloader for MockDownloader {
791 fn download(
792 &self,
793 _release: &ReleaseInfo,
794 ) -> std::result::Result<DownloadResult, UpgradeError> {
795 self.data.clone()
796 }
797 }
798
799 struct MockSmokeTester {
801 result: std::result::Result<(), UpgradeError>,
802 }
803
804 impl SmokeTester for MockSmokeTester {
805 fn test(&self, _binary_path: &Path) -> std::result::Result<(), UpgradeError> {
806 self.result.clone()
807 }
808 }
809
810 struct MockInteractor {
812 confirmed: bool,
813 }
814
815 impl UserInteractor for MockInteractor {
816 fn confirm_upgrade(&self, _current: &str, _target: &str) -> bool {
817 self.confirmed
818 }
819 }
820
821 fn make_release_info(version: &str, min_runtime: &str, schema: u32) -> ReleaseInfo {
822 ReleaseInfo {
823 version: version.to_string(),
824 download_url: format!("https://example.com/rusty-stack-{version}"),
825 checksum: String::new(), min_runtime_version: min_runtime.to_string(),
827 schema_version: schema,
828 }
829 }
830
831 #[test]
832 fn test_full_upgrade_success() {
833 let dir = tempfile::tempdir().unwrap();
834 let binary_path = dir.path().join("rusty-stack");
835 let backup_dir = dir.path().join("backups");
836 let cache_dir = dir.path().join("cache");
837 let cached_manifest = cache_dir.join("remote_manifest.json");
838
839 fs::write(&binary_path, b"old binary v0.1.0").unwrap();
841
842 let manifest_data = b"{\"schema_version\": 2}";
844 write_cached_manifest(&cached_manifest, manifest_data).unwrap();
845
846 let new_binary = b"new binary v0.2.0";
847 let checksum = compute_checksum(new_binary);
848
849 let mut release = make_release_info("0.2.0", "0.1.0", 2);
850 release.checksum = checksum;
851
852 let result = run_upgrade(
853 &VersionInfo {
854 version: "0.1.0".to_string(),
855 schema_version: 2,
856 },
857 &UpgradeOptions {
858 non_interactive: true,
859 binary_path: Some(binary_path.clone()),
860 backup_dir: Some(backup_dir.clone()),
861 cached_manifest_path: Some(cached_manifest.clone()),
862 },
863 &MockReleaseProvider {
864 release: Ok(release),
865 },
866 &MockDownloader {
867 data: Ok(DownloadResult {
868 binary_data: new_binary.to_vec(),
869 integrity_verified: false,
870 }),
871 },
872 &MockSmokeTester { result: Ok(()) },
873 &MockInteractor { confirmed: true },
874 );
875
876 assert!(result.is_ok(), "upgrade should succeed");
877 let upgrade_result = result.unwrap();
878 assert_eq!(upgrade_result.previous_version, "0.1.0");
879 assert_eq!(upgrade_result.new_version, "0.2.0");
880 assert_eq!(upgrade_result.status, UpgradeStatus::Success);
881 assert!(upgrade_result.backup_path.exists());
882
883 let content = fs::read(&binary_path).unwrap();
885 assert_eq!(content, new_binary.to_vec());
886
887 let restored_manifest = read_cached_manifest(&cached_manifest).unwrap();
889 assert_eq!(restored_manifest, manifest_data.to_vec());
890 }
891
892 #[test]
893 fn test_upgrade_rollback_on_smoke_test_failure() {
894 let dir = tempfile::tempdir().unwrap();
895 let binary_path = dir.path().join("rusty-stack");
896 let backup_dir = dir.path().join("backups");
897
898 let original_content = b"original binary v0.1.0";
899 fs::write(&binary_path, original_content).unwrap();
900
901 let new_binary = b"broken new binary";
902 let checksum = compute_checksum(new_binary);
903
904 let mut release = make_release_info("0.2.0", "0.1.0", 2);
905 release.checksum = checksum;
906
907 let result = run_upgrade(
908 &VersionInfo {
909 version: "0.1.0".to_string(),
910 schema_version: 2,
911 },
912 &UpgradeOptions {
913 non_interactive: true,
914 binary_path: Some(binary_path.clone()),
915 backup_dir: Some(backup_dir),
916 cached_manifest_path: None,
917 },
918 &MockReleaseProvider {
919 release: Ok(release),
920 },
921 &MockDownloader {
922 data: Ok(DownloadResult {
923 binary_data: new_binary.to_vec(),
924 integrity_verified: false,
925 }),
926 },
927 &MockSmokeTester {
928 result: Err(UpgradeError::SmokeTestFailed {
929 reason: "binary exited with code 1".to_string(),
930 }),
931 },
932 &MockInteractor { confirmed: true },
933 );
934
935 assert!(result.is_err(), "upgrade should fail on smoke test");
936 match result.unwrap_err() {
937 UpgradeError::SmokeTestFailed { reason } => {
938 assert!(reason.contains("binary exited with code 1"));
939 }
940 other => panic!("expected SmokeTestFailed, got {other:?}"),
941 }
942
943 let content = fs::read(&binary_path).unwrap();
945 assert_eq!(content, original_content.to_vec());
946 }
947
948 #[test]
949 fn test_upgrade_refused_incompatible_runtime() {
950 let dir = tempfile::tempdir().unwrap();
951 let binary_path = dir.path().join("rusty-stack");
952 fs::write(&binary_path, b"old binary").unwrap();
953
954 let release = make_release_info("2.0.0", "2.0.0", 2);
955
956 let result = run_upgrade(
957 &VersionInfo {
958 version: "1.0.0".to_string(),
959 schema_version: 2,
960 },
961 &UpgradeOptions {
962 non_interactive: true,
963 binary_path: Some(binary_path.clone()),
964 backup_dir: Some(dir.path().join("backups")),
965 cached_manifest_path: None,
966 },
967 &MockReleaseProvider {
968 release: Ok(release),
969 },
970 &MockDownloader {
971 data: Ok(DownloadResult {
972 binary_data: vec![],
973 integrity_verified: false,
974 }),
975 },
976 &MockSmokeTester { result: Ok(()) },
977 &MockInteractor { confirmed: true },
978 );
979
980 assert!(result.is_err());
981 match result.unwrap_err() {
982 UpgradeError::IncompatibleRuntime { .. } => {}
983 other => panic!("expected IncompatibleRuntime, got {other:?}"),
984 }
985
986 let content = fs::read(&binary_path).unwrap();
988 assert_eq!(content, b"old binary".to_vec());
989 }
990
991 #[test]
992 fn test_upgrade_refused_runtime_too_old_for_manifest() {
993 let dir = tempfile::tempdir().unwrap();
994 let binary_path = dir.path().join("rusty-stack");
995 fs::write(&binary_path, b"old binary v1 schema").unwrap();
996
997 let release = make_release_info("0.2.0", "0.1.0", 3);
998
999 let result = run_upgrade(
1000 &VersionInfo {
1001 version: "0.1.0".to_string(),
1002 schema_version: 1,
1003 },
1004 &UpgradeOptions {
1005 non_interactive: true,
1006 binary_path: Some(binary_path.clone()),
1007 backup_dir: Some(dir.path().join("backups")),
1008 cached_manifest_path: None,
1009 },
1010 &MockReleaseProvider {
1011 release: Ok(release),
1012 },
1013 &MockDownloader {
1014 data: Ok(DownloadResult {
1015 binary_data: vec![],
1016 integrity_verified: false,
1017 }),
1018 },
1019 &MockSmokeTester { result: Ok(()) },
1020 &MockInteractor { confirmed: true },
1021 );
1022
1023 assert!(result.is_err());
1024 match result.unwrap_err() {
1025 UpgradeError::RuntimeTooOld {
1026 current_schema,
1027 manifest_schema,
1028 } => {
1029 assert_eq!(current_schema, 1);
1030 assert_eq!(manifest_schema, 3);
1031 }
1032 other => panic!("expected RuntimeTooOld, got {other:?}"),
1033 }
1034 }
1035
1036 #[test]
1037 fn test_upgrade_declined_in_interactive_mode() {
1038 let dir = tempfile::tempdir().unwrap();
1039 let binary_path = dir.path().join("rusty-stack");
1040 let original_content = b"original binary";
1041 fs::write(&binary_path, original_content).unwrap();
1042
1043 let new_binary = b"new binary";
1044 let checksum = compute_checksum(new_binary);
1045
1046 let mut release = make_release_info("0.2.0", "0.1.0", 2);
1047 release.checksum = checksum;
1048
1049 let result = run_upgrade(
1050 &VersionInfo {
1051 version: "0.1.0".to_string(),
1052 schema_version: 2,
1053 },
1054 &UpgradeOptions {
1055 non_interactive: false, binary_path: Some(binary_path.clone()),
1057 backup_dir: Some(dir.path().join("backups")),
1058 cached_manifest_path: None,
1059 },
1060 &MockReleaseProvider {
1061 release: Ok(release),
1062 },
1063 &MockDownloader {
1064 data: Ok(DownloadResult {
1065 binary_data: new_binary.to_vec(),
1066 integrity_verified: false,
1067 }),
1068 },
1069 &MockSmokeTester { result: Ok(()) },
1070 &MockInteractor { confirmed: false }, );
1072
1073 assert!(result.is_err());
1074 match result.unwrap_err() {
1075 UpgradeError::Declined => {}
1076 other => panic!("expected Declined, got {other:?}"),
1077 }
1078
1079 let content = fs::read(&binary_path).unwrap();
1081 assert_eq!(content, original_content.to_vec());
1082 }
1083
1084 #[test]
1085 fn test_upgrade_integrity_failure_preserves_binary() {
1086 let dir = tempfile::tempdir().unwrap();
1087 let binary_path = dir.path().join("rusty-stack");
1088 let original_content = b"original binary";
1089 fs::write(&binary_path, original_content).unwrap();
1090
1091 let new_binary = b"corrupted new binary";
1092
1093 let mut release = make_release_info("0.2.0", "0.1.0", 2);
1094 release.checksum = "wrong_checksum_value".to_string();
1095
1096 let result = run_upgrade(
1097 &VersionInfo {
1098 version: "0.1.0".to_string(),
1099 schema_version: 2,
1100 },
1101 &UpgradeOptions {
1102 non_interactive: true,
1103 binary_path: Some(binary_path.clone()),
1104 backup_dir: Some(dir.path().join("backups")),
1105 cached_manifest_path: None,
1106 },
1107 &MockReleaseProvider {
1108 release: Ok(release),
1109 },
1110 &MockDownloader {
1111 data: Ok(DownloadResult {
1112 binary_data: new_binary.to_vec(),
1113 integrity_verified: false,
1114 }),
1115 },
1116 &MockSmokeTester { result: Ok(()) },
1117 &MockInteractor { confirmed: true },
1118 );
1119
1120 assert!(result.is_err());
1121 match result.unwrap_err() {
1122 UpgradeError::IntegrityCheckFailed { .. } => {}
1123 other => panic!("expected IntegrityCheckFailed, got {other:?}"),
1124 }
1125
1126 let content = fs::read(&binary_path).unwrap();
1128 assert_eq!(content, original_content.to_vec());
1129 }
1130
1131 #[test]
1134 fn test_parse_version_parts_standard() {
1135 assert_eq!(parse_version_parts("1.2.3"), Some((1, 2, 3)));
1136 }
1137
1138 #[test]
1139 fn test_parse_version_parts_prerelease() {
1140 assert_eq!(parse_version_parts("1.2.3-rc1"), Some((1, 2, 3)));
1141 }
1142
1143 #[test]
1144 fn test_parse_version_parts_invalid() {
1145 assert_eq!(parse_version_parts("invalid"), None);
1146 assert_eq!(parse_version_parts("1.2"), None);
1147 assert_eq!(parse_version_parts(""), None);
1148 }
1149
1150 #[test]
1153 fn test_upgrade_result_serializes_to_json() {
1154 let result = UpgradeResult {
1155 previous_version: "0.1.0".to_string(),
1156 new_version: "0.2.0".to_string(),
1157 status: UpgradeStatus::Success,
1158 backup_path: PathBuf::from("/home/user/.mlstack/backups/rusty-stack-0.1.0.bak"),
1159 };
1160
1161 let json = serde_json::to_string(&result).unwrap();
1162 assert!(json.contains("\"previous_version\":\"0.1.0\""));
1163 assert!(json.contains("\"new_version\":\"0.2.0\""));
1164 assert!(json.contains("\"status\":\"success\""));
1165
1166 let back: UpgradeResult = serde_json::from_str(&json).unwrap();
1168 assert_eq!(back, result);
1169 }
1170
1171 #[test]
1172 fn test_upgrade_result_rolled_back_serializes() {
1173 let result = UpgradeResult {
1174 previous_version: "0.1.0".to_string(),
1175 new_version: "0.2.0".to_string(),
1176 status: UpgradeStatus::RolledBack,
1177 backup_path: PathBuf::from("/tmp/backup"),
1178 };
1179
1180 let json = serde_json::to_string(&result).unwrap();
1181 assert!(json.contains("\"status\":\"rolled_back\""));
1182 }
1183
1184 #[test]
1185 fn test_upgrade_result_refused_serializes() {
1186 let result = UpgradeResult {
1187 previous_version: "0.1.0".to_string(),
1188 new_version: "0.2.0".to_string(),
1189 status: UpgradeStatus::Refused,
1190 backup_path: PathBuf::from("/tmp/backup"),
1191 };
1192
1193 let json = serde_json::to_string(&result).unwrap();
1194 assert!(json.contains("\"status\":\"refused\""));
1195 }
1196}