From 53d1647d98560e3677ffb19c8be3bbc885289544 Mon Sep 17 00:00:00 2001 From: Peter Rekdal Khan-Sunde Date: Sun, 12 Apr 2026 00:35:52 +0200 Subject: [PATCH 1/2] fix(core): optimize sparse delta apply path --- crates/surge-core/src/platform/fs.rs | 87 +++++++- crates/surge-core/src/releases/delta/mod.rs | 12 ++ .../src/releases/delta/sparse_ops.rs | 6 + crates/surge-core/src/releases/delta/tests.rs | 66 ++++++ crates/surge-core/src/update/manager.rs | 6 +- crates/surge-core/src/update/manager/apply.rs | 199 +++++++++++++++++- 6 files changed, 365 insertions(+), 11 deletions(-) diff --git a/crates/surge-core/src/platform/fs.rs b/crates/surge-core/src/platform/fs.rs index d08444d..19c98b3 100644 --- a/crates/surge-core/src/platform/fs.rs +++ b/crates/surge-core/src/platform/fs.rs @@ -1,5 +1,6 @@ +use std::collections::BTreeSet; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::error::{Result, SurgeError}; @@ -80,6 +81,90 @@ pub fn copy_directory(src: &Path, dst: &Path) -> Result<()> { Ok(()) } +/// Recursively copy a directory tree, skipping any relative paths in `excluded_relative_paths`. +pub fn copy_directory_filtered(src: &Path, dst: &Path, excluded_relative_paths: &BTreeSet) -> Result<()> { + copy_directory_filtered_recursive(src, dst, Path::new(""), excluded_relative_paths) +} + +fn copy_directory_filtered_recursive( + src: &Path, + dst: &Path, + relative_root: &Path, + excluded_relative_paths: &BTreeSet, +) -> Result<()> { + fs::create_dir_all(dst)?; + + let mut entries = fs::read_dir(src)?.collect::, std::io::Error>>()?; + entries.sort_by_key(std::fs::DirEntry::file_name); + + for entry in entries { + let relative_path = copy_child_relative_path(relative_root, &entry.file_name()); + if excluded_relative_paths.contains(©_relative_path_to_string(&relative_path)) { + continue; + } + + let source = entry.path(); + let target = dst.join(entry.file_name()); + let metadata = fs::symlink_metadata(&source)?; + let file_type = metadata.file_type(); + + if file_type.is_dir() { + copy_directory_filtered_recursive(&source, &target, &relative_path, excluded_relative_paths)?; + } else if file_type.is_symlink() { + copy_symlink(&source, &target)?; + } else if file_type.is_file() { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&source, &target)?; + } else { + return Err(SurgeError::Io(std::io::Error::other(format!( + "unsupported filesystem entry while copying '{}'", + source.display() + )))); + } + } + + Ok(()) +} + +fn copy_child_relative_path(prefix: &Path, child_name: &std::ffi::OsStr) -> PathBuf { + if prefix.as_os_str().is_empty() { + PathBuf::from(child_name) + } else { + prefix.join(child_name) + } +} + +fn copy_relative_path_to_string(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn copy_symlink(source: &Path, dst: &Path) -> Result<()> { + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent)?; + } + let target = fs::read_link(source)?; + create_symlink(&target, dst) +} + +#[cfg(unix)] +fn create_symlink(target: &Path, dst: &Path) -> Result<()> { + std::os::unix::fs::symlink(target, dst)?; + Ok(()) +} + +#[cfg(windows)] +fn create_symlink(target: &Path, dst: &Path) -> Result<()> { + let metadata = fs::metadata(target)?; + if metadata.is_dir() { + std::os::windows::fs::symlink_dir(target, dst)?; + } else { + std::os::windows::fs::symlink_file(target, dst)?; + } + Ok(()) +} + /// Read entire file contents. pub fn read_file(path: &Path) -> Result> { Ok(fs::read(path)?) diff --git a/crates/surge-core/src/releases/delta/mod.rs b/crates/surge-core/src/releases/delta/mod.rs index af8c87e..3123b1a 100644 --- a/crates/surge-core/src/releases/delta/mod.rs +++ b/crates/surge-core/src/releases/delta/mod.rs @@ -21,6 +21,7 @@ pub use self::format::{ has_archive_bsdiff_magic_prefix, has_archive_chunked_magic_prefix, has_sparse_file_ops_magic_prefix, is_supported_delta, patch_format_from_magic_prefix, }; +pub(crate) use self::sparse_ops::apply_sparse_file_patch_to_directory; pub use self::sparse_ops::build_sparse_file_patch; pub fn decode_delta_patch(data: &[u8], delta: &DeltaArtifact) -> Result> { @@ -75,3 +76,14 @@ pub fn apply_delta_patch(older: &[u8], patch: &[u8], delta: &DeltaArtifact) -> R delta.algorithm, delta.patch_format ))) } + +#[must_use] +pub fn is_sparse_file_ops_delta(delta: &DeltaArtifact) -> bool { + let patch_format = normalized_or_default(&delta.patch_format, PATCH_FORMAT_BSDIFF4); + if !patch_format.eq_ignore_ascii_case(PATCH_FORMAT_SPARSE_FILE_OPS_V1) { + return false; + } + + let algorithm = delta.algorithm.trim(); + algorithm.is_empty() || algorithm.eq_ignore_ascii_case(DIFF_ALGORITHM_FILE_OPS) +} diff --git a/crates/surge-core/src/releases/delta/sparse_ops.rs b/crates/surge-core/src/releases/delta/sparse_ops.rs index b9f2700..14043b2 100644 --- a/crates/surge-core/src/releases/delta/sparse_ops.rs +++ b/crates/surge-core/src/releases/delta/sparse_ops.rs @@ -1,4 +1,5 @@ use std::fs; +use std::path::Path; use serde::{Deserialize, Serialize}; @@ -191,6 +192,11 @@ pub(super) fn apply_sparse_file_patch(older: &[u8], patch: &[u8]) -> Result Result<(i32, u32)> { + let (manifest, payloads) = decode_sparse_file_ops_payload(patch)?; + apply_sparse_file_ops(root, &manifest.ops, payloads).map(|()| (manifest.compression_level, manifest.zstd_workers)) +} + fn encode_sparse_file_ops_payload(manifest: &SparseFileDeltaManifest, payloads: &[u8]) -> Result> { let manifest_bytes = serde_json::to_vec(manifest)?; let manifest_len = u64::try_from(manifest_bytes.len()) diff --git a/crates/surge-core/src/releases/delta/tests.rs b/crates/surge-core/src/releases/delta/tests.rs index fb77090..71b9fe6 100644 --- a/crates/surge-core/src/releases/delta/tests.rs +++ b/crates/surge-core/src/releases/delta/tests.rs @@ -198,3 +198,69 @@ fn test_sparse_file_patch_roundtrip_rebuilds_full_archive_bytes() { let rebuilt = apply_delta_patch(&full_v1, &decoded, &delta).unwrap(); assert_eq!(rebuilt, full_v2); } + +#[test] +fn test_sparse_file_patch_can_apply_directly_to_directory() { + let dir = tempfile::tempdir().unwrap(); + let old_dir = dir.path().join("old"); + let new_dir = dir.path().join("new"); + std::fs::create_dir_all(old_dir.join("bin")).unwrap(); + std::fs::create_dir_all(new_dir.join("bin")).unwrap(); + std::fs::create_dir_all(new_dir.join("models")).unwrap(); + std::fs::write(old_dir.join("bin").join("runtime.bin"), vec![b'A'; 256 * 1024]).unwrap(); + std::fs::write(old_dir.join("config.json"), br#"{"version":1}"#).unwrap(); + std::fs::write(new_dir.join("bin").join("runtime.bin"), { + let mut bytes = vec![b'A'; 256 * 1024]; + bytes[2048] = b'B'; + bytes + }) + .unwrap(); + std::fs::write(new_dir.join("config.json"), br#"{"version":2}"#).unwrap(); + std::fs::write(new_dir.join("models").join("model-v2.bin"), vec![b'Z'; 128 * 1024]).unwrap(); + + let mut old_packer = ArchivePacker::new(7).unwrap(); + old_packer.add_directory(&old_dir, "").unwrap(); + let full_v1 = old_packer.finalize().unwrap(); + + let mut new_packer = ArchivePacker::new(7).unwrap(); + new_packer.add_directory(&new_dir, "").unwrap(); + let full_v2 = new_packer.finalize().unwrap(); + + let patch = build_sparse_file_patch( + &full_v1, + &full_v2, + 7, + 0, + &ChunkedDiffOptions { + chunk_size: 64 * 1024, + max_threads: 1, + }, + ) + .unwrap(); + let delta_bytes = zstd::encode_all(patch.as_slice(), 3).unwrap(); + let delta = DeltaArtifact::sparse_file_ops_zstd( + "primary", + "1.0.0", + "demo-1.1.0-delta.tar.zst", + i64::try_from(delta_bytes.len()).unwrap(), + &sha256_hex(&delta_bytes), + ); + assert!(is_sparse_file_ops_delta(&delta)); + + let working_dir = tempfile::tempdir().unwrap(); + crate::platform::fs::copy_directory(&old_dir, working_dir.path()).unwrap(); + + let decoded = decode_delta_patch(&delta_bytes, &delta).unwrap(); + let archive_settings = apply_sparse_file_patch_to_directory(working_dir.path(), &decoded).unwrap(); + assert_eq!(archive_settings, (7, 0)); + + let mut rebuilt_packer = ArchivePacker::new(7).unwrap(); + rebuilt_packer.add_directory(working_dir.path(), "").unwrap(); + let rebuilt = rebuilt_packer.finalize().unwrap(); + + assert_eq!(rebuilt, full_v2); + assert_eq!( + std::fs::read_to_string(working_dir.path().join("config.json")).unwrap(), + r#"{"version":2}"# + ); +} diff --git a/crates/surge-core/src/update/manager.rs b/crates/surge-core/src/update/manager.rs index 8c851f2..8378a3e 100644 --- a/crates/surge-core/src/update/manager.rs +++ b/crates/surge-core/src/update/manager.rs @@ -1710,7 +1710,7 @@ mod tests { os, rid: rid.clone(), is_genesis: false, - full_filename: full_v3_name, + full_filename: full_v3_name.clone(), full_size: full_v3.len() as i64, full_sha256: sha256_hex(&full_v3), deltas: vec![DeltaArtifact::bsdiff_zstd( @@ -1830,7 +1830,7 @@ mod tests { os, rid: rid.clone(), is_genesis: false, - full_filename: full_v3_name, + full_filename: full_v3_name.clone(), full_size: full_v3.len() as i64, full_sha256: sha256_hex(&full_v3), deltas: vec![DeltaArtifact::sparse_file_ops_zstd( @@ -1927,6 +1927,8 @@ mod tests { let cached_current_full = install_root.join(".surge-cache").join("artifacts").join(&full_v2_name); assert!(!cached_current_full.exists()); + let cached_latest_full = install_root.join(".surge-cache").join("artifacts").join(&full_v3_name); + assert!(cached_latest_full.exists()); } #[tokio::test] diff --git a/crates/surge-core/src/update/manager/apply.rs b/crates/surge-core/src/update/manager/apply.rs index 25f115a..729588f 100644 --- a/crates/surge-core/src/update/manager/apply.rs +++ b/crates/surge-core/src/update/manager/apply.rs @@ -12,9 +12,12 @@ use crate::crypto::sha256::sha256_hex; use crate::error::{Result, SurgeError}; use crate::pack::builder::build_canonical_archive_from_directory; use crate::platform::detect::current_rid; -use crate::platform::fs::write_file_atomic; +use crate::platform::fs::{copy_directory_filtered, write_file_atomic}; use crate::releases::artifact_cache::cache_path_for_key; -use crate::releases::delta::{apply_delta_patch, decode_delta_patch, is_supported_delta}; +use crate::releases::delta::{ + apply_delta_patch, apply_sparse_file_patch_to_directory, decode_delta_patch, is_sparse_file_ops_delta, + is_supported_delta, +}; use crate::releases::manifest::{ReleaseEntry, decompress_release_index}; use crate::releases::restore::{ RestoreOptions, find_release_for_version_rid, restore_full_archive_for_version_with_options, @@ -93,6 +96,23 @@ async fn materialize_delta_payload( where F: Fn(ProgressInfo) + Send + Sync, { + if info.apply_releases.iter().all(|release| { + release + .selected_delta() + .is_some_and(|delta| is_sparse_file_ops_delta(&delta)) + }) && find_previous_app_dir(&manager.install_dir, &manager.current_version).is_some() + { + return materialize_sparse_delta_payload_direct( + manager, + info, + staging_dir, + artifact_cache_dir, + extract_dir, + progress, + ) + .await; + } + let apply_delta_started_at = Instant::now(); let apply_delta_total_items = i64::try_from(info.apply_releases.len()).unwrap_or(i64::MAX); let apply_delta_total_bytes = info @@ -188,7 +208,12 @@ where }, ); + let latest = info + .apply_releases + .last() + .ok_or_else(|| SurgeError::Update("No latest release".to_string()))?; let rebuilt_archive_path = staging_dir.join("rebuilt-full.tar.zst"); + cache_rebuilt_full_archive(artifact_cache_dir, latest, &rebuilt_archive)?; tokio::fs::write(&rebuilt_archive_path, &rebuilt_archive).await?; extract_archive_with_progress(&rebuilt_archive_path, extract_dir, progress, 80, 90)?; @@ -200,6 +225,135 @@ where } } +async fn materialize_sparse_delta_payload_direct( + manager: &UpdateManager, + info: &UpdateInfo, + staging_dir: &Path, + artifact_cache_dir: &Path, + extract_dir: &Path, + progress: Option<&Arc>, +) -> Result +where + F: Fn(ProgressInfo) + Send + Sync, +{ + let apply_delta_started_at = Instant::now(); + let apply_delta_total_items = i64::try_from(info.apply_releases.len()).unwrap_or(i64::MAX); + let apply_delta_total_bytes = info + .apply_releases + .iter() + .filter_map(ReleaseEntry::selected_delta) + .fold(0i64, |acc, delta| acc.saturating_add(delta.size.max(0))); + + emit_progress( + progress, + ProgressInfo { + phase: 5, + total_percent: 60, + bytes_total: apply_delta_total_bytes, + items_total: apply_delta_total_items, + ..ProgressInfo::default() + }, + ); + + stage_installed_app_tree_for_sparse_apply(&manager.install_dir, &manager.current_version, extract_dir)?; + + let mut apply_delta_items_done = 0i64; + let mut apply_delta_bytes_done = 0i64; + let mut final_archive_settings: Option<(i32, u32)> = None; + for release in &info.apply_releases { + manager.ctx.check_cancelled()?; + + let Some(delta) = release.selected_delta() else { + return Err(SurgeError::Update(format!( + "Delta update path is missing delta filename for {}", + release.version + ))); + }; + + if !is_sparse_file_ops_delta(&delta) { + return Err(SurgeError::Update(format!( + "Delta {} for {} is not eligible for direct sparse application", + delta.filename, release.version + ))); + } + + let delta_path = staging_dir.join(&delta.filename); + let delta_compressed = tokio::fs::read(&delta_path).await?; + let patch = decode_delta_patch(delta_compressed.as_slice(), &delta) + .map_err(|e| SurgeError::Archive(format!("Failed to decompress delta {}: {e}", delta.filename)))?; + final_archive_settings = Some( + apply_sparse_file_patch_to_directory(extract_dir, &patch) + .map_err(|e| SurgeError::Update(format!("Failed to apply delta {}: {e}", delta.filename)))?, + ); + + apply_delta_items_done = apply_delta_items_done.saturating_add(1); + apply_delta_bytes_done = apply_delta_bytes_done.saturating_add(delta.size.max(0)); + let phase_percent = clamp_progress_percent(apply_delta_items_done, apply_delta_total_items.max(1)); + emit_progress( + progress, + ProgressInfo { + phase: 5, + phase_percent, + total_percent: phase_total_percent(60, 20, phase_percent), + bytes_done: apply_delta_bytes_done, + bytes_total: apply_delta_total_bytes, + items_done: apply_delta_items_done, + items_total: apply_delta_total_items, + speed_bytes_per_sec: average_speed_bytes_per_sec( + u64::try_from(apply_delta_bytes_done.max(0)).unwrap_or(u64::MAX), + apply_delta_started_at, + ), + }, + ); + } + + manager.ctx.check_cancelled()?; + let latest = info + .apply_releases + .last() + .ok_or_else(|| SurgeError::Update("No latest release".to_string()))?; + let (compression_level, zstd_workers) = final_archive_settings.unwrap_or_else(|| { + let budget = manager.ctx.resource_budget(); + (budget.zstd_compression_level, budget.effective_zstd_workers()) + }); + let rebuilt_archive = + build_canonical_archive_from_directory(extract_dir, compression_level, zstd_workers, &BTreeSet::new())?; + if !latest.full_sha256.is_empty() { + let hash = sha256_hex(&rebuilt_archive); + if hash != latest.full_sha256 { + return Err(SurgeError::Update(format!( + "SHA-256 mismatch for rebuilt full archive {}: expected {}, got {hash}", + latest.version, latest.full_sha256 + ))); + } + } + cache_rebuilt_full_archive(artifact_cache_dir, latest, &rebuilt_archive)?; + + emit_progress( + progress, + ProgressInfo { + phase: 5, + phase_percent: 100, + total_percent: 90, + bytes_done: apply_delta_total_bytes, + bytes_total: apply_delta_total_bytes, + items_done: apply_delta_total_items, + items_total: apply_delta_total_items, + speed_bytes_per_sec: average_speed_bytes_per_sec( + u64::try_from(apply_delta_total_bytes.max(0)).unwrap_or(u64::MAX), + apply_delta_started_at, + ), + }, + ); + + let source = extract_dir.join(&info.latest_version); + if source.exists() { + Ok(source) + } else { + Ok(extract_dir.to_path_buf()) + } +} + async fn restore_base_full_archive(manager: &UpdateManager, artifact_cache_dir: &Path) -> Result> { let index = if let Some(cached) = &manager.cached_index { cached.clone() @@ -244,6 +398,30 @@ async fn restore_base_full_archive(manager: &UpdateManager, artifact_cache_dir: } } +fn stage_installed_app_tree_for_sparse_apply( + install_dir: &Path, + current_version: &str, + extract_dir: &Path, +) -> Result<()> { + let app_dir = find_previous_app_dir(install_dir, current_version).ok_or_else(|| { + SurgeError::NotFound(format!( + "No active installed app directory was found for current version {current_version}" + )) + })?; + let excluded_relative_paths = installed_app_archive_exclusions(&app_dir)?; + copy_directory_filtered(&app_dir, extract_dir, &excluded_relative_paths) +} + +fn cache_rebuilt_full_archive(artifact_cache_dir: &Path, release: &ReleaseEntry, archive: &[u8]) -> Result<()> { + let full_filename = release.full_filename.trim(); + if full_filename.is_empty() { + return Ok(()); + } + + let cache_path = cache_path_for_key(artifact_cache_dir, full_filename)?; + write_file_atomic(&cache_path, archive) +} + fn extract_archive_with_progress( archive_path: &Path, extract_dir: &Path, @@ -318,12 +496,7 @@ pub(super) fn synthesize_current_full_archive_from_installed_app( )) })?; - let mut excluded_relative_paths = BTreeSet::new(); - excluded_relative_paths.insert(crate::install::RUNTIME_MANIFEST_RELATIVE_PATH.to_string()); - excluded_relative_paths.insert(crate::install::LEGACY_RUNTIME_MANIFEST_RELATIVE_PATH.to_string()); - if runtime_state_dir_contains_only_manifests(&app_dir)? { - excluded_relative_paths.insert(".surge".to_string()); - } + let excluded_relative_paths = installed_app_archive_exclusions(&app_dir)?; let budget = ctx.resource_budget(); let archive = build_canonical_archive_from_directory( @@ -410,3 +583,13 @@ fn runtime_state_dir_contains_only_manifests(app_dir: &Path) -> Result { Ok(true) } + +fn installed_app_archive_exclusions(app_dir: &Path) -> Result> { + let mut excluded_relative_paths = BTreeSet::new(); + excluded_relative_paths.insert(crate::install::RUNTIME_MANIFEST_RELATIVE_PATH.to_string()); + excluded_relative_paths.insert(crate::install::LEGACY_RUNTIME_MANIFEST_RELATIVE_PATH.to_string()); + if runtime_state_dir_contains_only_manifests(app_dir)? { + excluded_relative_paths.insert(".surge".to_string()); + } + Ok(excluded_relative_paths) +} From 50c818c1e99d344842a474622a4717d6dc2c43f5 Mon Sep 17 00:00:00 2001 From: Peter Rekdal Khan-Sunde Date: Sun, 12 Apr 2026 00:48:44 +0200 Subject: [PATCH 2/2] fix(core): restore pristine sparse delta basis --- crates/surge-core/src/platform/fs.rs | 87 +--------- crates/surge-core/src/releases/delta/tests.rs | 20 ++- crates/surge-core/src/update/manager.rs | 153 ++++++++++++++++++ crates/surge-core/src/update/manager/apply.rs | 24 +-- 4 files changed, 178 insertions(+), 106 deletions(-) diff --git a/crates/surge-core/src/platform/fs.rs b/crates/surge-core/src/platform/fs.rs index 19c98b3..d08444d 100644 --- a/crates/surge-core/src/platform/fs.rs +++ b/crates/surge-core/src/platform/fs.rs @@ -1,6 +1,5 @@ -use std::collections::BTreeSet; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; use crate::error::{Result, SurgeError}; @@ -81,90 +80,6 @@ pub fn copy_directory(src: &Path, dst: &Path) -> Result<()> { Ok(()) } -/// Recursively copy a directory tree, skipping any relative paths in `excluded_relative_paths`. -pub fn copy_directory_filtered(src: &Path, dst: &Path, excluded_relative_paths: &BTreeSet) -> Result<()> { - copy_directory_filtered_recursive(src, dst, Path::new(""), excluded_relative_paths) -} - -fn copy_directory_filtered_recursive( - src: &Path, - dst: &Path, - relative_root: &Path, - excluded_relative_paths: &BTreeSet, -) -> Result<()> { - fs::create_dir_all(dst)?; - - let mut entries = fs::read_dir(src)?.collect::, std::io::Error>>()?; - entries.sort_by_key(std::fs::DirEntry::file_name); - - for entry in entries { - let relative_path = copy_child_relative_path(relative_root, &entry.file_name()); - if excluded_relative_paths.contains(©_relative_path_to_string(&relative_path)) { - continue; - } - - let source = entry.path(); - let target = dst.join(entry.file_name()); - let metadata = fs::symlink_metadata(&source)?; - let file_type = metadata.file_type(); - - if file_type.is_dir() { - copy_directory_filtered_recursive(&source, &target, &relative_path, excluded_relative_paths)?; - } else if file_type.is_symlink() { - copy_symlink(&source, &target)?; - } else if file_type.is_file() { - if let Some(parent) = target.parent() { - fs::create_dir_all(parent)?; - } - fs::copy(&source, &target)?; - } else { - return Err(SurgeError::Io(std::io::Error::other(format!( - "unsupported filesystem entry while copying '{}'", - source.display() - )))); - } - } - - Ok(()) -} - -fn copy_child_relative_path(prefix: &Path, child_name: &std::ffi::OsStr) -> PathBuf { - if prefix.as_os_str().is_empty() { - PathBuf::from(child_name) - } else { - prefix.join(child_name) - } -} - -fn copy_relative_path_to_string(path: &Path) -> String { - path.to_string_lossy().replace('\\', "/") -} - -fn copy_symlink(source: &Path, dst: &Path) -> Result<()> { - if let Some(parent) = dst.parent() { - fs::create_dir_all(parent)?; - } - let target = fs::read_link(source)?; - create_symlink(&target, dst) -} - -#[cfg(unix)] -fn create_symlink(target: &Path, dst: &Path) -> Result<()> { - std::os::unix::fs::symlink(target, dst)?; - Ok(()) -} - -#[cfg(windows)] -fn create_symlink(target: &Path, dst: &Path) -> Result<()> { - let metadata = fs::metadata(target)?; - if metadata.is_dir() { - std::os::windows::fs::symlink_dir(target, dst)?; - } else { - std::os::windows::fs::symlink_file(target, dst)?; - } - Ok(()) -} - /// Read entire file contents. pub fn read_file(path: &Path) -> Result> { Ok(fs::read(path)?) diff --git a/crates/surge-core/src/releases/delta/tests.rs b/crates/surge-core/src/releases/delta/tests.rs index 71b9fe6..5109b12 100644 --- a/crates/surge-core/src/releases/delta/tests.rs +++ b/crates/surge-core/src/releases/delta/tests.rs @@ -217,6 +217,13 @@ fn test_sparse_file_patch_can_apply_directly_to_directory() { .unwrap(); std::fs::write(new_dir.join("config.json"), br#"{"version":2}"#).unwrap(); std::fs::write(new_dir.join("models").join("model-v2.bin"), vec![b'Z'; 128 * 1024]).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(old_dir.join("bin"), std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::set_permissions(new_dir.join("bin"), std::fs::Permissions::from_mode(0o700)).unwrap(); + } let mut old_packer = ArchivePacker::new(7).unwrap(); old_packer.add_directory(&old_dir, "").unwrap(); @@ -248,7 +255,7 @@ fn test_sparse_file_patch_can_apply_directly_to_directory() { assert!(is_sparse_file_ops_delta(&delta)); let working_dir = tempfile::tempdir().unwrap(); - crate::platform::fs::copy_directory(&old_dir, working_dir.path()).unwrap(); + crate::archive::extractor::extract_to(&full_v1, working_dir.path(), None).unwrap(); let decoded = decode_delta_patch(&delta_bytes, &delta).unwrap(); let archive_settings = apply_sparse_file_patch_to_directory(working_dir.path(), &decoded).unwrap(); @@ -263,4 +270,15 @@ fn test_sparse_file_patch_can_apply_directly_to_directory() { std::fs::read_to_string(working_dir.path().join("config.json")).unwrap(), r#"{"version":2}"# ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mode = std::fs::metadata(working_dir.path().join("bin")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o700); + } } diff --git a/crates/surge-core/src/update/manager.rs b/crates/surge-core/src/update/manager.rs index 8378a3e..28e5a80 100644 --- a/crates/surge-core/src/update/manager.rs +++ b/crates/surge-core/src/update/manager.rs @@ -1931,6 +1931,159 @@ mod tests { assert!(cached_latest_full.exists()); } + #[tokio::test] + async fn test_download_and_apply_sparse_delta_uses_pristine_base_when_persistent_assets_diverge() { + let tmp = tempfile::tempdir().unwrap(); + let store_root = tmp.path().join("store"); + let install_root = tmp.path().join("install"); + let app_id = "test-app"; + std::fs::create_dir_all(&store_root).unwrap(); + std::fs::create_dir_all(&install_root).unwrap(); + let app_store = app_scoped_store_root(&store_root, app_id); + + let rid = current_rid(); + let os = current_os_label_for_tests(); + + let source_v2 = tmp.path().join("source-v2"); + let source_v3 = tmp.path().join("source-v3"); + std::fs::create_dir_all(&source_v2).unwrap(); + std::fs::create_dir_all(&source_v3).unwrap(); + std::fs::write(source_v2.join("payload.txt"), "v2 payload").unwrap(); + std::fs::write(source_v2.join("settings.json"), r#"{"theme":"light"}"#).unwrap(); + std::fs::write(source_v3.join("payload.txt"), "v3 payload").unwrap(); + std::fs::write(source_v3.join("settings.json"), r#"{"theme":"light"}"#).unwrap(); + + let mut packer_v2 = ArchivePacker::new(3).unwrap(); + packer_v2.add_directory(&source_v2, "").unwrap(); + let full_v2 = packer_v2.finalize().unwrap(); + + let mut packer_v3 = ArchivePacker::new(3).unwrap(); + packer_v3.add_directory(&source_v3, "").unwrap(); + let full_v3 = packer_v3.finalize().unwrap(); + + let patch_v3 = build_sparse_file_patch(&full_v2, &full_v3, 3, 0, &ChunkedDiffOptions::default()).unwrap(); + let delta_v3 = zstd::encode_all(patch_v3.as_slice(), 3).unwrap(); + + let full_v2_name = format!("{app_id}-1.1.0-{rid}-full.tar.zst"); + let full_v3_name = format!("{app_id}-1.2.0-{rid}-full.tar.zst"); + let delta_v3_name = format!("{app_id}-1.2.0-{rid}-delta.tar.zst"); + + std::fs::write(app_store.join(&full_v2_name), &full_v2).unwrap(); + std::fs::write(app_store.join(&delta_v3_name), &delta_v3).unwrap(); + + let persistent_assets = vec!["settings.json".to_string()]; + let index = ReleaseIndex { + app_id: app_id.to_string(), + releases: vec![ + ReleaseEntry { + version: "1.1.0".to_string(), + channels: vec!["stable".to_string()], + os: os.clone(), + rid: rid.clone(), + is_genesis: true, + full_filename: full_v2_name.clone(), + full_size: full_v2.len() as i64, + full_sha256: sha256_hex(&full_v2), + deltas: Vec::new(), + preferred_delta_id: String::new(), + created_utc: chrono::Utc::now().to_rfc3339(), + release_notes: String::new(), + name: String::new(), + main_exe: app_id.to_string(), + install_directory: app_id.to_string(), + supervisor_id: String::new(), + icon: String::new(), + shortcuts: Vec::new(), + persistent_assets: persistent_assets.clone(), + installers: Vec::new(), + environment: std::collections::BTreeMap::new(), + }, + ReleaseEntry { + version: "1.2.0".to_string(), + channels: vec!["stable".to_string()], + os, + rid: rid.clone(), + is_genesis: false, + full_filename: full_v3_name.clone(), + full_size: full_v3.len() as i64, + full_sha256: sha256_hex(&full_v3), + deltas: vec![DeltaArtifact::sparse_file_ops_zstd( + "primary", + "1.1.0", + &delta_v3_name, + delta_v3.len() as i64, + &sha256_hex(&delta_v3), + )], + preferred_delta_id: "primary".to_string(), + created_utc: chrono::Utc::now().to_rfc3339(), + release_notes: String::new(), + name: String::new(), + main_exe: app_id.to_string(), + install_directory: app_id.to_string(), + supervisor_id: String::new(), + icon: String::new(), + shortcuts: Vec::new(), + persistent_assets, + installers: Vec::new(), + environment: std::collections::BTreeMap::new(), + }, + ], + ..ReleaseIndex::default() + }; + + write_app_scoped_release_index(&store_root, app_id, &index); + + let active_app_dir = install_root.join("app"); + std::fs::create_dir_all(active_app_dir.join(".surge")).unwrap(); + std::fs::write(active_app_dir.join("payload.txt"), "v2 payload").unwrap(); + std::fs::write(active_app_dir.join("settings.json"), r#"{"theme":"dark"}"#).unwrap(); + std::fs::write( + active_app_dir.join(crate::install::RUNTIME_MANIFEST_RELATIVE_PATH), + format!("id: {app_id}\nversion: 1.1.0\n"), + ) + .unwrap(); + std::fs::write( + active_app_dir.join(crate::install::LEGACY_RUNTIME_MANIFEST_RELATIVE_PATH), + format!("id: {app_id}\nversion: 1.1.0\n"), + ) + .unwrap(); + + let ctx = Arc::new(Context::new()); + ctx.set_storage( + StorageProvider::Filesystem, + store_root.to_str().unwrap(), + "", + "", + "", + "", + ); + + let mut manager = UpdateManager::new(ctx, app_id, "1.1.0", "stable", install_root.to_str().unwrap()).unwrap(); + let info = manager.check_for_updates().await.unwrap().unwrap(); + assert_eq!(info.apply_strategy, ApplyStrategy::Delta); + manager + .download_and_apply(&info, None::) + .await + .unwrap(); + + let installed_payload = std::fs::read_to_string(install_root.join("app").join("payload.txt")).unwrap(); + assert_eq!(installed_payload, "v3 payload"); + let installed_settings = std::fs::read_to_string(install_root.join("app").join("settings.json")).unwrap(); + assert_eq!(installed_settings, r#"{"theme":"dark"}"#); + + let cached_latest_full = install_root.join(".surge-cache").join("artifacts").join(&full_v3_name); + assert!(cached_latest_full.exists()); + let extracted_cached = tempfile::tempdir().unwrap(); + crate::archive::extractor::extract_to( + &std::fs::read(&cached_latest_full).unwrap(), + extracted_cached.path(), + None, + ) + .unwrap(); + let cached_settings = std::fs::read_to_string(extracted_cached.path().join("settings.json")).unwrap(); + assert_eq!(cached_settings, r#"{"theme":"light"}"#); + } + #[tokio::test] async fn test_download_and_apply_delta_prefers_app_scoped_release_index_lineage() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/surge-core/src/update/manager/apply.rs b/crates/surge-core/src/update/manager/apply.rs index 729588f..60e6ce9 100644 --- a/crates/surge-core/src/update/manager/apply.rs +++ b/crates/surge-core/src/update/manager/apply.rs @@ -5,14 +5,14 @@ use std::time::Instant; use tracing::{debug, warn}; -use crate::archive::extractor::extract_file_to_with_progress; +use crate::archive::extractor::{extract_file_to_with_progress, extract_to}; use crate::config::constants::RELEASES_FILE_COMPRESSED; use crate::context::Context; use crate::crypto::sha256::sha256_hex; use crate::error::{Result, SurgeError}; use crate::pack::builder::build_canonical_archive_from_directory; use crate::platform::detect::current_rid; -use crate::platform::fs::{copy_directory_filtered, write_file_atomic}; +use crate::platform::fs::write_file_atomic; use crate::releases::artifact_cache::cache_path_for_key; use crate::releases::delta::{ apply_delta_patch, apply_sparse_file_patch_to_directory, decode_delta_patch, is_sparse_file_ops_delta, @@ -100,8 +100,7 @@ where release .selected_delta() .is_some_and(|delta| is_sparse_file_ops_delta(&delta)) - }) && find_previous_app_dir(&manager.install_dir, &manager.current_version).is_some() - { + }) { return materialize_sparse_delta_payload_direct( manager, info, @@ -255,7 +254,8 @@ where }, ); - stage_installed_app_tree_for_sparse_apply(&manager.install_dir, &manager.current_version, extract_dir)?; + let base_archive = restore_base_full_archive(manager, artifact_cache_dir).await?; + extract_to(&base_archive, extract_dir, None)?; let mut apply_delta_items_done = 0i64; let mut apply_delta_bytes_done = 0i64; @@ -398,20 +398,6 @@ async fn restore_base_full_archive(manager: &UpdateManager, artifact_cache_dir: } } -fn stage_installed_app_tree_for_sparse_apply( - install_dir: &Path, - current_version: &str, - extract_dir: &Path, -) -> Result<()> { - let app_dir = find_previous_app_dir(install_dir, current_version).ok_or_else(|| { - SurgeError::NotFound(format!( - "No active installed app directory was found for current version {current_version}" - )) - })?; - let excluded_relative_paths = installed_app_archive_exclusions(&app_dir)?; - copy_directory_filtered(&app_dir, extract_dir, &excluded_relative_paths) -} - fn cache_rebuilt_full_archive(artifact_cache_dir: &Path, release: &ReleaseEntry, archive: &[u8]) -> Result<()> { let full_filename = release.full_filename.trim(); if full_filename.is_empty() {