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    use std::sync::atomic::{AtomicBool, Ordering};
572
573    // ---- Version compatibility tests (VAL-UPGR-002, VAL-UPGR-003) ----
574
575    #[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    // ---- Checksum verification tests (VAL-UPGR-010) ----
636
637    #[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    // ---- Backup and rollback tests (VAL-UPGR-004, VAL-UPGR-012) ----
665
666    #[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    // ---- Swap binary test (VAL-UPGR-004) ----
713
714    #[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        // Don't create the file — swap should still work
732
733        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    // ---- Cached manifest preservation tests (VAL-UPGR-006) ----
741
742    #[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        // Write manifest with specific formatting
766        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    // ---- Full upgrade flow tests (VAL-UPGR-001, VAL-UPGR-004, VAL-UPGR-007, VAL-UPGR-011) ----
774
775    /// A mock release provider for testing.
776    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    /// A mock binary downloader for testing.
787    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    /// A mock smoke tester for testing.
801    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    /// A mock user interactor for testing.
812    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    /// A smoke tester that records whether it was called.
823    #[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(), // Will be set by test
841            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        // Set up initial binary
855        fs::write(&binary_path, b"old binary v0.1.0").unwrap();
856
857        // Set up cached manifest
858        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        // Binary was replaced
899        let content = fs::read(&binary_path).unwrap();
900        assert_eq!(content, new_binary.to_vec());
901
902        // Cached manifest preserved
903        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        // Original binary should be restored
959        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        // Binary should be unchanged
1002        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, // Interactive mode
1071                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 }, // User declines
1086        );
1087
1088        assert!(result.is_err());
1089        match result.unwrap_err() {
1090            UpgradeError::Declined => {}
1091            other => panic!("expected Declined, got {other:?}"),
1092        }
1093
1094        // Binary should be unchanged
1095        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        // Binary should be unchanged (integrity check happens before swap)
1142        let content = fs::read(&binary_path).unwrap();
1143        assert_eq!(content, original_content.to_vec());
1144    }
1145
1146    // ---- Version parsing edge cases ----
1147
1148    #[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    // ---- UpgradeResult JSON output test (VAL-UPGR-009) ----
1166
1167    #[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        // Roundtrip
1182        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}