use std::io::Write;
use std::path::Path;
use boxlite_shared::errors::{BoxliteError, BoxliteResult};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::disk::constants::filenames as disk_filenames;
pub(crate) const MANIFEST_FILENAME: &str = "manifest.json";
pub(crate) const ARCHIVE_VERSION: u32 = 3;
pub(crate) const CAPABILITY_POLICY_ARCHIVE_VERSION: u32 = 4;
pub(crate) const PUBLISHED_PORTS_ARCHIVE_VERSION: u32 = 5;
pub(crate) const MAX_SUPPORTED_VERSION: u32 = PUBLISHED_PORTS_ARCHIVE_VERSION;
pub(crate) fn archive_version_for_options(options: &crate::runtime::options::BoxOptions) -> u32 {
if !options.ports.is_empty() {
PUBLISHED_PORTS_ARCHIVE_VERSION
} else if options.advanced.capabilities().is_none() {
ARCHIVE_VERSION
} else {
CAPABILITY_POLICY_ARCHIVE_VERSION
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ArchiveManifest {
pub version: u32,
pub box_name: Option<String>,
pub image: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub box_options: Option<crate::runtime::options::BoxOptions>,
pub guest_disk_checksum: String,
pub container_disk_checksum: String,
pub exported_at: String,
}
pub(crate) fn build_zstd_tar_archive(
output_path: &Path,
manifest_path: &Path,
container_disk: &Path,
compression_level: i32,
) -> BoxliteResult<()> {
let file = std::fs::File::create(output_path).map_err(|e| {
BoxliteError::Storage(format!(
"Failed to create archive file {}: {}",
output_path.display(),
e
))
})?;
let encoder = zstd::Encoder::new(file, compression_level)
.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)?;
let encoder = builder
.into_inner()
.map_err(|e| BoxliteError::Storage(format!("Failed to finalize tar: {}", e)))?;
encoder
.finish()
.map_err(|e| BoxliteError::Storage(format!("Failed to finish zstd compression: {}", e)))?;
Ok(())
}
fn append_archive_files<W: Write>(
builder: &mut tar::Builder<W>,
manifest_path: &Path,
container_disk: &Path,
) -> BoxliteResult<()> {
builder
.append_path_with_name(manifest_path, MANIFEST_FILENAME)
.map_err(|e| BoxliteError::Storage(format!("Failed to add manifest to archive: {}", e)))?;
builder
.append_path_with_name(container_disk, disk_filenames::CONTAINER_DISK)
.map_err(|e| {
BoxliteError::Storage(format!("Failed to add container disk to archive: {}", e))
})?;
Ok(())
}
const ZSTD_MAGIC: [u8; 4] = [0x28, 0xB5, 0x2F, 0xFD];
pub(crate) fn extract_archive(archive_path: &Path, dest_dir: &Path) -> BoxliteResult<()> {
use std::io::Read;
let mut file = std::fs::File::open(archive_path).map_err(|e| {
BoxliteError::Storage(format!(
"Failed to open archive {}: {}",
archive_path.display(),
e
))
})?;
let mut magic = [0u8; 4];
file.read_exact(&mut magic).map_err(|e| {
BoxliteError::Storage(format!(
"Failed to read archive header {}: {}",
archive_path.display(),
e
))
})?;
drop(file);
let file = std::fs::File::open(archive_path).map_err(|e| {
BoxliteError::Storage(format!(
"Failed to reopen archive {}: {}",
archive_path.display(),
e
))
})?;
if magic == ZSTD_MAGIC {
let decoder = zstd::Decoder::new(file)
.map_err(|e| BoxliteError::Storage(format!("Failed to create zstd decoder: {}", e)))?;
unpack_file_members(decoder, dest_dir)
} else {
unpack_file_members(file, dest_dir)
}
}
fn carries_file_contents(entry_type: tar::EntryType) -> bool {
matches!(
entry_type,
tar::EntryType::Regular | tar::EntryType::Continuous | tar::EntryType::GNUSparse
)
}
fn unpack_file_members<R: std::io::Read>(reader: R, dest_dir: &Path) -> BoxliteResult<()> {
let mut archive = tar::Archive::new(reader);
let entries = archive
.entries()
.map_err(|e| BoxliteError::Storage(format!("Failed to read archive: {}", e)))?;
for entry in entries {
let mut entry = entry
.map_err(|e| BoxliteError::Storage(format!("Failed to read archive member: {}", e)))?;
let entry_type = entry.header().entry_type();
if !carries_file_contents(entry_type) {
let name = entry
.path()
.map(|path| path.display().to_string())
.unwrap_or_else(|_| "<unreadable>".to_string());
return Err(BoxliteError::Storage(format!(
"Invalid archive: member '{}' is {:?}, only files are allowed",
name, entry_type
)));
}
entry
.unpack_in(dest_dir)
.map_err(|e| BoxliteError::Storage(format!("Failed to extract archive: {}", e)))?;
}
Ok(())
}
pub(crate) fn move_file(src: &Path, dst: &Path) -> BoxliteResult<()> {
match std::fs::rename(src, dst) {
Ok(()) => Ok(()),
Err(e) if e.raw_os_error() == Some(libc::EXDEV) => {
std::fs::copy(src, dst).map_err(|e| {
BoxliteError::Storage(format!(
"Failed to copy {} to {}: {}",
src.display(),
dst.display(),
e
))
})?;
std::fs::remove_file(src).map_err(|e| {
BoxliteError::Storage(format!(
"Failed to remove source after cross-fs copy {}: {}",
src.display(),
e
))
})?;
Ok(())
}
Err(e) => Err(BoxliteError::Storage(format!(
"Failed to move {} to {}: {}",
src.display(),
dst.display(),
e
))),
}
}
pub(crate) fn sha256_file(path: &Path) -> BoxliteResult<String> {
use std::io::Read;
let mut file = std::fs::File::open(path).map_err(|e| {
BoxliteError::Storage(format!(
"Failed to open {} for checksum: {}",
path.display(),
e
))
})?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = file.read(&mut buf).map_err(|e| {
BoxliteError::Storage(format!(
"Failed to read {} for checksum: {}",
path.display(),
e
))
})?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(format!("sha256:{}", hex::encode(hasher.finalize())))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn configuration_an_older_importer_would_drop_raises_the_archive_version() {
assert_eq!(ARCHIVE_VERSION, 3);
assert_eq!(CAPABILITY_POLICY_ARCHIVE_VERSION, 4);
assert_eq!(PUBLISHED_PORTS_ARCHIVE_VERSION, 5);
let ordinary = crate::runtime::options::BoxOptions::default();
assert_eq!(archive_version_for_options(&ordinary), ARCHIVE_VERSION);
let mut custom_advanced = crate::runtime::advanced_options::AdvancedBoxOptions::default();
custom_advanced
.set_capabilities(Some(
crate::runtime::advanced_options::ContainerCapabilities {
drop: vec!["NET_RAW".into()],
..Default::default()
},
))
.unwrap();
let custom = crate::runtime::options::BoxOptions {
advanced: custom_advanced,
..Default::default()
};
assert_eq!(
archive_version_for_options(&custom),
CAPABILITY_POLICY_ARCHIVE_VERSION
);
let mut explicit_empty_advanced =
crate::runtime::advanced_options::AdvancedBoxOptions::default();
explicit_empty_advanced
.set_capabilities(Some(
crate::runtime::advanced_options::ContainerCapabilities::default(),
))
.unwrap();
let explicit_empty = crate::runtime::options::BoxOptions {
advanced: explicit_empty_advanced,
..Default::default()
};
assert_eq!(
archive_version_for_options(&explicit_empty),
CAPABILITY_POLICY_ARCHIVE_VERSION
);
for ports in [
vec![crate::runtime::options::PortSpec {
host_port: None,
guest_port: 3000,
protocol: crate::runtime::options::PortProtocol::Tcp,
host_ip: None,
}],
vec![crate::runtime::options::PortSpec {
host_port: Some(18080),
guest_port: 80,
protocol: crate::runtime::options::PortProtocol::Tcp,
host_ip: Some("127.0.0.1".to_string()),
}],
] {
let published = crate::runtime::options::BoxOptions {
ports,
..Default::default()
};
assert_eq!(
archive_version_for_options(&published),
PUBLISHED_PORTS_ARCHIVE_VERSION
);
}
}
#[test]
fn this_builds_port_exports_are_never_canonicalized_on_import() {
let published = crate::runtime::options::BoxOptions {
ports: vec![crate::runtime::options::PortSpec {
host_port: Some(18080),
guest_port: 80,
protocol: crate::runtime::options::PortProtocol::Tcp,
host_ip: Some("127.0.0.1".to_string()),
}],
..Default::default()
};
assert!(
archive_version_for_options(&published) >= PUBLISHED_PORTS_ARCHIVE_VERSION,
"an export carrying ports must be stamped at or above the version \
below which the importer rewrites them"
);
}
#[test]
fn test_extract_zstd_archive_via_magic_bytes() {
let dir = tempdir().unwrap();
let archive_path = dir.path().join("test.boxlite");
let extract_dir = dir.path().join("extracted");
std::fs::create_dir_all(&extract_dir).unwrap();
let test_content = b"hello from zstd archive";
let test_file = dir.path().join("test.txt");
std::fs::write(&test_file, test_content).unwrap();
{
let file = std::fs::File::create(&archive_path).unwrap();
let encoder = zstd::Encoder::new(file, 3).unwrap();
let mut builder = tar::Builder::new(encoder);
builder
.append_path_with_name(&test_file, "test.txt")
.unwrap();
let encoder = builder.into_inner().unwrap();
encoder.finish().unwrap();
}
let header = std::fs::read(&archive_path).unwrap();
assert_eq!(&header[..4], &ZSTD_MAGIC);
extract_archive(&archive_path, &extract_dir).unwrap();
let content = std::fs::read_to_string(extract_dir.join("test.txt")).unwrap();
assert_eq!(content, "hello from zstd archive");
}
#[test]
fn test_extract_plain_tar_via_magic_bytes() {
let dir = tempdir().unwrap();
let archive_path = dir.path().join("test.tar");
let extract_dir = dir.path().join("extracted");
std::fs::create_dir_all(&extract_dir).unwrap();
let test_file = dir.path().join("test.txt");
std::fs::write(&test_file, b"hello from plain tar").unwrap();
{
let file = std::fs::File::create(&archive_path).unwrap();
let mut builder = tar::Builder::new(file);
builder
.append_path_with_name(&test_file, "test.txt")
.unwrap();
builder.finish().unwrap();
}
let header = std::fs::read(&archive_path).unwrap();
assert_ne!(&header[..4], &ZSTD_MAGIC);
extract_archive(&archive_path, &extract_dir).unwrap();
let content = std::fs::read_to_string(extract_dir.join("test.txt")).unwrap();
assert_eq!(content, "hello from plain tar");
}
#[test]
fn test_move_file_same_filesystem() {
let dir = tempdir().unwrap();
let src = dir.path().join("src.txt");
let dst = dir.path().join("dst.txt");
std::fs::write(&src, "move me").unwrap();
move_file(&src, &dst).unwrap();
assert!(!src.exists());
assert_eq!(std::fs::read_to_string(&dst).unwrap(), "move me");
}
#[test]
fn test_move_file_nonexistent_source_errors() {
let dir = tempdir().unwrap();
let src = dir.path().join("nonexistent.txt");
let dst = dir.path().join("dst.txt");
assert!(move_file(&src, &dst).is_err());
}
#[test]
fn test_sha256_file_deterministic() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.bin");
std::fs::write(&path, b"deterministic content").unwrap();
let hash1 = sha256_file(&path).unwrap();
let hash2 = sha256_file(&path).unwrap();
assert_eq!(hash1, hash2);
assert!(hash1.starts_with("sha256:"));
}
#[test]
fn test_build_and_extract_roundtrip() {
let dir = tempdir().unwrap();
let archive_path = dir.path().join("roundtrip.boxlite");
let extract_dir = dir.path().join("extracted");
std::fs::create_dir_all(&extract_dir).unwrap();
let manifest_path = dir.path().join(MANIFEST_FILENAME);
let container_path = dir.path().join("container.qcow2");
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, 3).unwrap();
extract_archive(&archive_path, &extract_dir).unwrap();
assert_eq!(
std::fs::read_to_string(extract_dir.join(MANIFEST_FILENAME)).unwrap(),
r#"{"version":2}"#
);
assert_eq!(
std::fs::read_to_string(extract_dir.join(disk_filenames::CONTAINER_DISK)).unwrap(),
"fake-container-disk",
"the container disk must be extracted"
);
assert!(
!extract_dir.join(disk_filenames::GUEST_ROOTFS_DISK).exists(),
"the archive must not contain a guest rootfs member"
);
}
#[test]
fn extract_archive_rejects_symlink_entry() {
let dir = tempdir().unwrap();
let archive_path = dir.path().join("evil.boxlite");
let extract_dir = dir.path().join("extracted");
std::fs::create_dir_all(&extract_dir).unwrap();
let victim = dir.path().join("victim-disk.qcow2");
std::fs::write(&victim, b"victim bytes").unwrap();
{
let file = std::fs::File::create(&archive_path).unwrap();
let mut builder = tar::Builder::new(file);
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Symlink);
header.set_size(0);
header.set_mode(0o777);
builder
.append_link(&mut header, disk_filenames::CONTAINER_DISK, &victim)
.unwrap();
builder.finish().unwrap();
}
let error = extract_archive(&archive_path, &extract_dir)
.expect_err("a symlink archive member must be rejected");
assert!(
error.to_string().contains(disk_filenames::CONTAINER_DISK),
"the error must name the offending member, got: {error}"
);
assert!(
extract_dir
.join(disk_filenames::CONTAINER_DISK)
.symlink_metadata()
.is_err(),
"the link must never reach the filesystem"
);
}
#[test]
fn extract_archive_rejects_hardlink_entry() {
let dir = tempdir().unwrap();
let archive_path = dir.path().join("evil.boxlite");
let extract_dir = dir.path().join("extracted");
std::fs::create_dir_all(&extract_dir).unwrap();
let manifest = dir.path().join(MANIFEST_FILENAME);
std::fs::write(&manifest, br#"{"version":3}"#).unwrap();
{
let file = std::fs::File::create(&archive_path).unwrap();
let mut builder = tar::Builder::new(file);
builder
.append_path_with_name(&manifest, MANIFEST_FILENAME)
.unwrap();
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Link);
header.set_size(0);
header.set_mode(0o644);
builder
.append_link(
&mut header,
disk_filenames::CONTAINER_DISK,
MANIFEST_FILENAME,
)
.unwrap();
builder.finish().unwrap();
}
let error = extract_archive(&archive_path, &extract_dir)
.expect_err("a hard link archive member must be rejected");
assert!(
error.to_string().contains(disk_filenames::CONTAINER_DISK),
"the error must name the offending member, got: {error}"
);
assert!(
extract_dir
.join(disk_filenames::CONTAINER_DISK)
.symlink_metadata()
.is_err(),
"the link must never reach the filesystem"
);
}
#[test]
fn extract_archive_accepts_legacy_guest_rootfs_member() {
let dir = tempdir().unwrap();
let archive_path = dir.path().join("legacy.boxlite");
let extract_dir = dir.path().join("extracted");
std::fs::create_dir_all(&extract_dir).unwrap();
let manifest = dir.path().join(MANIFEST_FILENAME);
let container = dir.path().join("container-src");
let guest = dir.path().join("guest-src");
std::fs::write(&manifest, br#"{"version":2}"#).unwrap();
std::fs::write(&container, b"container-disk").unwrap();
std::fs::write(&guest, b"guest-rootfs-disk").unwrap();
{
let file = std::fs::File::create(&archive_path).unwrap();
let mut builder = tar::Builder::new(file);
builder
.append_path_with_name(&manifest, MANIFEST_FILENAME)
.unwrap();
builder
.append_path_with_name(&container, disk_filenames::CONTAINER_DISK)
.unwrap();
builder
.append_path_with_name(&guest, disk_filenames::GUEST_ROOTFS_DISK)
.unwrap();
builder.finish().unwrap();
}
extract_archive(&archive_path, &extract_dir)
.expect("a legacy 3-member archive must import");
assert_eq!(
std::fs::read_to_string(extract_dir.join(disk_filenames::CONTAINER_DISK)).unwrap(),
"container-disk"
);
assert_eq!(
std::fs::read_to_string(extract_dir.join(disk_filenames::GUEST_ROOTFS_DISK)).unwrap(),
"guest-rootfs-disk"
);
}
#[test]
fn extract_archive_accepts_sparse_disk_member() {
use std::io::{Seek, SeekFrom, Write};
const HOLE_END: u64 = 8 * 1024 * 1024;
let dir = tempdir().unwrap();
let archive_path = dir.path().join("sparse.boxlite");
let extract_dir = dir.path().join("extracted");
std::fs::create_dir_all(&extract_dir).unwrap();
let manifest = dir.path().join(MANIFEST_FILENAME);
std::fs::write(&manifest, br#"{"version":3}"#).unwrap();
let disk = dir.path().join("sparse-disk");
{
let mut file = std::fs::File::create(&disk).unwrap();
file.write_all(b"head").unwrap();
file.seek(SeekFrom::Start(HOLE_END)).unwrap();
file.write_all(b"tail").unwrap();
file.sync_all().unwrap();
}
build_zstd_tar_archive(&archive_path, &manifest, &disk, 3).unwrap();
#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))]
{
let file = std::fs::File::open(&archive_path).unwrap();
let decoder = zstd::Decoder::new(file).unwrap();
let mut probe = tar::Archive::new(decoder);
let mut disk_member_type = None;
for entry in probe.entries().unwrap() {
let entry = entry.unwrap();
if entry.path().unwrap().to_string_lossy() == disk_filenames::CONTAINER_DISK {
disk_member_type = Some(entry.header().entry_type());
}
}
assert_eq!(
disk_member_type,
Some(tar::EntryType::GNUSparse),
"the fixture must be encoded the way a real exported disk is"
);
}
extract_archive(&archive_path, &extract_dir).expect("a sparse disk member must extract");
let extracted = std::fs::read(extract_dir.join(disk_filenames::CONTAINER_DISK)).unwrap();
assert_eq!(extracted.len() as u64, HOLE_END + 4);
assert_eq!(&extracted[..4], b"head");
assert_eq!(&extracted[HOLE_END as usize..], b"tail");
}
}