Skip to main content

rusty_stack/orchestrator/
upgrade.rs

1//! Upgrade command implementation — download, verify, swap, and rollback.
2//!
3//! This module implements the `rusty upgrade` flow:
4//! 1. Check manifest version compatibility (runtime vs. manifest `min_runtime_version`)
5//! 2. Download new binary from release URL
6//! 3. Verify binary integrity (SHA-256 checksum against published value)
7//! 4. Swap binary with backup of previous version
8//! 5. Run smoke test to verify the new binary works
9//! 6. Rollback on smoke test failure
10//!
11//! Supports interactive confirmation and non-interactive `--yes` mode.
12//! Preserves cached remote manifest across upgrades.
13//! Operationally separate from `rusty update`.
14
15use 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// ---------------------------------------------------------------------------
23// Public types
24// ---------------------------------------------------------------------------
25
26/// Version information for the current binary.
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28pub struct VersionInfo {
29    pub version: String,
30    pub schema_version: u32,
31}
32
33/// Metadata about an available upgrade target.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35pub struct ReleaseInfo {
36    pub version: String,
37    pub download_url: String,
38    pub checksum: String,
39    /// Minimum runtime version required by this release.
40    pub min_runtime_version: String,
41    /// Schema version shipped with this release's bundled baseline manifest.
42    pub schema_version: u32,
43}
44
45/// Download result for an upgrade artifact.
46///
47/// `binary_data` must always contain executable bytes ready to be swapped in.
48/// If `integrity_verified` is `true`, the downloader already verified artifact
49/// integrity against the release checksum (for example archive checksum before
50/// extraction).
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct DownloadResult {
53    pub binary_data: Vec<u8>,
54    pub integrity_verified: bool,
55}
56
57/// Result of a successful upgrade operation.
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
59pub struct UpgradeResult {
60    pub previous_version: String,
61    pub new_version: String,
62    pub status: UpgradeStatus,
63    /// Path where the previous binary was backed up.
64    pub backup_path: PathBuf,
65}
66
67/// Status of an upgrade attempt.
68#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
69#[serde(rename_all = "snake_case")]
70pub enum UpgradeStatus {
71    /// Upgrade completed successfully.
72    Success,
73    /// Upgrade failed and was rolled back.
74    RolledBack,
75    /// Upgrade was refused due to compatibility issues.
76    Refused,
77}
78
79/// Options controlling upgrade behavior.
80#[derive(Debug, Clone, Default)]
81pub struct UpgradeOptions {
82    /// Skip interactive confirmation prompts.
83    pub non_interactive: bool,
84    /// Custom binary path (defaults to current executable).
85    pub binary_path: Option<PathBuf>,
86    /// Custom backup directory (defaults to `~/.mlstack/backups`).
87    pub backup_dir: Option<PathBuf>,
88    /// Custom cached manifest path (defaults to `~/.mlstack/cache/remote_manifest.json`).
89    pub cached_manifest_path: Option<PathBuf>,
90}
91
92// ---------------------------------------------------------------------------
93// Errors
94// ---------------------------------------------------------------------------
95
96/// Errors that can occur during the upgrade process.
97#[derive(Debug, Clone, PartialEq)]
98pub enum UpgradeError {
99    /// The target release requires a newer runtime than the current binary.
100    IncompatibleRuntime { current: String, required: String },
101    /// The current binary is too old to parse the manifest format.
102    RuntimeTooOld {
103        current_schema: u32,
104        manifest_schema: u32,
105    },
106    /// Binary integrity check failed (checksum mismatch).
107    IntegrityCheckFailed { expected: String, actual: String },
108    /// The smoke test failed after swap.
109    SmokeTestFailed { reason: String },
110    /// Download failed.
111    DownloadFailed { reason: String },
112    /// User declined the upgrade in interactive mode.
113    Declined,
114    /// Latest release is not newer than the current runtime.
115    NoUpgradeAvailable { current: String, latest: String },
116    /// I/O error during file operations.
117    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
168// ---------------------------------------------------------------------------
169// Core upgrade logic (trait-based for testability)
170// ---------------------------------------------------------------------------
171
172/// Trait for release discovery. Implementations provide release info from
173/// a remote source or test fixture.
174pub trait ReleaseProvider {
175    /// Fetch the latest available release information.
176    fn fetch_latest_release(&self) -> std::result::Result<ReleaseInfo, UpgradeError>;
177}
178
179/// Trait for binary download. Implementations provide the actual download
180/// or test fixture data.
181pub trait BinaryDownloader {
182    /// Download the upgrade artifact and return executable bytes.
183    fn download(&self, release: &ReleaseInfo) -> std::result::Result<DownloadResult, UpgradeError>;
184}
185
186/// Trait for smoke-testing a binary. Implementations run the new binary
187/// and check that it starts correctly.
188pub trait SmokeTester {
189    /// Run a smoke test on the binary at the given path.
190    /// Returns `Ok(())` if the binary works, `Err` with reason on failure.
191    fn test(&self, binary_path: &Path) -> std::result::Result<(), UpgradeError>;
192}
193
194/// Trait for user interaction. Implementations provide interactive
195/// confirmation or auto-accept in non-interactive mode.
196pub trait UserInteractor {
197    /// Ask the user to confirm the upgrade from `current` to `target`.
198    /// Returns `true` if confirmed, `false` if declined.
199    fn confirm_upgrade(&self, current: &str, target: &str) -> bool;
200}
201
202// ---------------------------------------------------------------------------
203// Version comparison helpers
204// ---------------------------------------------------------------------------
205
206/// Parse a version string like "1.2.3" or "1.2.3-rc1" into (major, minor, patch).
207fn 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
220/// Check if `current` version satisfies the `required` minimum version.
221/// Returns `Ok(())` if compatible, `Err(UpgradeError)` if not.
222pub 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
238/// Check if the current runtime's schema version can handle a manifest
239/// with the given schema version.
240pub fn check_schema_compatibility(
241    current_schema: u32,
242    manifest_schema: u32,
243) -> std::result::Result<(), UpgradeError> {
244    // The runtime can only parse manifests at or below its own schema version.
245    // Newer manifest schemas require a runtime upgrade first.
246    if current_schema < manifest_schema {
247        return Err(UpgradeError::RuntimeTooOld {
248            current_schema,
249            manifest_schema,
250        });
251    }
252    Ok(())
253}
254
255// ---------------------------------------------------------------------------
256// Checksum verification
257// ---------------------------------------------------------------------------
258
259/// Compute the SHA-256 checksum of a byte slice.
260pub 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
270/// Verify that `data` matches the expected SHA-256 `checksum`.
271pub 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
286// ---------------------------------------------------------------------------
287// File operations
288// ---------------------------------------------------------------------------
289
290/// Get the default backup directory path.
291pub fn default_backup_dir() -> PathBuf {
292    dirs::home_dir()
293        .unwrap_or_else(|| PathBuf::from("/tmp"))
294        .join(".mlstack")
295        .join("backups")
296}
297
298/// Get the default cached manifest path.
299pub 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
307/// Get the current binary path.
308pub fn current_binary_path() -> Result<PathBuf> {
309    std::env::current_exe().context("failed to determine current executable path")
310}
311
312/// Create a backup of the current binary.
313///
314/// Copies the binary at `source` to `backup_dir` with a versioned filename.
315/// Returns the path to the backup file.
316pub 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
338/// Swap the old binary with the new one.
339///
340/// 1. Rename the current binary to a `.old` temporary file
341/// 2. Write the new binary to the original path
342/// 3. Set executable permissions
343/// 4. Remove the `.old` temporary file
344pub fn swap_binary(binary_path: &Path, new_data: &[u8]) -> Result<()> {
345    let old_path = binary_path.with_extension("old");
346
347    // Rename current binary to .old
348    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    // Write new binary
359    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        // Set executable permissions on Unix
366        #[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        // Attempt to restore the old binary on write failure
379        if old_path.exists() {
380            let _ = fs::rename(&old_path, binary_path);
381        }
382        return Err(e);
383    }
384
385    // Clean up the old temporary file
386    if old_path.exists() {
387        let _ = fs::remove_file(&old_path);
388    }
389
390    Ok(())
391}
392
393/// Rollback to a previous binary version.
394///
395/// Restores the backup binary to the binary path.
396pub 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    // Set executable permissions on Unix
406    #[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
417// ---------------------------------------------------------------------------
418// Cached manifest preservation
419// ---------------------------------------------------------------------------
420
421/// Read the cached remote manifest bytes if present.
422pub fn read_cached_manifest(path: &Path) -> Option<Vec<u8>> {
423    fs::read(path).ok()
424}
425
426/// Write the cached remote manifest bytes back (preservation after upgrade).
427pub 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
436// ---------------------------------------------------------------------------
437// Full upgrade orchestration
438// ---------------------------------------------------------------------------
439
440/// Run the full upgrade flow.
441///
442/// This is the main entry point for the upgrade command. It:
443/// 1. Checks version compatibility
444/// 2. Fetches release info
445/// 3. Optionally confirms with user
446/// 4. Downloads the new binary
447/// 5. Verifies integrity
448/// 6. Preserves cached manifest
449/// 7. Creates backup
450/// 8. Swaps binary
451/// 9. Runs smoke test
452/// 10. Rolls back on failure
453pub 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    // Step 1: Fetch latest release info
462    let release = release_provider.fetch_latest_release()?;
463
464    // Step 1.1: Skip when the fetched release is not newer than current
465    if !is_release_newer(&current_version.version, &release.version) {
466        return Err(UpgradeError::NoUpgradeAvailable {
467            current: current_version.version.clone(),
468            latest: release.version.clone(),
469        });
470    }
471
472    // Step 2: Check manifest version compatibility
473    check_schema_compatibility(current_version.schema_version, release.schema_version)?;
474
475    // Step 3: Check runtime version compatibility
476    check_version_compatibility(&current_version.version, &release.min_runtime_version)?;
477
478    // Step 4: Interactive confirmation (if applicable)
479    if !options.non_interactive {
480        let confirmed = interactor.confirm_upgrade(&current_version.version, &release.version);
481        if !confirmed {
482            return Err(UpgradeError::Declined);
483        }
484    }
485
486    // Step 5: Download new binary
487    let download = downloader.download(&release)?;
488
489    // Step 6: Verify integrity
490    if !download.integrity_verified {
491        verify_integrity(&download.binary_data, &release.checksum)?;
492    }
493    let new_binary_data = download.binary_data;
494
495    // Step 7: Preserve cached manifest
496    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    // Step 8: Determine binary path and backup directory
503    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    // Step 9: Create backup
513    let backup_path =
514        create_backup(&binary_path, &backup_dir, &current_version.version).map_err(|e| {
515            UpgradeError::IoError {
516                path: binary_path.display().to_string(),
517                reason: e.to_string(),
518            }
519        })?;
520
521    // Step 10: Swap binary
522    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    // Step 11: Run smoke test
528    match smoke_tester.test(&binary_path) {
529        Ok(()) => {
530            // Success — restore cached manifest
531            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            // Rollback on smoke test failure
544            let _ = rollback(&binary_path, &backup_path);
545
546            // Restore cached manifest after rollback
547            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// ===========================================================================
565// Tests
566// ===========================================================================
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    // ---- Version compatibility tests (VAL-UPGR-002, VAL-UPGR-003) ----
573
574    #[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    // ---- Checksum verification tests (VAL-UPGR-010) ----
635
636    #[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    // ---- Backup and rollback tests (VAL-UPGR-004, VAL-UPGR-012) ----
664
665    #[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    // ---- Swap binary test (VAL-UPGR-004) ----
712
713    #[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        // Don't create the file — swap should still work
731
732        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    // ---- Cached manifest preservation tests (VAL-UPGR-006) ----
740
741    #[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        // Write manifest with specific formatting
765        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    // ---- Full upgrade flow tests (VAL-UPGR-001, VAL-UPGR-004, VAL-UPGR-007, VAL-UPGR-011) ----
773
774    /// A mock release provider for testing.
775    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    /// A mock binary downloader for testing.
786    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    /// A mock smoke tester for testing.
800    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    /// A mock user interactor for testing.
811    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(), // Will be set by test
826            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        // Set up initial binary
840        fs::write(&binary_path, b"old binary v0.1.0").unwrap();
841
842        // Set up cached manifest
843        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        // Binary was replaced
884        let content = fs::read(&binary_path).unwrap();
885        assert_eq!(content, new_binary.to_vec());
886
887        // Cached manifest preserved
888        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        // Original binary should be restored
944        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        // Binary should be unchanged
987        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, // Interactive mode
1056                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 }, // User declines
1071        );
1072
1073        assert!(result.is_err());
1074        match result.unwrap_err() {
1075            UpgradeError::Declined => {}
1076            other => panic!("expected Declined, got {other:?}"),
1077        }
1078
1079        // Binary should be unchanged
1080        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        // Binary should be unchanged (integrity check happens before swap)
1127        let content = fs::read(&binary_path).unwrap();
1128        assert_eq!(content, original_content.to_vec());
1129    }
1130
1131    // ---- Version parsing edge cases ----
1132
1133    #[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    // ---- UpgradeResult JSON output test (VAL-UPGR-009) ----
1151
1152    #[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        // Roundtrip
1167        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}