use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VersionInfo {
pub version: String,
pub schema_version: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ReleaseInfo {
pub version: String,
pub download_url: String,
pub checksum: String,
pub min_runtime_version: String,
pub schema_version: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DownloadResult {
pub binary_data: Vec<u8>,
pub integrity_verified: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UpgradeResult {
pub previous_version: String,
pub new_version: String,
pub status: UpgradeStatus,
pub backup_path: PathBuf,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum UpgradeStatus {
Success,
RolledBack,
Refused,
}
#[derive(Debug, Clone, Default)]
pub struct UpgradeOptions {
pub non_interactive: bool,
pub binary_path: Option<PathBuf>,
pub backup_dir: Option<PathBuf>,
pub cached_manifest_path: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum UpgradeError {
IncompatibleRuntime { current: String, required: String },
RuntimeTooOld {
current_schema: u32,
manifest_schema: u32,
},
IntegrityCheckFailed { expected: String, actual: String },
SmokeTestFailed { reason: String },
DownloadFailed { reason: String },
Declined,
NoUpgradeAvailable { current: String, latest: String },
IoError { path: String, reason: String },
}
impl std::fmt::Display for UpgradeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UpgradeError::IncompatibleRuntime { current, required } => {
write!(
f,
"incompatible runtime: current {current} does not meet required {required}"
)
}
UpgradeError::RuntimeTooOld {
current_schema,
manifest_schema,
} => {
write!(
f,
"runtime too old: current schema v{current_schema} cannot parse manifest schema v{manifest_schema}; manual upgrade required"
)
}
UpgradeError::IntegrityCheckFailed { expected, actual } => {
write!(
f,
"integrity check failed: expected checksum {expected}, got {actual}"
)
}
UpgradeError::SmokeTestFailed { reason } => {
write!(f, "smoke test failed: {reason}")
}
UpgradeError::DownloadFailed { reason } => {
write!(f, "download failed: {reason}")
}
UpgradeError::Declined => {
write!(f, "upgrade declined by user")
}
UpgradeError::NoUpgradeAvailable { current, latest } => {
write!(
f,
"no upgrade available: current version {current} is already >= latest {latest}"
)
}
UpgradeError::IoError { path, reason } => {
write!(f, "I/O error at {path}: {reason}")
}
}
}
}
impl std::error::Error for UpgradeError {}
pub trait ReleaseProvider {
fn fetch_latest_release(&self) -> std::result::Result<ReleaseInfo, UpgradeError>;
}
pub trait BinaryDownloader {
fn download(&self, release: &ReleaseInfo) -> std::result::Result<DownloadResult, UpgradeError>;
}
pub trait SmokeTester {
fn test(&self, binary_path: &Path) -> std::result::Result<(), UpgradeError>;
}
pub trait UserInteractor {
fn confirm_upgrade(&self, current: &str, target: &str) -> bool;
}
fn parse_version_parts(version: &str) -> Option<(u32, u32, u32)> {
let base = version.split('-').next()?;
let parts: Vec<&str> = base.split('.').collect();
if parts.len() != 3 {
return None;
}
Some((
parts[0].parse().ok()?,
parts[1].parse().ok()?,
parts[2].parse().ok()?,
))
}
pub fn check_version_compatibility(
current: &str,
required: &str,
) -> std::result::Result<(), UpgradeError> {
let cur = parse_version_parts(current).unwrap_or((0, 0, 0));
let req = parse_version_parts(required).unwrap_or((0, 0, 0));
if cur < req {
return Err(UpgradeError::IncompatibleRuntime {
current: current.to_string(),
required: required.to_string(),
});
}
Ok(())
}
pub fn check_schema_compatibility(
current_schema: u32,
manifest_schema: u32,
) -> std::result::Result<(), UpgradeError> {
if current_schema < manifest_schema {
return Err(UpgradeError::RuntimeTooOld {
current_schema,
manifest_schema,
});
}
Ok(())
}
pub fn compute_checksum(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
let result = hasher.finalize();
result
.iter()
.map(|b| format!("{:02x}", b))
.collect::<String>()
}
pub fn verify_integrity(
data: &[u8],
expected_checksum: &str,
) -> std::result::Result<(), UpgradeError> {
let actual = compute_checksum(data);
if actual == expected_checksum {
Ok(())
} else {
Err(UpgradeError::IntegrityCheckFailed {
expected: expected_checksum.to_string(),
actual,
})
}
}
pub fn default_backup_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".mlstack")
.join("backups")
}
pub fn default_cached_manifest_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".mlstack")
.join("cache")
.join("remote_manifest.json")
}
pub fn current_binary_path() -> Result<PathBuf> {
std::env::current_exe().context("failed to determine current executable path")
}
pub fn create_backup(source: &Path, backup_dir: &Path, version: &str) -> Result<PathBuf> {
fs::create_dir_all(backup_dir).with_context(|| {
format!(
"failed to create backup directory: {}",
backup_dir.display()
)
})?;
let backup_name = format!("rusty-stack-{version}.bak");
let backup_path = backup_dir.join(&backup_name);
fs::copy(source, &backup_path).with_context(|| {
format!(
"failed to backup binary from {} to {}",
source.display(),
backup_path.display()
)
})?;
Ok(backup_path)
}
pub fn swap_binary(binary_path: &Path, new_data: &[u8]) -> Result<()> {
let old_path = binary_path.with_extension("old");
if binary_path.exists() {
fs::rename(binary_path, &old_path).with_context(|| {
format!(
"failed to rename {} to {}",
binary_path.display(),
old_path.display()
)
})?;
}
let write_result = (|| -> Result<()> {
let mut f = fs::File::create(binary_path)
.with_context(|| format!("failed to create new binary at {}", binary_path.display()))?;
f.write_all(new_data)
.with_context(|| format!("failed to write new binary at {}", binary_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(binary_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(binary_path, perms)?;
}
Ok(())
})();
if let Err(e) = write_result {
if old_path.exists() {
let _ = fs::rename(&old_path, binary_path);
}
return Err(e);
}
if old_path.exists() {
let _ = fs::remove_file(&old_path);
}
Ok(())
}
pub fn rollback(binary_path: &Path, backup_path: &Path) -> Result<()> {
fs::copy(backup_path, binary_path).with_context(|| {
format!(
"failed to rollback binary from {} to {}",
backup_path.display(),
binary_path.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(binary_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(binary_path, perms)?;
}
Ok(())
}
pub fn read_cached_manifest(path: &Path) -> Option<Vec<u8>> {
fs::read(path).ok()
}
pub fn write_cached_manifest(path: &Path, data: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create cache directory: {}", parent.display()))?;
}
fs::write(path, data)
.with_context(|| format!("failed to write cached manifest to {}", path.display()))
}
pub fn run_upgrade(
current_version: &VersionInfo,
options: &UpgradeOptions,
release_provider: &dyn ReleaseProvider,
downloader: &dyn BinaryDownloader,
smoke_tester: &dyn SmokeTester,
interactor: &dyn UserInteractor,
) -> std::result::Result<UpgradeResult, UpgradeError> {
let release = release_provider.fetch_latest_release()?;
if !is_release_newer(¤t_version.version, &release.version) {
return Err(UpgradeError::NoUpgradeAvailable {
current: current_version.version.clone(),
latest: release.version.clone(),
});
}
check_schema_compatibility(current_version.schema_version, release.schema_version)?;
check_version_compatibility(¤t_version.version, &release.min_runtime_version)?;
if !options.non_interactive {
let confirmed = interactor.confirm_upgrade(¤t_version.version, &release.version);
if !confirmed {
return Err(UpgradeError::Declined);
}
}
let download = downloader.download(&release)?;
if !download.integrity_verified {
verify_integrity(&download.binary_data, &release.checksum)?;
}
let new_binary_data = download.binary_data;
let cached_manifest_path = options
.cached_manifest_path
.clone()
.unwrap_or_else(default_cached_manifest_path);
let cached_manifest_data = read_cached_manifest(&cached_manifest_path);
let binary_path = options
.binary_path
.clone()
.unwrap_or_else(|| current_binary_path().unwrap_or_else(|_| PathBuf::from("rusty-stack")));
let backup_dir = options
.backup_dir
.clone()
.unwrap_or_else(default_backup_dir);
let backup_path =
create_backup(&binary_path, &backup_dir, ¤t_version.version).map_err(|e| {
UpgradeError::IoError {
path: binary_path.display().to_string(),
reason: e.to_string(),
}
})?;
swap_binary(&binary_path, &new_binary_data).map_err(|e| UpgradeError::IoError {
path: binary_path.display().to_string(),
reason: e.to_string(),
})?;
match smoke_tester.test(&binary_path) {
Ok(()) => {
if let Some(data) = cached_manifest_data {
let _ = write_cached_manifest(&cached_manifest_path, &data);
}
Ok(UpgradeResult {
previous_version: current_version.version.clone(),
new_version: release.version.clone(),
status: UpgradeStatus::Success,
backup_path,
})
}
Err(smoke_err) => {
let _ = rollback(&binary_path, &backup_path);
if let Some(data) = cached_manifest_data {
let _ = write_cached_manifest(&cached_manifest_path, &data);
}
Err(UpgradeError::SmokeTestFailed {
reason: smoke_err.to_string(),
})
}
}
}
fn is_release_newer(current: &str, latest: &str) -> bool {
let cur = parse_version_parts(current).unwrap_or((0, 0, 0));
let lat = parse_version_parts(latest).unwrap_or((0, 0, 0));
lat > cur
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_compat_equal_versions() {
assert!(check_version_compatibility("1.0.0", "1.0.0").is_ok());
}
#[test]
fn test_version_compat_newer_current() {
assert!(check_version_compatibility("2.0.0", "1.0.0").is_ok());
}
#[test]
fn test_version_compat_older_current_refused() {
let result = check_version_compatibility("1.0.0", "2.0.0");
assert!(result.is_err());
match result.unwrap_err() {
UpgradeError::IncompatibleRuntime { current, required } => {
assert_eq!(current, "1.0.0");
assert_eq!(required, "2.0.0");
}
other => panic!("expected IncompatibleRuntime, got {other:?}"),
}
}
#[test]
fn test_version_compat_minor_older_refused() {
let result = check_version_compatibility("1.0.0", "1.1.0");
assert!(result.is_err());
}
#[test]
fn test_version_compat_patch_newer_ok() {
assert!(check_version_compatibility("1.0.1", "1.0.0").is_ok());
}
#[test]
fn test_schema_compat_equal() {
assert!(check_schema_compatibility(2, 2).is_ok());
}
#[test]
fn test_schema_compat_newer_runtime_ok() {
assert!(check_schema_compatibility(3, 2).is_ok());
}
#[test]
fn test_schema_compat_older_runtime_refused() {
let result = check_schema_compatibility(1, 3);
assert!(result.is_err());
match result.unwrap_err() {
UpgradeError::RuntimeTooOld {
current_schema,
manifest_schema,
} => {
assert_eq!(current_schema, 1);
assert_eq!(manifest_schema, 3);
}
other => panic!("expected RuntimeTooOld, got {other:?}"),
}
}
#[test]
fn test_checksum_valid() {
let data = b"hello world";
let expected = compute_checksum(data);
assert!(verify_integrity(data, &expected).is_ok());
}
#[test]
fn test_checksum_tampered_binary_rejected() {
let data = b"original binary content";
let expected = compute_checksum(data);
let tampered = b"tampered binary content";
let result = verify_integrity(tampered, &expected);
assert!(result.is_err());
match result.unwrap_err() {
UpgradeError::IntegrityCheckFailed { .. } => {}
other => panic!("expected IntegrityCheckFailed, got {other:?}"),
}
}
#[test]
fn test_checksum_empty_data() {
let data = b"";
let expected = compute_checksum(data);
assert!(verify_integrity(data, &expected).is_ok());
}
#[test]
fn test_backup_creates_file() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("rusty-stack");
fs::write(&source, b"binary content").unwrap();
let backup_dir = dir.path().join("backups");
let backup = create_backup(&source, &backup_dir, "0.1.0").unwrap();
assert!(backup.exists());
assert_eq!(fs::read(&backup).unwrap(), b"binary content".to_vec());
assert_eq!(backup.file_name().unwrap(), "rusty-stack-0.1.0.bak");
}
#[test]
fn test_backup_preserves_content() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("rusty-stack");
let content = b"\x00\x01\x02\xff binary data with null bytes";
fs::write(&source, content).unwrap();
let backup_dir = dir.path().join("backups");
let backup = create_backup(&source, &backup_dir, "1.2.3").unwrap();
let backup_content = fs::read(&backup).unwrap();
assert_eq!(backup_content, content.to_vec());
}
#[test]
fn test_rollback_restores_binary() {
let dir = tempfile::tempdir().unwrap();
let binary_path = dir.path().join("rusty-stack");
let backup_path = dir.path().join("rusty-stack-0.1.0.bak");
let original_content = b"original binary";
let new_content = b"new binary that fails smoke test";
fs::write(&binary_path, new_content).unwrap();
fs::write(&backup_path, original_content).unwrap();
rollback(&binary_path, &backup_path).unwrap();
let restored = fs::read(&binary_path).unwrap();
assert_eq!(restored, original_content.to_vec());
}
#[test]
fn test_swap_binary_replaces_content() {
let dir = tempfile::tempdir().unwrap();
let binary_path = dir.path().join("rusty-stack");
fs::write(&binary_path, b"old binary").unwrap();
let new_data = b"new binary";
swap_binary(&binary_path, new_data).unwrap();
let content = fs::read(&binary_path).unwrap();
assert_eq!(content, new_data.to_vec());
}
#[test]
fn test_swap_binary_creates_if_missing() {
let dir = tempfile::tempdir().unwrap();
let binary_path = dir.path().join("rusty-stack");
let new_data = b"brand new binary";
swap_binary(&binary_path, new_data).unwrap();
let content = fs::read(&binary_path).unwrap();
assert_eq!(content, new_data.to_vec());
}
#[test]
fn test_cached_manifest_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cache").join("remote_manifest.json");
let data = b"{\"schema_version\": 2, \"components\": []}";
write_cached_manifest(&path, data).unwrap();
let read_data = read_cached_manifest(&path).unwrap();
assert_eq!(read_data, data.to_vec());
}
#[test]
fn test_cached_manifest_missing_returns_none() {
let path = PathBuf::from("/nonexistent/path/manifest.json");
assert!(read_cached_manifest(&path).is_none());
}
#[test]
fn test_cached_manifest_preserves_exact_bytes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("manifest.json");
let original = b"{\n \"schema_version\": 2,\n \"components\": []\n}";
write_cached_manifest(&path, original).unwrap();
let restored = read_cached_manifest(&path).unwrap();
assert_eq!(restored, original.to_vec());
}
struct MockReleaseProvider {
release: std::result::Result<ReleaseInfo, UpgradeError>,
}
impl ReleaseProvider for MockReleaseProvider {
fn fetch_latest_release(&self) -> std::result::Result<ReleaseInfo, UpgradeError> {
self.release.clone()
}
}
struct MockDownloader {
data: std::result::Result<DownloadResult, UpgradeError>,
}
impl BinaryDownloader for MockDownloader {
fn download(
&self,
_release: &ReleaseInfo,
) -> std::result::Result<DownloadResult, UpgradeError> {
self.data.clone()
}
}
struct MockSmokeTester {
result: std::result::Result<(), UpgradeError>,
}
impl SmokeTester for MockSmokeTester {
fn test(&self, _binary_path: &Path) -> std::result::Result<(), UpgradeError> {
self.result.clone()
}
}
struct MockInteractor {
confirmed: bool,
}
impl UserInteractor for MockInteractor {
fn confirm_upgrade(&self, _current: &str, _target: &str) -> bool {
self.confirmed
}
}
fn make_release_info(version: &str, min_runtime: &str, schema: u32) -> ReleaseInfo {
ReleaseInfo {
version: version.to_string(),
download_url: format!("https://example.com/rusty-stack-{version}"),
checksum: String::new(), min_runtime_version: min_runtime.to_string(),
schema_version: schema,
}
}
#[test]
fn test_full_upgrade_success() {
let dir = tempfile::tempdir().unwrap();
let binary_path = dir.path().join("rusty-stack");
let backup_dir = dir.path().join("backups");
let cache_dir = dir.path().join("cache");
let cached_manifest = cache_dir.join("remote_manifest.json");
fs::write(&binary_path, b"old binary v0.1.0").unwrap();
let manifest_data = b"{\"schema_version\": 2}";
write_cached_manifest(&cached_manifest, manifest_data).unwrap();
let new_binary = b"new binary v0.2.0";
let checksum = compute_checksum(new_binary);
let mut release = make_release_info("0.2.0", "0.1.0", 2);
release.checksum = checksum;
let result = run_upgrade(
&VersionInfo {
version: "0.1.0".to_string(),
schema_version: 2,
},
&UpgradeOptions {
non_interactive: true,
binary_path: Some(binary_path.clone()),
backup_dir: Some(backup_dir.clone()),
cached_manifest_path: Some(cached_manifest.clone()),
},
&MockReleaseProvider {
release: Ok(release),
},
&MockDownloader {
data: Ok(DownloadResult {
binary_data: new_binary.to_vec(),
integrity_verified: false,
}),
},
&MockSmokeTester { result: Ok(()) },
&MockInteractor { confirmed: true },
);
assert!(result.is_ok(), "upgrade should succeed");
let upgrade_result = result.unwrap();
assert_eq!(upgrade_result.previous_version, "0.1.0");
assert_eq!(upgrade_result.new_version, "0.2.0");
assert_eq!(upgrade_result.status, UpgradeStatus::Success);
assert!(upgrade_result.backup_path.exists());
let content = fs::read(&binary_path).unwrap();
assert_eq!(content, new_binary.to_vec());
let restored_manifest = read_cached_manifest(&cached_manifest).unwrap();
assert_eq!(restored_manifest, manifest_data.to_vec());
}
#[test]
fn test_upgrade_rollback_on_smoke_test_failure() {
let dir = tempfile::tempdir().unwrap();
let binary_path = dir.path().join("rusty-stack");
let backup_dir = dir.path().join("backups");
let original_content = b"original binary v0.1.0";
fs::write(&binary_path, original_content).unwrap();
let new_binary = b"broken new binary";
let checksum = compute_checksum(new_binary);
let mut release = make_release_info("0.2.0", "0.1.0", 2);
release.checksum = checksum;
let result = run_upgrade(
&VersionInfo {
version: "0.1.0".to_string(),
schema_version: 2,
},
&UpgradeOptions {
non_interactive: true,
binary_path: Some(binary_path.clone()),
backup_dir: Some(backup_dir),
cached_manifest_path: None,
},
&MockReleaseProvider {
release: Ok(release),
},
&MockDownloader {
data: Ok(DownloadResult {
binary_data: new_binary.to_vec(),
integrity_verified: false,
}),
},
&MockSmokeTester {
result: Err(UpgradeError::SmokeTestFailed {
reason: "binary exited with code 1".to_string(),
}),
},
&MockInteractor { confirmed: true },
);
assert!(result.is_err(), "upgrade should fail on smoke test");
match result.unwrap_err() {
UpgradeError::SmokeTestFailed { reason } => {
assert!(reason.contains("binary exited with code 1"));
}
other => panic!("expected SmokeTestFailed, got {other:?}"),
}
let content = fs::read(&binary_path).unwrap();
assert_eq!(content, original_content.to_vec());
}
#[test]
fn test_upgrade_refused_incompatible_runtime() {
let dir = tempfile::tempdir().unwrap();
let binary_path = dir.path().join("rusty-stack");
fs::write(&binary_path, b"old binary").unwrap();
let release = make_release_info("2.0.0", "2.0.0", 2);
let result = run_upgrade(
&VersionInfo {
version: "1.0.0".to_string(),
schema_version: 2,
},
&UpgradeOptions {
non_interactive: true,
binary_path: Some(binary_path.clone()),
backup_dir: Some(dir.path().join("backups")),
cached_manifest_path: None,
},
&MockReleaseProvider {
release: Ok(release),
},
&MockDownloader {
data: Ok(DownloadResult {
binary_data: vec![],
integrity_verified: false,
}),
},
&MockSmokeTester { result: Ok(()) },
&MockInteractor { confirmed: true },
);
assert!(result.is_err());
match result.unwrap_err() {
UpgradeError::IncompatibleRuntime { .. } => {}
other => panic!("expected IncompatibleRuntime, got {other:?}"),
}
let content = fs::read(&binary_path).unwrap();
assert_eq!(content, b"old binary".to_vec());
}
#[test]
fn test_upgrade_refused_runtime_too_old_for_manifest() {
let dir = tempfile::tempdir().unwrap();
let binary_path = dir.path().join("rusty-stack");
fs::write(&binary_path, b"old binary v1 schema").unwrap();
let release = make_release_info("0.2.0", "0.1.0", 3);
let result = run_upgrade(
&VersionInfo {
version: "0.1.0".to_string(),
schema_version: 1,
},
&UpgradeOptions {
non_interactive: true,
binary_path: Some(binary_path.clone()),
backup_dir: Some(dir.path().join("backups")),
cached_manifest_path: None,
},
&MockReleaseProvider {
release: Ok(release),
},
&MockDownloader {
data: Ok(DownloadResult {
binary_data: vec![],
integrity_verified: false,
}),
},
&MockSmokeTester { result: Ok(()) },
&MockInteractor { confirmed: true },
);
assert!(result.is_err());
match result.unwrap_err() {
UpgradeError::RuntimeTooOld {
current_schema,
manifest_schema,
} => {
assert_eq!(current_schema, 1);
assert_eq!(manifest_schema, 3);
}
other => panic!("expected RuntimeTooOld, got {other:?}"),
}
}
#[test]
fn test_upgrade_declined_in_interactive_mode() {
let dir = tempfile::tempdir().unwrap();
let binary_path = dir.path().join("rusty-stack");
let original_content = b"original binary";
fs::write(&binary_path, original_content).unwrap();
let new_binary = b"new binary";
let checksum = compute_checksum(new_binary);
let mut release = make_release_info("0.2.0", "0.1.0", 2);
release.checksum = checksum;
let result = run_upgrade(
&VersionInfo {
version: "0.1.0".to_string(),
schema_version: 2,
},
&UpgradeOptions {
non_interactive: false, binary_path: Some(binary_path.clone()),
backup_dir: Some(dir.path().join("backups")),
cached_manifest_path: None,
},
&MockReleaseProvider {
release: Ok(release),
},
&MockDownloader {
data: Ok(DownloadResult {
binary_data: new_binary.to_vec(),
integrity_verified: false,
}),
},
&MockSmokeTester { result: Ok(()) },
&MockInteractor { confirmed: false }, );
assert!(result.is_err());
match result.unwrap_err() {
UpgradeError::Declined => {}
other => panic!("expected Declined, got {other:?}"),
}
let content = fs::read(&binary_path).unwrap();
assert_eq!(content, original_content.to_vec());
}
#[test]
fn test_upgrade_integrity_failure_preserves_binary() {
let dir = tempfile::tempdir().unwrap();
let binary_path = dir.path().join("rusty-stack");
let original_content = b"original binary";
fs::write(&binary_path, original_content).unwrap();
let new_binary = b"corrupted new binary";
let mut release = make_release_info("0.2.0", "0.1.0", 2);
release.checksum = "wrong_checksum_value".to_string();
let result = run_upgrade(
&VersionInfo {
version: "0.1.0".to_string(),
schema_version: 2,
},
&UpgradeOptions {
non_interactive: true,
binary_path: Some(binary_path.clone()),
backup_dir: Some(dir.path().join("backups")),
cached_manifest_path: None,
},
&MockReleaseProvider {
release: Ok(release),
},
&MockDownloader {
data: Ok(DownloadResult {
binary_data: new_binary.to_vec(),
integrity_verified: false,
}),
},
&MockSmokeTester { result: Ok(()) },
&MockInteractor { confirmed: true },
);
assert!(result.is_err());
match result.unwrap_err() {
UpgradeError::IntegrityCheckFailed { .. } => {}
other => panic!("expected IntegrityCheckFailed, got {other:?}"),
}
let content = fs::read(&binary_path).unwrap();
assert_eq!(content, original_content.to_vec());
}
#[test]
fn test_parse_version_parts_standard() {
assert_eq!(parse_version_parts("1.2.3"), Some((1, 2, 3)));
}
#[test]
fn test_parse_version_parts_prerelease() {
assert_eq!(parse_version_parts("1.2.3-rc1"), Some((1, 2, 3)));
}
#[test]
fn test_parse_version_parts_invalid() {
assert_eq!(parse_version_parts("invalid"), None);
assert_eq!(parse_version_parts("1.2"), None);
assert_eq!(parse_version_parts(""), None);
}
#[test]
fn test_upgrade_result_serializes_to_json() {
let result = UpgradeResult {
previous_version: "0.1.0".to_string(),
new_version: "0.2.0".to_string(),
status: UpgradeStatus::Success,
backup_path: PathBuf::from("/home/user/.mlstack/backups/rusty-stack-0.1.0.bak"),
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"previous_version\":\"0.1.0\""));
assert!(json.contains("\"new_version\":\"0.2.0\""));
assert!(json.contains("\"status\":\"success\""));
let back: UpgradeResult = serde_json::from_str(&json).unwrap();
assert_eq!(back, result);
}
#[test]
fn test_upgrade_result_rolled_back_serializes() {
let result = UpgradeResult {
previous_version: "0.1.0".to_string(),
new_version: "0.2.0".to_string(),
status: UpgradeStatus::RolledBack,
backup_path: PathBuf::from("/tmp/backup"),
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"status\":\"rolled_back\""));
}
#[test]
fn test_upgrade_result_refused_serializes() {
let result = UpgradeResult {
previous_version: "0.1.0".to_string(),
new_version: "0.2.0".to_string(),
status: UpgradeStatus::Refused,
backup_path: PathBuf::from("/tmp/backup"),
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"status\":\"refused\""));
}
}