use std::path::Path;
use std::sync::Arc;
use boxlite_shared::errors::{BoxliteError, BoxliteResult};
use crate::disk::constants::filenames as disk_filenames;
use crate::litebox::LiteBox;
use crate::litebox::archive::{
ArchiveManifest, MANIFEST_FILENAME, MAX_SUPPORTED_VERSION, PUBLISHED_PORTS_ARCHIVE_VERSION,
extract_archive, move_file, sha256_file,
};
use crate::runtime::advanced_options::SecurityOptions;
use crate::runtime::options::{
ArchiveImportPolicy, BoxArchive, BoxOptions, RootfsSpec, normalize_legacy_ports,
};
use crate::runtime::rt_impl::RuntimeImpl;
use crate::runtime::types::BoxStatus;
pub(crate) async fn import_box(
runtime: &Arc<RuntimeImpl>,
archive: BoxArchive,
name: Option<String>,
) -> BoxliteResult<LiteBox> {
let t0 = std::time::Instant::now();
let archive_path = archive.path().to_path_buf();
if !archive_path.exists() {
return Err(BoxliteError::NotFound(format!(
"Archive not found: {}",
archive_path.display()
)));
}
let layout = runtime.layout.clone();
let (manifest, temp_dir) =
tokio::task::spawn_blocking(move || extract_and_validate(&archive_path, &layout))
.await
.map_err(|e| {
BoxliteError::Internal(format!("Import extraction task panicked: {}", e))
})??;
let options = options_from_manifest(&manifest, archive.import_policy())?;
let staging_dir = temp_dir.path().join("staging");
let temp_path = temp_dir.path().to_path_buf();
let staging_clone = staging_dir.clone();
tokio::task::spawn_blocking(move || install_disks(&temp_path, &staging_clone))
.await
.map_err(|e| BoxliteError::Internal(format!("Import install task panicked: {}", e)))??;
let litebox = runtime
.provision_box(staging_dir, name, options, BoxStatus::Stopped)
.await?;
tracing::info!(
box_id = %litebox.id(),
elapsed_ms = t0.elapsed().as_millis() as u64,
"Imported box from archive"
);
Ok(litebox)
}
fn options_from_manifest(
manifest: &ArchiveManifest,
policy: ArchiveImportPolicy,
) -> BoxliteResult<BoxOptions> {
let mut options = manifest.box_options.clone().unwrap_or_else(|| BoxOptions {
rootfs: RootfsSpec::Image(manifest.image.clone()),
..Default::default()
});
if manifest.version < PUBLISHED_PORTS_ARCHIVE_VERSION {
let changed_mappings = normalize_legacy_ports(&mut options.ports);
if changed_mappings > 0 {
tracing::warn!(
archive_version = manifest.version,
changed_mappings,
"Canonicalized legacy archive port mappings"
);
}
}
options.sanitize().map_err(|error| {
BoxliteError::InvalidArgument(format!("invalid archive box_options: {error}"))
})?;
if policy == ArchiveImportPolicy::Trusted {
return Ok(options);
}
if options.advanced.kernel.is_some() {
return Err(rejected_upload("custom kernels"));
}
if options.advanced.nested_virtualization {
return Err(rejected_upload("nested virtualization"));
}
if options.advanced.privileged {
return Err(rejected_upload("privileged mode"));
}
if matches!(options.rootfs, RootfsSpec::RootfsPath(_)) {
return Err(rejected_upload("host rootfs paths"));
}
if !options.volumes.is_empty() {
return Err(rejected_upload("volume mounts"));
}
options.advanced.security = SecurityOptions::default();
options.sanitize().map_err(|error| {
BoxliteError::InvalidArgument(format!("invalid archive box_options: {error}"))
})?;
Ok(options)
}
fn rejected_upload(subject: &str) -> BoxliteError {
BoxliteError::Unsupported(format!(
"{subject} cannot be requested by an archive uploaded through a REST server"
))
}
fn extract_and_validate(
archive_path: &Path,
layout: &crate::runtime::layout::FilesystemLayout,
) -> BoxliteResult<(ArchiveManifest, tempfile::TempDir)> {
let temp_dir = tempfile::tempdir_in(layout.temp_dir())
.map_err(|e| BoxliteError::Storage(format!("Failed to create temp directory: {}", e)))?;
extract_archive(archive_path, temp_dir.path())?;
let manifest_path = temp_dir.path().join(MANIFEST_FILENAME);
if !manifest_path.exists() {
return Err(BoxliteError::Storage(
"Invalid archive: manifest.json not found".to_string(),
));
}
let manifest_json = std::fs::read_to_string(&manifest_path)?;
let manifest: ArchiveManifest = serde_json::from_str(&manifest_json)
.map_err(|e| BoxliteError::Storage(format!("Invalid manifest: {}", e)))?;
if manifest.version > MAX_SUPPORTED_VERSION {
return Err(BoxliteError::Storage(format!(
"Unsupported archive version {} (max supported: {}). Upgrade boxlite.",
manifest.version, MAX_SUPPORTED_VERSION
)));
}
let extracted_container = temp_dir.path().join(disk_filenames::CONTAINER_DISK);
if !extracted_container.exists() {
return Err(BoxliteError::Storage(format!(
"Invalid archive: {} not found",
disk_filenames::CONTAINER_DISK
)));
}
if !manifest.container_disk_checksum.is_empty() {
let actual = sha256_file(&extracted_container)?;
if actual != manifest.container_disk_checksum {
return Err(BoxliteError::Storage(format!(
"Container disk checksum mismatch: expected {}, got {}",
manifest.container_disk_checksum, actual
)));
}
}
Ok((manifest, temp_dir))
}
fn install_disks(temp_dir: &Path, box_home: &Path) -> BoxliteResult<()> {
let extracted_container = temp_dir.join(disk_filenames::CONTAINER_DISK);
ensure_within_extraction_dir(&extracted_container, temp_dir)?;
validate_no_backing_references(&extracted_container)?;
let disks_dir = box_home.join("disks");
std::fs::create_dir_all(&disks_dir).map_err(|e| {
BoxliteError::Storage(format!(
"Failed to create disks directory {}: {}",
disks_dir.display(),
e
))
})?;
move_file(
&extracted_container,
&disks_dir.join(disk_filenames::CONTAINER_DISK),
)?;
Ok(())
}
fn ensure_within_extraction_dir(disk_path: &Path, extraction_dir: &Path) -> BoxliteResult<()> {
let resolve = |path: &Path| -> BoxliteResult<std::path::PathBuf> {
path.canonicalize().map_err(|e| {
BoxliteError::Storage(format!("Failed to resolve {}: {}", path.display(), e))
})
};
let resolved_disk = resolve(disk_path)?;
let resolved_root = resolve(extraction_dir)?;
if !resolved_disk.starts_with(&resolved_root) {
return Err(BoxliteError::InvalidState(format!(
"Imported disk '{}' resolves outside the extraction directory. \
This is not allowed for security reasons.",
disk_path.display()
)));
}
Ok(())
}
pub(crate) fn validate_no_backing_references(disk_path: &Path) -> BoxliteResult<()> {
match crate::disk::read_backing_file_path(disk_path) {
Ok(None) => Ok(()),
Ok(Some(backing)) => Err(BoxliteError::InvalidState(format!(
"Imported disk '{}' has backing file reference '{}'. \
This is not allowed for security reasons.",
disk_path.display(),
backing
))),
Err(error) => Err(BoxliteError::InvalidState(format!(
"Imported disk '{}' is not a readable qcow2 image: {error}",
disk_path.display()
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::types::Bytes;
use tempfile::TempDir;
fn v3_manifest(options: BoxOptions) -> ArchiveManifest {
ArchiveManifest {
version: 3,
box_name: None,
image: "alpine:latest".to_string(),
box_options: Some(options),
guest_disk_checksum: String::new(),
container_disk_checksum: String::new(),
exported_at: "2026-07-26T00:00:00Z".to_string(),
}
}
fn loopback_port() -> crate::runtime::options::PortSpec {
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()),
}
}
#[test]
fn canonicalization_window_stops_at_the_published_ports_version() {
let options = BoxOptions {
ports: vec![loopback_port()],
..Default::default()
};
let mut legacy = v3_manifest(options.clone());
legacy.version = PUBLISHED_PORTS_ARCHIVE_VERSION - 1;
let rewritten = options_from_manifest(&legacy, ArchiveImportPolicy::Trusted).unwrap();
assert_eq!(
rewritten.ports[0].host_ip, None,
"a pre-publication archive never meant its bind IP"
);
let mut current = v3_manifest(options.clone());
current.version = PUBLISHED_PORTS_ARCHIVE_VERSION;
let preserved = options_from_manifest(¤t, ArchiveImportPolicy::Trusted).unwrap();
assert_eq!(
preserved.ports, options.ports,
"a v5 archive carries publication semantics and must survive import intact"
);
}
#[test]
fn untrusted_import_rejects_nested_virtualization() {
let mut advanced = crate::runtime::advanced_options::AdvancedBoxOptions::default();
advanced.nested_virtualization = true;
let options = BoxOptions {
advanced,
..Default::default()
};
let error =
options_from_manifest(&v3_manifest(options), ArchiveImportPolicy::UntrustedRemote)
.unwrap_err();
assert!(matches!(error, BoxliteError::Unsupported(_)), "{error:?}");
assert!(error.to_string().contains("nested virtualization"));
}
#[test]
fn untrusted_import_rejects_privileged() {
let mut advanced = crate::runtime::advanced_options::AdvancedBoxOptions::default();
advanced.privileged = true;
let options = BoxOptions {
advanced,
..Default::default()
};
let error =
options_from_manifest(&v3_manifest(options), ArchiveImportPolicy::UntrustedRemote)
.unwrap_err();
assert!(matches!(error, BoxliteError::Unsupported(_)), "{error:?}");
assert!(error.to_string().contains("privileged mode"));
}
#[test]
fn untrusted_import_rejects_custom_kernel() {
let kernel = tempfile::NamedTempFile::new().unwrap();
let mut options = BoxOptions::default();
options.advanced.kernel = Some(crate::experimental::custom_kernel::KernelOptions::new(
kernel.path(),
));
let error =
options_from_manifest(&v3_manifest(options), ArchiveImportPolicy::UntrustedRemote)
.unwrap_err();
assert!(matches!(error, BoxliteError::Unsupported(_)), "{error:?}");
assert!(error.to_string().contains("custom kernels"));
}
#[test]
fn untrusted_import_rejects_host_volumes() {
let mut options = BoxOptions::default();
options
.volumes
.push(crate::runtime::options::VolumeSpec::bind_mount(
"/", "/host",
));
let error =
options_from_manifest(&v3_manifest(options), ArchiveImportPolicy::UntrustedRemote)
.expect_err("untrusted archives must not select server host paths");
assert!(matches!(error, BoxliteError::Unsupported(_)), "{error:?}");
assert!(error.to_string().contains("volume mounts"));
}
#[test]
fn untrusted_import_rejects_managed_volumes() {
let mut options = BoxOptions::default();
options
.volumes
.push(crate::runtime::options::VolumeSpec::managed_volume(
"someone-elses-data",
"/data",
));
let error =
options_from_manifest(&v3_manifest(options), ArchiveImportPolicy::UntrustedRemote)
.expect_err("untrusted archives must not select managed volumes");
assert!(matches!(error, BoxliteError::Unsupported(_)), "{error:?}");
assert!(error.to_string().contains("volume mounts"));
}
#[test]
fn untrusted_import_rejects_host_rootfs_paths() {
let options = BoxOptions {
rootfs: RootfsSpec::RootfsPath("/".to_string()),
..Default::default()
};
let error =
options_from_manifest(&v3_manifest(options), ArchiveImportPolicy::UntrustedRemote)
.expect_err("untrusted archives must not select a server rootfs path");
assert!(matches!(error, BoxliteError::Unsupported(_)), "{error:?}");
assert!(error.to_string().contains("host rootfs paths"));
}
#[test]
fn untrusted_import_replaces_archive_security_with_server_default() {
let mut options = BoxOptions::default();
options.advanced.security = SecurityOptions::disabled();
let resolved =
options_from_manifest(&v3_manifest(options), ArchiveImportPolicy::UntrustedRemote)
.unwrap();
let mut expected = SecurityOptions::default();
expected.resource_limits.max_file_size = Some(Bytes::from_gib(20).as_bytes());
assert_eq!(resolved.advanced.security, expected);
}
#[test]
fn untrusted_import_derives_the_fsize_limit_from_the_disk() {
let options = BoxOptions {
disk_size_gb: Some(20),
..BoxOptions::default()
};
let resolved =
options_from_manifest(&v3_manifest(options), ArchiveImportPolicy::UntrustedRemote)
.unwrap();
assert_eq!(
resolved.advanced.security.resource_limits.max_file_size,
Some(Bytes::from_gib(40).as_bytes())
);
}
#[test]
fn trusted_import_preserves_archive_configuration() {
let mut advanced = crate::runtime::advanced_options::AdvancedBoxOptions::default();
advanced.nested_virtualization = true;
advanced.privileged = true;
advanced.security = SecurityOptions::disabled();
let options = BoxOptions {
advanced,
..Default::default()
};
let resolved =
options_from_manifest(&v3_manifest(options), ArchiveImportPolicy::Trusted).unwrap();
assert!(resolved.advanced.nested_virtualization);
assert!(resolved.advanced.privileged);
let mut expected = SecurityOptions::disabled();
expected.resource_limits.max_file_size = Some(Bytes::from_gib(20).as_bytes());
assert_eq!(resolved.advanced.security, expected);
}
#[test]
fn imported_capability_policy_is_validated_before_install() {
let mut advanced = crate::runtime::advanced_options::AdvancedBoxOptions::default();
advanced
.set_capabilities(Some(
crate::runtime::advanced_options::ContainerCapabilities {
drop: vec!["NET-ADMIN".into()],
..Default::default()
},
))
.unwrap();
let manifest = ArchiveManifest {
version: 3,
box_name: Some("untrusted".into()),
image: "alpine:latest".into(),
box_options: Some(BoxOptions {
advanced,
..Default::default()
}),
guest_disk_checksum: String::new(),
container_disk_checksum: String::new(),
exported_at: "2026-01-01T00:00:00Z".into(),
};
let error = options_from_manifest(&manifest, ArchiveImportPolicy::Trusted)
.expect_err("malformed archived capability policy must be rejected");
assert!(matches!(error, BoxliteError::InvalidArgument(_)));
assert!(error.to_string().contains("NET-ADMIN"));
}
#[test]
fn test_validate_no_backing_references_rejects_absolute() {
let dir = TempDir::new_in("/tmp").unwrap();
let disk = dir.path().join("evil.qcow2");
crate::disk::qcow2::write_test_qcow2(&disk, Some("/etc/shadow"));
let result = validate_no_backing_references(&disk);
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(msg.contains("backing file reference"), "Got: {msg}");
assert!(msg.contains("/etc/shadow"), "Got: {msg}");
}
#[test]
fn test_validate_no_backing_references_rejects_relative() {
let dir = TempDir::new_in("/tmp").unwrap();
let disk = dir.path().join("evil.qcow2");
crate::disk::qcow2::write_test_qcow2(&disk, Some("../../other/disk.qcow2"));
let result = validate_no_backing_references(&disk);
assert!(result.is_err());
}
#[test]
fn test_validate_no_backing_references_accepts_standalone() {
let dir = TempDir::new_in("/tmp").unwrap();
let disk = dir.path().join("clean.qcow2");
crate::disk::qcow2::write_test_qcow2(&disk, None);
let result = validate_no_backing_references(&disk);
assert!(result.is_ok());
}
#[test]
fn test_validate_no_backing_references_rejects_unparsable_disk() {
let dir = TempDir::new_in("/tmp").unwrap();
let disk = dir.path().join("not-a-qcow2.qcow2");
std::fs::write(&disk, b"this is not a qcow2 header at all").unwrap();
let error = validate_no_backing_references(&disk)
.expect_err("an unparsable disk must not be treated as safe");
assert!(
error.to_string().contains("qcow2"),
"the error must say the disk is not a usable qcow2, got: {error}"
);
}
}