From a187aeb3382e09a5382a5c783dfcb09d5060069f Mon Sep 17 00:00:00 2001 From: G4614 <92488762+G4614@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:13:34 +0800 Subject: [PATCH] fix(export): stop shipping the guest rootfs disk in box archives The guest rootfs disk is a thin COW overlay over the host-global guest rootfs cache, keyed by the bootstrap image plus guest binary version. It holds no user state, and clone and snapshot-restore already treat it as disposable: when it is absent, the next start recreates the overlay from the local cache. Export was the exception. It flattened the overlay into the archive, which both bloated every archive with a host-independent blob and stripped the backing reference, so an imported box would boot from the archived copy instead of the importing host's correctly-versioned cache. Export now omits it and import never installs one, even from an older archive. No archive version bump: the disk was already optional, since a never-started box exported without it, so old importers handle its absence and new importers ignore its presence. guest_disk_checksum stays on the manifest, always empty, so older importers still parse it. Co-Authored-By: Claude Opus 5 --- src/boxlite/src/litebox/archive.rs | 17 ++-- src/boxlite/src/litebox/clone_export.rs | 107 ++++++++++++++++++------ src/boxlite/src/runtime/import.rs | 34 +++----- 3 files changed, 99 insertions(+), 59 deletions(-) diff --git a/src/boxlite/src/litebox/archive.rs b/src/boxlite/src/litebox/archive.rs index c6ae9391b..bedcda8d5 100644 --- a/src/boxlite/src/litebox/archive.rs +++ b/src/boxlite/src/litebox/archive.rs @@ -77,11 +77,13 @@ pub struct ArchiveManifest { // ── Build ─────────────────────────────────────────────────────────────── /// Build a zstd-compressed tar archive. +/// +/// Carries the manifest and the container disk only. The guest rootfs disk is +/// not exported — see `do_export_flatten`. pub(crate) fn build_zstd_tar_archive( output_path: &Path, manifest_path: &Path, container_disk: &Path, - guest_disk: Option<&Path>, compression_level: i32, ) -> BoxliteResult<()> { let file = std::fs::File::create(output_path).map_err(|e| { @@ -96,7 +98,7 @@ pub(crate) fn build_zstd_tar_archive( .map_err(|e| BoxliteError::Storage(format!("Failed to create zstd encoder: {}", e)))?; let mut builder = tar::Builder::new(encoder); - append_archive_files(&mut builder, manifest_path, container_disk, guest_disk)?; + append_archive_files(&mut builder, manifest_path, container_disk)?; let encoder = builder .into_inner() @@ -112,7 +114,6 @@ fn append_archive_files( builder: &mut tar::Builder, manifest_path: &Path, container_disk: &Path, - guest_disk: Option<&Path>, ) -> BoxliteResult<()> { builder .append_path_with_name(manifest_path, MANIFEST_FILENAME) @@ -124,14 +125,6 @@ fn append_archive_files( BoxliteError::Storage(format!("Failed to add container disk to archive: {}", e)) })?; - if let Some(guest) = guest_disk { - builder - .append_path_with_name(guest, disk_filenames::GUEST_ROOTFS_DISK) - .map_err(|e| { - BoxliteError::Storage(format!("Failed to add guest rootfs disk to archive: {}", e)) - })?; - } - Ok(()) } @@ -458,7 +451,7 @@ mod tests { std::fs::write(&manifest_path, r#"{"version":2}"#).unwrap(); std::fs::write(&container_path, "fake-container-disk").unwrap(); - build_zstd_tar_archive(&archive_path, &manifest_path, &container_path, None, 3).unwrap(); + build_zstd_tar_archive(&archive_path, &manifest_path, &container_path, 3).unwrap(); extract_archive(&archive_path, &extract_dir).unwrap(); assert_eq!( diff --git a/src/boxlite/src/litebox/clone_export.rs b/src/boxlite/src/litebox/clone_export.rs index 15fe3f8d9..365fa915c 100644 --- a/src/boxlite/src/litebox/clone_export.rs +++ b/src/boxlite/src/litebox/clone_export.rs @@ -229,12 +229,20 @@ impl BoxImpl { struct FlattenResult { temp_dir: tempfile::TempDir, flat_container: std::path::PathBuf, - flat_guest: Option, flatten_ms: u64, } -/// Phase 1: Flatten qcow2 disk chains into standalone images. +/// Phase 1: Flatten the container disk chain into a standalone image. /// Runs inside the quiesce bracket — this is the only part that needs disk consistency. +/// +/// The guest rootfs disk is deliberately not exported. It is a thin COW overlay +/// over the host-global guest rootfs cache (`bases/{id}.ext4`, keyed by the +/// bootstrap image + guest binary version), holds no user state, and is +/// recreated from the importing host's own cache on first start — the same way +/// clone and snapshot-restore already treat it. Shipping it would both bloat the +/// archive with a host-independent blob and, because flattening strips its +/// backing reference, make the imported box boot from the archived copy instead +/// of the importing host's correctly-versioned cache. fn do_export_flatten( box_home: &std::path::Path, runtime_layout: &crate::runtime::layout::FilesystemLayout, @@ -244,7 +252,6 @@ fn do_export_flatten( let disks_dir = box_home.join("disks"); let container_disk = disks_dir.join(disk_filenames::CONTAINER_DISK); - let guest_disk = disks_dir.join(disk_filenames::GUEST_ROOTFS_DISK); if !container_disk.exists() { return Err(BoxliteError::Storage(format!( @@ -259,20 +266,11 @@ fn do_export_flatten( let t_flatten = Instant::now(); let flat_container = temp_dir.path().join(disk_filenames::CONTAINER_DISK); Qcow2Helper::flatten(&container_disk, &flat_container)?; - - let flat_guest = if guest_disk.exists() { - let flat = temp_dir.path().join(disk_filenames::GUEST_ROOTFS_DISK); - Qcow2Helper::flatten(&guest_disk, &flat)?; - Some(flat) - } else { - None - }; let flatten_ms = t_flatten.elapsed().as_millis() as u64; Ok(FlattenResult { temp_dir, flat_container, - flat_guest, flatten_ms, }) } @@ -300,10 +298,6 @@ fn do_export_finalize( let t_checksum = Instant::now(); let container_disk_checksum = sha256_file(&flatten.flat_container)?; - let guest_disk_checksum = match flatten.flat_guest { - Some(ref fg) => sha256_file(fg)?, - None => String::new(), - }; let checksum_ms = t_checksum.elapsed().as_millis() as u64; let image = match &config_options.rootfs { @@ -316,7 +310,9 @@ fn do_export_finalize( box_name: config_name.map(|s| s.to_string()), image, box_options: Some(config_options.clone()), - guest_disk_checksum, + // Kept for wire compatibility with importers that still expect the + // field; the guest rootfs disk is no longer exported. + guest_disk_checksum: String::new(), container_disk_checksum, exported_at: chrono::Utc::now().to_rfc3339(), }; @@ -327,13 +323,7 @@ fn do_export_finalize( std::fs::write(&manifest_path, manifest_json)?; let t_archive = Instant::now(); - build_zstd_tar_archive( - &output_path, - &manifest_path, - &flatten.flat_container, - flatten.flat_guest.as_deref(), - 3, - )?; + build_zstd_tar_archive(&output_path, &manifest_path, &flatten.flat_container, 3)?; let archive_ms = t_archive.elapsed().as_millis() as u64; tracing::info!( @@ -347,3 +337,72 @@ fn do_export_finalize( Ok(crate::runtime::options::BoxArchive::new(output_path)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::layout::{FilesystemLayout, FsLayoutConfig}; + + /// Entry paths inside a built `.boxlite` archive. + fn archive_entry_names(archive_path: &std::path::Path) -> Vec { + let file = std::fs::File::open(archive_path).expect("open archive"); + let decoder = zstd::Decoder::new(file).expect("zstd decoder"); + let mut archive = tar::Archive::new(decoder); + archive + .entries() + .expect("read entries") + .map(|e| { + e.expect("entry") + .path() + .expect("entry path") + .to_string_lossy() + .into_owned() + }) + .collect() + } + + /// The guest rootfs disk is host-global state that the importing host + /// rebuilds from its own version-keyed cache, so it must never travel + /// inside an archive — shipping it also lets the archived copy win over + /// that cache, since flattening strips its backing reference. + #[test] + fn export_omits_the_guest_rootfs_disk() { + let home = tempfile::tempdir_in("/tmp").expect("home dir"); + let layout = FilesystemLayout::new(home.path().to_path_buf(), FsLayoutConfig::default()); + std::fs::create_dir_all(layout.temp_dir()).expect("temp dir"); + + // A box home carrying both disks, as any started box does. + let box_home = home.path().join("box"); + let disks = box_home.join("disks"); + std::fs::create_dir_all(&disks).expect("disks dir"); + Qcow2Helper::create_disk(&disks.join(disk_filenames::CONTAINER_DISK), true) + .expect("container disk") + .leak(); + Qcow2Helper::create_disk(&disks.join(disk_filenames::GUEST_ROOTFS_DISK), true) + .expect("guest disk") + .leak(); + + let flattened = do_export_flatten(&box_home, &layout).expect("flatten"); + let dest = home.path().join("out.boxlite"); + let archive = do_export_finalize( + flattened, + Some("some-box"), + &crate::runtime::options::BoxOptions::default(), + "box-id", + &dest, + ) + .expect("finalize"); + + let entries = archive_entry_names(archive.path()); + assert!( + entries.iter().any(|e| e == disk_filenames::CONTAINER_DISK), + "archive must carry the container disk, got {entries:?}" + ); + assert!( + !entries + .iter() + .any(|e| e == disk_filenames::GUEST_ROOTFS_DISK), + "archive must not carry the guest rootfs disk, got {entries:?}" + ); + } +} diff --git a/src/boxlite/src/runtime/import.rs b/src/boxlite/src/runtime/import.rs index 76f427429..e960a18b4 100644 --- a/src/boxlite/src/runtime/import.rs +++ b/src/boxlite/src/runtime/import.rs @@ -174,21 +174,21 @@ fn extract_and_validate( } } - let extracted_guest = temp_dir.path().join(disk_filenames::GUEST_ROOTFS_DISK); - if extracted_guest.exists() && !manifest.guest_disk_checksum.is_empty() { - let actual = sha256_file(&extracted_guest)?; - if actual != manifest.guest_disk_checksum { - return Err(BoxliteError::Storage(format!( - "Guest disk checksum mismatch: expected {}, got {}", - manifest.guest_disk_checksum, actual - ))); - } - } + // A guest rootfs disk carried by an older archive is ignored, so it is + // neither checksummed nor installed — see `install_disks`. Ok((manifest, temp_dir)) } -/// Validate disk security and move disks into box_home/disks/. +/// Validate disk security and move the container disk into box_home/disks/. +/// +/// The guest rootfs disk is never installed, even when an older archive carries +/// one. It holds no user state, and letting an archived copy win would bypass +/// the importing host's own version-keyed guest rootfs cache: export flattens +/// the overlay, so the archived disk has no backing reference and +/// `validate_reusable_guest_rootfs_disk` would accept it verbatim. Leaving it +/// absent makes the next start rebuild the overlay from the local cache, which +/// is what clone and snapshot-restore already do. fn install_disks(temp_dir: &Path, box_home: &Path) -> BoxliteResult<()> { // Security: Reject imported disks that reference backing files. // A crafted archive could include a qcow2 with a backing reference to @@ -196,11 +196,6 @@ fn install_disks(temp_dir: &Path, box_home: &Path) -> BoxliteResult<()> { let extracted_container = temp_dir.join(disk_filenames::CONTAINER_DISK); validate_no_backing_references(&extracted_container)?; - let extracted_guest = temp_dir.join(disk_filenames::GUEST_ROOTFS_DISK); - if extracted_guest.exists() { - validate_no_backing_references(&extracted_guest)?; - } - let disks_dir = box_home.join("disks"); std::fs::create_dir_all(&disks_dir).map_err(|e| { BoxliteError::Storage(format!( @@ -215,13 +210,6 @@ fn install_disks(temp_dir: &Path, box_home: &Path) -> BoxliteResult<()> { &disks_dir.join(disk_filenames::CONTAINER_DISK), )?; - if extracted_guest.exists() { - move_file( - &extracted_guest, - &disks_dir.join(disk_filenames::GUEST_ROOTFS_DISK), - )?; - } - Ok(()) }