use a3s_box_core::error::{BoxError, Result};
use a3s_box_core::rootfs_metadata::{
RootfsEntryKind, RootfsMetadataEntry, RootfsMetadataManifest, IMAGE_ROOTFS_METADATA_PATH,
IMAGE_ROOTFS_METADATA_TEMP_PATH, PREVIOUS_ROOTFS_METADATA_PATH, ROOTFS_METADATA_PATH,
ROOTFS_METADATA_TEMP_PATH,
};
use base64::Engine;
use flate2::read::GzDecoder;
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use tar::Archive;
pub fn extract_layer(layer_path: &Path, target_dir: &Path) -> Result<()> {
let max_layer_bytes =
super::limited_reader::cap_from_env("A3S_BOX_MAX_LAYER_BYTES", 16 * 1024 * 1024 * 1024);
extract_layer_with_cap(layer_path, target_dir, max_layer_bytes, false)
}
pub(crate) fn extract_layer_with_metadata(layer_path: &Path, target_dir: &Path) -> Result<()> {
let max_layer_bytes =
super::limited_reader::cap_from_env("A3S_BOX_MAX_LAYER_BYTES", 16 * 1024 * 1024 * 1024);
extract_layer_with_cap(layer_path, target_dir, max_layer_bytes, true)
}
fn extract_layer_with_cap(
layer_path: &Path,
target_dir: &Path,
max_layer_bytes: u64,
track_metadata: bool,
) -> Result<()> {
if !layer_path.exists() {
return Err(BoxError::OciImageError(format!(
"Layer file not found: {}",
layer_path.display()
)));
}
std::fs::create_dir_all(target_dir).map_err(|e| {
BoxError::OciImageError(format!(
"Failed to create target directory {}: {}",
target_dir.display(),
e
))
})?;
let mut file = File::open(layer_path).map_err(|e| {
BoxError::OciImageError(format!(
"Failed to open layer file {}: {}",
layer_path.display(),
e
))
})?;
let mut magic = [0u8; 4];
let read = file.read(&mut magic).map_err(|e| {
BoxError::OciImageError(format!(
"Failed to read layer header {}: {e}",
layer_path.display()
))
})?;
file.seek(SeekFrom::Start(0)).map_err(|e| {
BoxError::OciImageError(format!(
"Failed to rewind layer {}: {e}",
layer_path.display()
))
})?;
let decoder: Box<dyn Read> = if read >= 2 && magic[0] == 0x1f && magic[1] == 0x8b {
Box::new(GzDecoder::new(file))
} else if read >= 4 && magic == [0x28, 0xb5, 0x2f, 0xfd] {
Box::new(zstd::stream::read::Decoder::new(file).map_err(|e| {
BoxError::OciImageError(format!(
"Failed to init zstd decoder for {}: {e}",
layer_path.display()
))
})?)
} else {
Box::new(file)
};
let decoder = super::limited_reader::LimitedReader::new(decoder, max_layer_bytes);
let mut archive = Archive::new(decoder);
archive.set_preserve_permissions(true);
archive.set_preserve_mtime(true);
archive.set_overwrite(true);
#[cfg(unix)]
{
archive.set_unpack_xattrs(true);
if unsafe { libc::geteuid() } == 0 {
archive.set_preserve_ownerships(true);
}
}
let mut metadata = if track_metadata {
load_image_metadata(target_dir)?
} else {
BTreeMap::new()
};
let entries = archive
.entries()
.map_err(|e| BoxError::OciImageError(format!("Failed to read layer entries: {e}")))?;
for entry in entries {
let mut entry = entry
.map_err(|e| BoxError::OciImageError(format!("Failed to read layer entry: {e}")))?;
let path = entry
.path()
.map_err(|e| BoxError::OciImageError(format!("Invalid layer entry path: {e}")))?
.into_owned();
if path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
tracing::warn!(path = %path.display(), "Skipping layer entry with '..' component");
continue;
}
let normalized = normalize_layer_path(&path).ok_or_else(|| {
BoxError::OciImageError(format!("Invalid layer entry path: {}", path.display()))
})?;
if let Some(reserved) = reserved_metadata_path(&normalized) {
return Err(BoxError::OciImageError(format!(
"OCI layer contains reserved internal path {reserved}"
)));
}
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if file_name == ".wh..wh..opq" {
if let Some(parent) = path.parent() {
if let Some(dir) = resolve_within(target_dir, parent) {
if let Ok(read) = std::fs::read_dir(&dir) {
for child in read.flatten() {
remove_path(&child.path());
}
}
} else {
tracing::warn!(parent = %parent.display(), "Skipping opaque whiteout: parent escapes the rootfs");
}
}
if track_metadata {
let parent = normalize_layer_path(path.parent().unwrap_or_else(|| Path::new("")))
.ok_or_else(|| {
BoxError::OciImageError("Invalid opaque whiteout path".to_string())
})?;
remove_metadata_descendants(&mut metadata, &parent, false);
}
continue;
}
if let Some(victim_name) = file_name.strip_prefix(".wh.") {
let victim = normalize_layer_path(
&path
.parent()
.unwrap_or_else(|| Path::new(""))
.join(victim_name),
)
.ok_or_else(|| BoxError::OciImageError("Invalid whiteout path".to_string()))?;
if let Some(reserved) = reserved_metadata_path(&victim) {
return Err(BoxError::OciImageError(format!(
"OCI layer whiteouts reserved internal path {reserved}"
)));
}
if let Some(parent) = path.parent() {
if let Some(dir) = resolve_within(target_dir, parent) {
remove_path(&dir.join(victim_name));
} else {
tracing::warn!(parent = %parent.display(), "Skipping whiteout: parent escapes the rootfs");
}
}
if track_metadata {
remove_metadata_descendants(&mut metadata, &victim, true);
}
continue;
}
if entry.header().entry_type() == tar::EntryType::Symlink {
prepare_symlink_destination(target_dir, &path)?;
} else if entry.header().entry_type().is_hard_link() {
prepare_hardlink_destination(target_dir, &path)?;
}
reject_overlay_private_xattrs(&mut entry, &path)?;
let desired = if track_metadata {
Some(metadata_from_header(&entry, &normalized)?)
} else {
None
};
let unpacked = entry.unpack_in(target_dir).map_err(|e| {
#[cfg(windows)]
if error_chain_has_raw_os_error(&e, WINDOWS_SYMLINK_PRIVILEGE_ERROR) {
return BoxError::OciImageError(format!(
"Failed to extract layer to {}: Windows cannot preserve OCI symlink {}: \
enable Developer Mode so a non-elevated process can create symbolic links, \
or grant the service identity SeCreateSymbolicLinkPrivilege; flattening the \
link would corrupt the image (ERROR_PRIVILEGE_NOT_HELD (1314)). See \
https://learn.microsoft.com/windows/advanced-settings/developer-mode",
target_dir.display(),
path.display(),
));
}
let cause = std::error::Error::source(&e)
.map(|src| format!("{e}: {src}"))
.unwrap_or_else(|| e.to_string());
BoxError::OciImageError(format!(
"Failed to extract layer to {}: {cause}",
target_dir.display(),
))
})?;
if track_metadata && unpacked {
if let Some(desired) = desired {
if desired.kind != RootfsEntryKind::Directory {
remove_metadata_descendants(&mut metadata, &normalized, false);
}
metadata.insert(normalized, desired);
}
}
}
if track_metadata {
finalize_image_metadata(target_dir, &mut metadata)?;
}
tracing::debug!(
layer = %layer_path.display(),
target = %target_dir.display(),
"Extracted OCI layer"
);
Ok(())
}
#[cfg(unix)]
fn reject_overlay_private_xattrs<R: Read>(
entry: &mut tar::Entry<'_, R>,
path: &Path,
) -> Result<()> {
const PAX_XATTR_PREFIX: &[u8] = b"SCHILY.xattr.";
let Some(extensions) = entry.pax_extensions().map_err(|error| {
BoxError::OciImageError(format!(
"Failed to inspect extended attributes for {}: {error}",
path.display()
))
})?
else {
return Ok(());
};
for extension in extensions {
let extension = extension.map_err(|error| {
BoxError::OciImageError(format!(
"Invalid PAX extended attribute for {}: {error}",
path.display()
))
})?;
let Some(name) = extension.key_bytes().strip_prefix(PAX_XATTR_PREFIX) else {
continue;
};
if name.starts_with(b"trusted.overlay.") || name.starts_with(b"user.overlay.") {
return Err(BoxError::OciImageError(format!(
"OCI layer entry {} contains reserved overlayfs metadata",
path.display()
)));
}
}
Ok(())
}
#[cfg(not(unix))]
fn reject_overlay_private_xattrs<R: Read>(
_entry: &mut tar::Entry<'_, R>,
_path: &Path,
) -> Result<()> {
Ok(())
}
fn image_metadata_relative_path() -> PathBuf {
PathBuf::from(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/'))
}
fn terminal_metadata_relative_path() -> PathBuf {
PathBuf::from(ROOTFS_METADATA_PATH.trim_start_matches('/'))
}
fn previous_metadata_relative_path() -> PathBuf {
PathBuf::from(PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/'))
}
fn image_metadata_temp_relative_path() -> PathBuf {
PathBuf::from(IMAGE_ROOTFS_METADATA_TEMP_PATH.trim_start_matches('/'))
}
fn terminal_metadata_temp_relative_path() -> PathBuf {
PathBuf::from(ROOTFS_METADATA_TEMP_PATH.trim_start_matches('/'))
}
fn reserved_metadata_path(path: &Path) -> Option<&'static str> {
if path.starts_with(image_metadata_relative_path()) {
Some(IMAGE_ROOTFS_METADATA_PATH)
} else if path.starts_with(terminal_metadata_relative_path()) {
Some(ROOTFS_METADATA_PATH)
} else if path.starts_with(previous_metadata_relative_path()) {
Some(PREVIOUS_ROOTFS_METADATA_PATH)
} else if path.starts_with(image_metadata_temp_relative_path()) {
Some(IMAGE_ROOTFS_METADATA_TEMP_PATH)
} else if path.starts_with(terminal_metadata_temp_relative_path()) {
Some(ROOTFS_METADATA_TEMP_PATH)
} else {
None
}
}
fn normalize_layer_path(path: &Path) -> Option<PathBuf> {
use std::path::Component;
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::Normal(name) => normalized.push(name),
Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
}
}
Some(normalized)
}
fn metadata_from_header<R: Read>(
entry: &tar::Entry<'_, R>,
path: &Path,
) -> Result<RootfsMetadataEntry> {
let entry_type = entry.header().entry_type();
let (kind, link_target_base64) = if entry_type.is_dir() {
(RootfsEntryKind::Directory, None)
} else if entry_type.is_symlink() {
let target = entry
.link_name()
.map_err(|error| BoxError::OciImageError(format!("Invalid symlink target: {error}")))?
.ok_or_else(|| BoxError::OciImageError("Missing symlink target".to_string()))?;
(
RootfsEntryKind::Symlink,
Some(base64::engine::general_purpose::STANDARD.encode(guest_path_bytes(&target))),
)
} else if entry_type.is_file() || entry_type.is_hard_link() {
(RootfsEntryKind::Regular, None)
} else {
return Err(BoxError::OciImageError(format!(
"Unsupported OCI layer entry type at {}",
path.display()
)));
};
let path_base64 =
base64::engine::general_purpose::STANDARD.encode(archive_metadata_path_bytes(path));
Ok(RootfsMetadataEntry {
path_base64,
kind,
mode: entry.header().mode().map_err(|error| {
BoxError::OciImageError(format!("Invalid mode at {}: {error}", path.display()))
})?,
uid: entry.header().uid().map_err(|error| {
BoxError::OciImageError(format!("Invalid uid at {}: {error}", path.display()))
})?,
gid: entry.header().gid().map_err(|error| {
BoxError::OciImageError(format!("Invalid gid at {}: {error}", path.display()))
})?,
mtime: entry.header().mtime().map_err(|error| {
BoxError::OciImageError(format!("Invalid mtime at {}: {error}", path.display()))
})?,
size: entry.header().size().map_err(|error| {
BoxError::OciImageError(format!("Invalid size at {}: {error}", path.display()))
})?,
link_target_base64,
})
}
fn archive_metadata_path_bytes(path: &Path) -> Vec<u8> {
let mut encoded = vec![b'.'];
for component in path.components() {
if let std::path::Component::Normal(name) = component {
encoded.push(b'/');
encoded.extend_from_slice(name.as_encoded_bytes());
}
}
encoded
}
fn guest_path_bytes(path: &Path) -> Vec<u8> {
let bytes = path.as_os_str().as_encoded_bytes();
#[cfg(windows)]
{
bytes
.iter()
.map(|byte| if *byte == b'\\' { b'/' } else { *byte })
.collect()
}
#[cfg(not(windows))]
{
bytes.to_vec()
}
}
fn remove_metadata_descendants(
metadata: &mut BTreeMap<PathBuf, RootfsMetadataEntry>,
path: &Path,
include_path: bool,
) {
metadata.retain(|candidate, _| {
!(candidate.starts_with(path) && (include_path || candidate != path))
});
}
fn load_image_metadata(target_dir: &Path) -> Result<BTreeMap<PathBuf, RootfsMetadataEntry>> {
let path = crate::oci::rootfs::resolve_guest_file_path(
target_dir,
IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/'),
)?;
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(error) => {
return Err(BoxError::OciImageError(format!(
"Failed to read image metadata {}: {error}",
path.display()
)))
}
};
let manifest: RootfsMetadataManifest = serde_json::from_slice(&bytes).map_err(|error| {
BoxError::OciImageError(format!(
"Invalid image metadata {}: {error}",
path.display()
))
})?;
manifest.validate().map_err(BoxError::OciImageError)?;
let mut result = BTreeMap::new();
for entry in manifest.entries {
let raw = base64::engine::general_purpose::STANDARD
.decode(&entry.path_base64)
.map_err(|error| BoxError::OciImageError(format!("Invalid metadata path: {error}")))?;
let archive_path = PathBuf::from(os_string_from_encoded_bytes(raw));
let relative = normalize_layer_path(&archive_path)
.ok_or_else(|| BoxError::OciImageError("Unsafe path in image metadata".to_string()))?;
if reserved_metadata_path(&relative).is_some() || result.insert(relative, entry).is_some() {
return Err(BoxError::OciImageError(
"Duplicate or reserved path in image metadata".to_string(),
));
}
}
Ok(result)
}
pub(crate) fn finalize_rootfs_metadata(target_dir: &Path) -> Result<()> {
let mut metadata = load_image_metadata(target_dir)?;
finalize_image_metadata(target_dir, &mut metadata)?;
prepare_rootless_metadata_replay(target_dir, &metadata)
}
fn prepare_rootless_metadata_replay(
target_dir: &Path,
metadata: &BTreeMap<PathBuf, RootfsMetadataEntry>,
) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, PermissionsExt};
if unsafe { libc::geteuid() } == 0 {
return Ok(());
}
for (relative, entry) in metadata {
if entry.kind == RootfsEntryKind::Symlink || entry.mode & 0o200 != 0 {
continue;
}
let relative_path = relative.to_str().ok_or_else(|| {
BoxError::OciImageError(format!(
"Metadata replay path is not UTF-8: {}",
relative.display()
))
})?;
let target = if entry.kind == RootfsEntryKind::Directory {
crate::oci::rootfs::resolve_guest_directory_path(target_dir, relative_path)?
} else {
crate::oci::rootfs::resolve_guest_file_path(target_dir, relative_path)?
};
let current = std::fs::symlink_metadata(&target).map_err(|error| {
BoxError::OciImageError(format!(
"Failed to prepare metadata replay for {}: {error}",
target.display()
))
})?;
std::fs::set_permissions(
&target,
std::fs::Permissions::from_mode((current.mode() & 0o7777) | 0o200),
)
.map_err(|error| {
BoxError::OciImageError(format!(
"Failed to prepare metadata replay for {}: {error}",
target.display()
))
})?;
}
}
#[cfg(not(unix))]
{
let _ = (target_dir, metadata);
}
Ok(())
}
fn finalize_image_metadata(
target_dir: &Path,
metadata: &mut BTreeMap<PathBuf, RootfsMetadataEntry>,
) -> Result<()> {
let mut final_entries = BTreeMap::new();
collect_final_metadata(
target_dir,
target_dir,
Path::new(""),
metadata,
&mut final_entries,
)?;
let manifest = RootfsMetadataManifest::new(final_entries.into_values().collect());
let destination_relative = IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/');
let temporary_relative = ".a3s_image_metadata_v1.json.tmp";
let bytes = serde_json::to_vec(&manifest).map_err(|error| {
BoxError::OciImageError(format!("Failed to encode image metadata: {error}"))
})?;
let temporary =
crate::oci::rootfs::replace_guest_file_no_follow(target_dir, temporary_relative, bytes)?;
let destination =
crate::oci::rootfs::remove_guest_entry_no_follow(target_dir, destination_relative)?;
std::fs::rename(&temporary, &destination).map_err(|error| {
BoxError::OciImageError(format!(
"Failed to activate image metadata {}: {error}",
destination.display()
))
})?;
*metadata = manifest
.entries
.into_iter()
.filter_map(|entry| decode_metadata_key(&entry).map(|key| (key, entry)))
.collect();
Ok(())
}
fn decode_metadata_key(entry: &RootfsMetadataEntry) -> Option<PathBuf> {
let raw = base64::engine::general_purpose::STANDARD
.decode(&entry.path_base64)
.ok()?;
normalize_layer_path(Path::new(&os_string_from_encoded_bytes(raw)))
}
fn os_string_from_encoded_bytes(raw: Vec<u8>) -> std::ffi::OsString {
unsafe { std::ffi::OsString::from_encoded_bytes_unchecked(raw) }
}
fn collect_final_metadata(
root: &Path,
source: &Path,
relative: &Path,
desired: &BTreeMap<PathBuf, RootfsMetadataEntry>,
output: &mut BTreeMap<PathBuf, RootfsMetadataEntry>,
) -> Result<()> {
if reserved_metadata_path(relative).is_some()
|| relative == Path::new(".a3s_image_metadata_v1.json.tmp")
|| relative == Path::new(".a3s_rootfs_metadata_v1.json.tmp")
|| relative == Path::new(".a3s_rootfs_metadata_v1.previous.json")
{
return Ok(());
}
let filesystem = std::fs::symlink_metadata(source).map_err(|error| {
BoxError::OciImageError(format!("Failed to inspect {}: {error}", source.display()))
})?;
let file_type = filesystem.file_type();
let previous = desired.get(relative);
let (kind, link_target_base64) = if file_type.is_dir() {
(RootfsEntryKind::Directory, None)
} else if file_type.is_file() {
(RootfsEntryKind::Regular, None)
} else if file_type.is_symlink() {
let target = std::fs::read_link(source).map_err(|error| {
BoxError::OciImageError(format!("Failed to read {}: {error}", source.display()))
})?;
let target = previous
.filter(|entry| entry.kind == RootfsEntryKind::Symlink)
.and_then(|entry| entry.link_target_base64.clone())
.unwrap_or_else(|| {
base64::engine::general_purpose::STANDARD.encode(guest_path_bytes(&target))
});
(RootfsEntryKind::Symlink, Some(target))
} else {
return Ok(());
};
#[cfg(unix)]
let (mode, mtime, size) = {
use std::os::unix::fs::MetadataExt;
(
filesystem.mode(),
filesystem.mtime().max(0) as u64,
filesystem.size(),
)
};
#[cfg(not(unix))]
let (mode, mtime, size) = previous
.map(|entry| (entry.mode, entry.mtime, entry.size))
.unwrap_or_else(|| {
(
if file_type.is_dir() { 0o755 } else { 0o644 },
0,
filesystem.len(),
)
});
let entry = RootfsMetadataEntry {
path_base64: base64::engine::general_purpose::STANDARD
.encode(archive_metadata_path_bytes(relative)),
kind,
mode,
uid: previous.map_or(0, |entry| entry.uid),
gid: previous.map_or(0, |entry| entry.gid),
mtime,
size,
link_target_base64,
};
output.insert(relative.to_path_buf(), entry);
if file_type.is_dir() {
let mut children: Vec<_> = std::fs::read_dir(source)
.map_err(|error| {
BoxError::OciImageError(format!("Failed to read {}: {error}", source.display()))
})?
.collect::<std::result::Result<_, _>>()
.map_err(|error| BoxError::OciImageError(format!("Failed to read entry: {error}")))?;
children.sort_by_key(|entry| entry.file_name());
for child in children {
collect_final_metadata(
root,
&child.path(),
&relative.join(child.file_name()),
desired,
output,
)?;
}
}
let _ = root;
Ok(())
}
#[cfg(windows)]
const WINDOWS_SYMLINK_PRIVILEGE_ERROR: i32 = 1314;
#[cfg(windows)]
fn error_chain_has_raw_os_error(error: &std::io::Error, code: i32) -> bool {
if error.raw_os_error() == Some(code) {
return true;
}
if error.to_string().contains(&format!("(os error {code})")) {
return true;
}
let mut source = std::error::Error::source(error);
while let Some(current) = source {
if current
.downcast_ref::<std::io::Error>()
.is_some_and(|error| error.raw_os_error() == Some(code))
|| current.to_string().contains(&format!("(os error {code})"))
{
return true;
}
source = current.source();
}
false
}
fn prepare_symlink_destination(target_dir: &Path, path: &Path) -> Result<()> {
let Some(name) = path.file_name() else {
return Ok(());
};
let parent = path.parent().unwrap_or_else(|| Path::new(""));
let Some(parent) = resolve_within_or_base(target_dir, parent) else {
tracing::warn!(parent = %parent.display(), "Skipping symlink destination preparation: parent escapes the rootfs");
return Ok(());
};
let candidate = parent.join(name);
let Ok(metadata) = std::fs::symlink_metadata(&candidate) else {
return Ok(());
};
if metadata.is_dir() {
std::fs::remove_dir_all(&candidate).map_err(|e| {
BoxError::OciImageError(format!(
"Failed to replace directory {} with symlink from layer: {}",
candidate.display(),
e
))
})?;
}
Ok(())
}
fn prepare_hardlink_destination(target_dir: &Path, path: &Path) -> Result<()> {
let Some(name) = path.file_name() else {
return Ok(());
};
let parent = path.parent().unwrap_or_else(|| Path::new(""));
let Some(parent) = resolve_within_or_base(target_dir, parent) else {
tracing::warn!(parent = %parent.display(), "Skipping hardlink destination preparation: parent escapes the rootfs");
return Ok(());
};
let candidate = parent.join(name);
let Ok(metadata) = std::fs::symlink_metadata(&candidate) else {
return Ok(());
};
let result = if metadata.is_dir() {
std::fs::remove_dir_all(&candidate)
} else {
std::fs::remove_file(&candidate)
};
result.map_err(|error| {
BoxError::OciImageError(format!(
"Failed to replace {} with hardlink from layer: {error}",
candidate.display()
))
})
}
fn resolve_within(target_dir: &Path, rel: &Path) -> Option<PathBuf> {
if rel.as_os_str().is_empty() {
return target_dir.canonicalize().ok();
}
resolve_within_or_base(target_dir, rel)
}
fn resolve_within_or_base(target_dir: &Path, rel: &Path) -> Option<PathBuf> {
let base = target_dir.canonicalize().ok()?;
if rel.as_os_str().is_empty() {
return Some(base);
}
let resolved = base.join(rel).canonicalize().ok()?;
resolved.starts_with(&base).then_some(resolved)
}
fn remove_path(path: &Path) {
let Ok(meta) = std::fs::symlink_metadata(path) else {
return;
};
let result = if meta.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
};
if let Err(e) = result {
tracing::warn!(path = %path.display(), error = %e, "Failed to apply whiteout deletion");
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_extract_layer_creates_target_directory() {
let temp_dir = TempDir::new().unwrap();
let layer_path = temp_dir.path().join("layer.tar.gz");
let target_dir = temp_dir.path().join("extracted");
create_test_layer(&layer_path, &[("test.txt", b"hello")]);
extract_layer(&layer_path, &target_dir).unwrap();
assert!(target_dir.exists());
assert!(target_dir.is_dir());
}
#[test]
fn test_extract_layer_extracts_files() {
let temp_dir = TempDir::new().unwrap();
let layer_path = temp_dir.path().join("layer.tar.gz");
let target_dir = temp_dir.path().join("extracted");
create_test_layer(
&layer_path,
&[("file1.txt", b"content1"), ("dir/file2.txt", b"content2")],
);
extract_layer(&layer_path, &target_dir).unwrap();
assert!(target_dir.join("file1.txt").exists());
assert!(target_dir.join("dir/file2.txt").exists());
let content1 = fs::read_to_string(target_dir.join("file1.txt")).unwrap();
assert_eq!(content1, "content1");
let content2 = fs::read_to_string(target_dir.join("dir/file2.txt")).unwrap();
assert_eq!(content2, "content2");
}
#[test]
fn test_extract_layer_nonexistent_file() {
let temp_dir = TempDir::new().unwrap();
let layer_path = temp_dir.path().join("nonexistent.tar.gz");
let target_dir = temp_dir.path().join("extracted");
let result = extract_layer(&layer_path, &target_dir);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Layer file not found"));
}
#[test]
fn test_extract_layer_multiple_layers_to_same_target() {
let temp_dir = TempDir::new().unwrap();
let layer1_path = temp_dir.path().join("layer1.tar.gz");
let layer2_path = temp_dir.path().join("layer2.tar.gz");
let target_dir = temp_dir.path().join("extracted");
create_test_layer(&layer1_path, &[("base.txt", b"base content")]);
create_test_layer(&layer2_path, &[("app.txt", b"app content")]);
extract_layer(&layer1_path, &target_dir).unwrap();
extract_layer(&layer2_path, &target_dir).unwrap();
assert!(target_dir.join("base.txt").exists());
assert!(target_dir.join("app.txt").exists());
}
#[test]
fn test_extract_layer_overwrites_existing_files() {
let temp_dir = TempDir::new().unwrap();
let layer1_path = temp_dir.path().join("layer1.tar.gz");
let layer2_path = temp_dir.path().join("layer2.tar.gz");
let target_dir = temp_dir.path().join("extracted");
create_test_layer(&layer1_path, &[("file.txt", b"version 1")]);
create_test_layer(&layer2_path, &[("file.txt", b"version 2")]);
extract_layer(&layer1_path, &target_dir).unwrap();
let content1 = fs::read_to_string(target_dir.join("file.txt")).unwrap();
assert_eq!(content1, "version 1");
extract_layer(&layer2_path, &target_dir).unwrap();
let content2 = fs::read_to_string(target_dir.join("file.txt")).unwrap();
assert_eq!(content2, "version 2");
}
#[test]
fn test_extract_layer_overwrites_existing_hardlink_destination() {
let temp_dir = TempDir::new().unwrap();
let layer1_path = temp_dir.path().join("layer1.tar.gz");
let layer2_path = temp_dir.path().join("layer2.tar.gz");
let target_dir = temp_dir.path().join("extracted");
create_test_layer(
&layer1_path,
&[
("usr/bin/perl", b"current interpreter"),
("usr/bin/perl5.38.2", b"stale interpreter"),
],
);
create_hardlink_test_layer(&layer2_path, "usr/bin/perl5.38.2", "usr/bin/perl");
extract_layer(&layer1_path, &target_dir).unwrap();
extract_layer(&layer2_path, &target_dir).unwrap();
assert_eq!(
fs::read(target_dir.join("usr/bin/perl5.38.2")).unwrap(),
b"current interpreter"
);
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
assert_eq!(
fs::metadata(target_dir.join("usr/bin/perl")).unwrap().ino(),
fs::metadata(target_dir.join("usr/bin/perl5.38.2"))
.unwrap()
.ino(),
);
}
}
#[test]
fn test_extract_layer_applies_whiteout() {
let temp_dir = TempDir::new().unwrap();
let layer1 = temp_dir.path().join("layer1.tar.gz");
let layer2 = temp_dir.path().join("layer2.tar.gz");
let target = temp_dir.path().join("extracted");
create_test_layer(
&layer1,
&[("dir/keep.txt", b"keep"), ("dir/removed.txt", b"bye")],
);
create_test_layer(&layer2, &[("dir/.wh.removed.txt", b"")]);
extract_layer(&layer1, &target).unwrap();
assert!(target.join("dir/removed.txt").exists());
extract_layer(&layer2, &target).unwrap();
assert!(target.join("dir/keep.txt").exists(), "sibling must survive");
assert!(
!target.join("dir/removed.txt").exists(),
"whiteout must delete the file from the lower layer"
);
assert!(
!target.join("dir/.wh.removed.txt").exists(),
"whiteout marker must not be written to the rootfs"
);
}
#[test]
fn test_extract_layer_applies_opaque_directory() {
let temp_dir = TempDir::new().unwrap();
let layer1 = temp_dir.path().join("l1.tar.gz");
let layer2 = temp_dir.path().join("l2.tar.gz");
let target = temp_dir.path().join("ex");
create_test_layer(&layer1, &[("d/old1.txt", b"a"), ("d/old2.txt", b"b")]);
create_test_layer(&layer2, &[("d/.wh..wh..opq", b""), ("d/new.txt", b"c")]);
extract_layer(&layer1, &target).unwrap();
extract_layer(&layer2, &target).unwrap();
assert!(!target.join("d/old1.txt").exists());
assert!(!target.join("d/old2.txt").exists());
assert!(target.join("d/new.txt").exists());
assert!(!target.join("d/.wh..wh..opq").exists());
}
#[test]
fn tracked_metadata_preserves_header_ownership_and_whiteouts() {
let temp_dir = TempDir::new().unwrap();
let layer1 = temp_dir.path().join("metadata-1.tar.gz");
let layer2 = temp_dir.path().join("metadata-2.tar.gz");
let target = temp_dir.path().join("rootfs");
create_owned_test_layer(&layer1, "dir/owned", b"payload", 123, 456, 0o750);
create_test_layer(&layer2, &[("dir/.wh.owned", b"")]);
extract_layer_with_metadata(&layer1, &target).unwrap();
let manifest = read_image_manifest(&target);
let owned = manifest
.entries
.iter()
.find(|entry| {
base64::engine::general_purpose::STANDARD
.decode(&entry.path_base64)
.is_ok_and(|raw| raw == b"./dir/owned")
})
.unwrap();
assert_eq!(
(owned.uid, owned.gid, owned.mode & 0o7777),
(123, 456, 0o750)
);
extract_layer_with_metadata(&layer2, &target).unwrap();
let manifest = read_image_manifest(&target);
assert!(!manifest.entries.iter().any(|entry| {
base64::engine::general_purpose::STANDARD
.decode(&entry.path_base64)
.is_ok_and(|raw| raw.ends_with(b"dir/owned"))
}));
}
#[test]
fn every_extraction_mode_rejects_reserved_internal_paths() {
for (index, reserved) in [
".a3s_image_metadata_v1.json",
".a3s_image_metadata_v1.json/child",
".a3s_image_metadata_v1.json.tmp",
".a3s_image_metadata_v1.json.tmp/child",
".a3s_rootfs_metadata_v1.json",
".a3s_rootfs_metadata_v1.json/child",
".a3s_rootfs_metadata_v1.json.tmp",
".a3s_rootfs_metadata_v1.json.tmp/child",
".a3s_rootfs_metadata_v1.previous.json",
".a3s_rootfs_metadata_v1.previous.json/child",
]
.into_iter()
.enumerate()
{
for track_metadata in [false, true] {
let temp_dir = TempDir::new().unwrap();
let layer = temp_dir
.path()
.join(format!("reserved-{index}-{track_metadata}.tar.gz"));
let target = temp_dir.path().join("rootfs");
create_test_layer(&layer, &[(reserved, b"forged")]);
let error = if track_metadata {
extract_layer_with_metadata(&layer, &target).unwrap_err()
} else {
extract_layer(&layer, &target).unwrap_err()
};
assert!(error.to_string().contains("reserved internal path"));
assert!(!target.join(reserved).exists());
}
}
}
#[test]
fn untracked_extraction_rejects_reserved_whiteouts() {
for victim in [
".a3s_rootfs_metadata_v1.previous.json",
".a3s_rootfs_metadata_v1.json.tmp",
] {
let temp_dir = TempDir::new().unwrap();
let layer = temp_dir.path().join("reserved-whiteout.tar.gz");
let target = temp_dir.path().join("rootfs");
let whiteout = format!(".wh.{victim}");
create_test_layer(&layer, &[(&whiteout, b"")]);
let error = extract_layer(&layer, &target).unwrap_err();
assert!(error
.to_string()
.contains("whiteouts reserved internal path"));
}
}
#[test]
fn tracked_metadata_rejects_reserved_terminal_symlink() {
let temp_dir = TempDir::new().unwrap();
let layer = temp_dir.path().join("reserved-terminal-link.tar.gz");
let target = temp_dir.path().join("rootfs");
create_layer_with_symlink(
&layer,
".a3s_rootfs_metadata_v1.json",
Path::new("/dev/zero"),
&[],
);
let error = extract_layer_with_metadata(&layer, &target).unwrap_err();
assert!(error.to_string().contains("reserved internal path"));
assert!(std::fs::symlink_metadata(target.join(".a3s_rootfs_metadata_v1.json")).is_err());
}
#[cfg(unix)]
#[test]
fn metadata_finalization_replaces_temp_symlink_without_touching_outside() {
let temp_dir = TempDir::new().unwrap();
let target = temp_dir.path().join("rootfs");
let outside = temp_dir.path().join("outside");
std::fs::create_dir_all(&target).unwrap();
std::fs::write(&outside, b"host-secret").unwrap();
std::os::unix::fs::symlink("../outside", target.join(".a3s_image_metadata_v1.json.tmp"))
.unwrap();
finalize_rootfs_metadata(&target).unwrap();
assert_eq!(std::fs::read(&outside).unwrap(), b"host-secret");
assert!(target.join(image_metadata_relative_path()).is_file());
assert!(!target.join(".a3s_image_metadata_v1.json.tmp").exists());
}
#[cfg(unix)]
#[test]
fn metadata_loading_rejects_destination_symlink_escape() {
let temp_dir = TempDir::new().unwrap();
let target = temp_dir.path().join("rootfs");
let outside = temp_dir.path().join("outside");
std::fs::create_dir_all(&target).unwrap();
std::fs::write(&outside, b"host-secret").unwrap();
std::os::unix::fs::symlink("../outside", target.join(image_metadata_relative_path()))
.unwrap();
let error = finalize_rootfs_metadata(&target).unwrap_err().to_string();
assert!(error.contains("escapes rootfs"), "{error}");
assert_eq!(std::fs::read(&outside).unwrap(), b"host-secret");
}
#[cfg(unix)]
#[test]
fn extraction_rejects_overlayfs_private_xattrs() {
let temp_dir = TempDir::new().unwrap();
for (index, xattr) in ["trusted.overlay.metacopy", "user.overlay.redirect"]
.into_iter()
.enumerate()
{
let layer = temp_dir
.path()
.join(format!("overlay-xattr-{index}.tar.gz"));
let target = temp_dir.path().join(format!("rootfs-{index}"));
create_overlay_xattr_test_layer(&layer, xattr);
let error = extract_layer_with_metadata(&layer, &target).unwrap_err();
assert!(error
.to_string()
.contains("contains reserved overlayfs metadata"));
assert!(!target.join("payload").exists());
}
}
#[test]
fn extract_layer_rejects_decompression_bomb_past_cap() {
let temp_dir = TempDir::new().unwrap();
let layer = temp_dir.path().join("bomb.tar.gz");
let target = temp_dir.path().join("out");
let big = vec![0u8; 64 * 1024];
create_test_layer(&layer, &[("big", &big)]);
let result = extract_layer_with_cap(&layer, &target, 4 * 1024, false);
assert!(
result.is_err(),
"the cap must abort an oversized (bomb) layer, got: {result:?}"
);
let written = std::fs::metadata(target.join("big"))
.map(|m| m.len())
.unwrap_or(0);
assert!(
written < 64 * 1024,
"cap must bound bytes written before aborting; wrote {written}"
);
}
#[test]
fn extract_layer_with_generous_cap_extracts_normally() {
let temp_dir = TempDir::new().unwrap();
let layer = temp_dir.path().join("ok.tar.gz");
let target = temp_dir.path().join("out");
create_test_layer(&layer, &[("file.txt", b"hello")]);
extract_layer_with_cap(&layer, &target, 16 * 1024 * 1024, false).unwrap();
assert!(target.join("file.txt").exists());
}
fn create_test_layer(path: &Path, files: &[(&str, &[u8])]) {
use flate2::write::GzEncoder;
use flate2::Compression;
use tar::Builder;
let file = File::create(path).unwrap();
let encoder = GzEncoder::new(file, Compression::default());
let mut builder = Builder::new(encoder);
for (name, content) in files {
let mut header = tar::Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_uid(0);
header.set_gid(0);
header.set_cksum();
builder.append_data(&mut header, name, *content).unwrap();
}
builder.finish().unwrap();
}
fn create_owned_test_layer(
path: &Path,
name: &str,
content: &[u8],
uid: u64,
gid: u64,
mode: u32,
) {
use flate2::write::GzEncoder;
use flate2::Compression;
use tar::Builder;
let file = File::create(path).unwrap();
let encoder = GzEncoder::new(file, Compression::default());
let mut builder = Builder::new(encoder);
let mut header = tar::Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(mode);
header.set_uid(uid);
header.set_gid(gid);
header.set_cksum();
builder.append_data(&mut header, name, content).unwrap();
builder.finish().unwrap();
}
#[cfg(unix)]
fn create_overlay_xattr_test_layer(path: &Path, xattr: &str) {
use flate2::write::GzEncoder;
use flate2::Compression;
use tar::Builder;
let file = File::create(path).unwrap();
let encoder = GzEncoder::new(file, Compression::default());
let mut builder = Builder::new(encoder);
let key = format!("SCHILY.xattr.{xattr}");
builder
.append_pax_extensions([(key.as_str(), b"".as_slice())])
.unwrap();
let mut header = tar::Header::new_gnu();
header.set_size(7);
header.set_mode(0o644);
header.set_uid(0);
header.set_gid(0);
header.set_cksum();
builder
.append_data(&mut header, "payload", b"payload".as_slice())
.unwrap();
builder.finish().unwrap();
}
fn create_hardlink_test_layer(path: &Path, name: &str, target: &str) {
use flate2::write::GzEncoder;
use flate2::Compression;
use tar::Builder;
let file = File::create(path).unwrap();
let encoder = GzEncoder::new(file, Compression::default());
let mut builder = Builder::new(encoder);
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Link);
header.set_size(0);
header.set_mode(0o755);
header.set_uid(0);
header.set_gid(0);
builder.append_link(&mut header, name, target).unwrap();
builder.finish().unwrap();
}
fn read_image_manifest(target: &Path) -> RootfsMetadataManifest {
let bytes =
std::fs::read(target.join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/'))).unwrap();
serde_json::from_slice(&bytes).unwrap()
}
fn write_test_tar<W: std::io::Write>(writer: W, files: &[(&str, &[u8])]) {
use tar::Builder;
let mut builder = Builder::new(writer);
for (name, content) in files {
let mut header = tar::Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_uid(0);
header.set_gid(0);
header.set_cksum();
builder.append_data(&mut header, name, *content).unwrap();
}
builder.finish().unwrap();
}
fn create_layer_with_symlink(path: &Path, link: &str, target: &Path, then: &[(&str, &[u8])]) {
use flate2::write::GzEncoder;
use flate2::Compression;
use tar::Builder;
let file = File::create(path).unwrap();
let mut builder = Builder::new(GzEncoder::new(file, Compression::default()));
let mut sh = tar::Header::new_gnu();
sh.set_entry_type(tar::EntryType::Symlink);
sh.set_size(0);
sh.set_mode(0o777);
sh.set_uid(0);
sh.set_gid(0);
builder.append_link(&mut sh, link, target).unwrap();
for (name, content) in then {
let mut h = tar::Header::new_gnu();
h.set_size(content.len() as u64);
h.set_mode(0o644);
h.set_uid(0);
h.set_gid(0);
h.set_cksum();
builder.append_data(&mut h, name, *content).unwrap();
}
builder.finish().unwrap();
}
#[test]
fn whiteout_does_not_delete_through_symlinked_parent() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("rootfs");
fs::create_dir_all(&target).unwrap();
let outside = tmp.path().join("outside");
fs::create_dir_all(&outside).unwrap();
let victim = outside.join("victim");
fs::write(&victim, b"keep me").unwrap();
let layer = tmp.path().join("evil.tar.gz");
create_layer_with_symlink(&layer, "esc", &outside, &[("esc/.wh.victim", b"")]);
let _ = extract_layer(&layer, &target);
assert!(
victim.exists(),
"SECURITY: whiteout followed a symlinked parent and deleted a host file outside the target ({})",
victim.display()
);
}
#[test]
fn opaque_whiteout_does_not_wipe_through_symlinked_parent() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("rootfs");
fs::create_dir_all(&target).unwrap();
let outside = tmp.path().join("outside");
fs::create_dir_all(&outside).unwrap();
let a = outside.join("a");
let b = outside.join("b");
fs::write(&a, b"a").unwrap();
fs::write(&b, b"b").unwrap();
let layer = tmp.path().join("evil.tar.gz");
create_layer_with_symlink(&layer, "esc", &outside, &[("esc/.wh..wh..opq", b"")]);
let _ = extract_layer(&layer, &target);
assert!(
a.exists() && b.exists(),
"SECURITY: opaque whiteout wiped a host directory through a symlinked parent"
);
}
#[test]
fn layer_entry_cannot_write_through_symlinked_parent() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("rootfs");
fs::create_dir_all(&target).unwrap();
let outside = tmp.path().join("outside");
fs::create_dir_all(&outside).unwrap();
let layer = tmp.path().join("evil.tar.gz");
create_layer_with_symlink(&layer, "esc", &outside, &[("esc/pwned", b"owned")]);
let _ = extract_layer(&layer, &target);
assert!(
!outside.join("pwned").exists(),
"SECURITY: a layer wrote through a symlinked parent to outside the target"
);
}
#[test]
fn test_extract_layer_handles_zstd() {
let temp_dir = TempDir::new().unwrap();
let layer_path = temp_dir.path().join("layer.tar.zst");
let target_dir = temp_dir.path().join("extracted");
{
let file = File::create(&layer_path).unwrap();
let encoder = zstd::stream::write::Encoder::new(file, 0)
.unwrap()
.auto_finish();
write_test_tar(encoder, &[("z.txt", b"zstd-content")]);
}
extract_layer(&layer_path, &target_dir).unwrap();
assert_eq!(
fs::read_to_string(target_dir.join("z.txt")).unwrap(),
"zstd-content"
);
}
#[test]
fn test_extract_layer_handles_uncompressed_tar() {
let temp_dir = TempDir::new().unwrap();
let layer_path = temp_dir.path().join("layer.tar");
let target_dir = temp_dir.path().join("extracted");
write_test_tar(File::create(&layer_path).unwrap(), &[("p.txt", b"plain")]);
extract_layer(&layer_path, &target_dir).unwrap();
assert_eq!(
fs::read_to_string(target_dir.join("p.txt")).unwrap(),
"plain"
);
}
}