Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 5 additions & 12 deletions src/boxlite/src/litebox/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand All @@ -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()
Expand All @@ -112,7 +114,6 @@ fn append_archive_files<W: Write>(
builder: &mut tar::Builder<W>,
manifest_path: &Path,
container_disk: &Path,
guest_disk: Option<&Path>,
) -> BoxliteResult<()> {
builder
.append_path_with_name(manifest_path, MANIFEST_FILENAME)
Expand All @@ -124,14 +125,6 @@ fn append_archive_files<W: Write>(
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(())
}

Expand Down Expand Up @@ -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!(
Expand Down
107 changes: 83 additions & 24 deletions src/boxlite/src/litebox/clone_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,12 +229,20 @@ impl BoxImpl {
struct FlattenResult {
temp_dir: tempfile::TempDir,
flat_container: std::path::PathBuf,
flat_guest: Option<std::path::PathBuf>,
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,
Expand All @@ -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!(
Expand All @@ -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,
})
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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(),
};
Expand All @@ -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!(
Expand All @@ -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<String> {
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:?}"
);
}
}
34 changes: 11 additions & 23 deletions src/boxlite/src/runtime/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,33 +174,28 @@ 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
// /etc/shadow or another box's disk, leaking data on first read.
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!(
Expand All @@ -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(())
}

Expand Down
Loading