use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
use anyhow::{Context, Result, ensure};
use composefs::util::DigestWrite;
use fn_error_context::context;
use sha2::{Digest, Sha256};
use composefs::{
fsverity::FsVerityHashValue,
repository::Repository,
tree::{Directory, FileSystem, Inode, Stat},
};
use containers_image_proxy::oci_spec::image::Digest as OciDigest;
use crate::skopeo::TAR_LAYER_CONTENT_TYPE;
use crate::tar::{TarEntry, TarItem};
#[context("Processing tar entry")]
pub fn process_entry<ObjectID: FsVerityHashValue>(
filesystem: &mut FileSystem<ObjectID>,
entry: TarEntry<ObjectID>,
) -> Result<()> {
if entry.path.file_name().is_none() {
ensure!(
matches!(entry.item, TarItem::Directory),
"Unpacking layer tar: filename {:?} must be a directory",
entry.path
);
filesystem.set_root_stat(entry.stat);
return Ok(());
}
let inode = match entry.item {
TarItem::Directory => Inode::Directory(Box::from(Directory::new(entry.stat))),
TarItem::Leaf(content) => {
let id = filesystem.push_leaf(entry.stat, content);
Inode::leaf(id)
}
TarItem::Hardlink(target) => {
let (dir, filename) = filesystem.root.split(&target)?;
Inode::leaf(dir.leaf_id(filename)?)
}
};
let (dir, filename) = filesystem
.root
.split_mut(entry.path.as_os_str())
.with_context(|| {
format!(
"Error unpacking container layer file {:?} {:?}",
entry.path, inode
)
})?;
let bytes = filename.as_bytes();
if let Some(whiteout) = bytes.strip_prefix(b".wh.") {
if whiteout == b".wh..opq" {
dir.clear();
} else {
dir.remove(OsStr::from_bytes(whiteout));
}
} else {
dir.merge(filename, inode);
}
Ok(())
}
pub fn create_filesystem<ObjectID: FsVerityHashValue>(
repo: &Repository<ObjectID>,
config_name: &OciDigest,
config_verity: Option<&ObjectID>,
) -> Result<FileSystem<ObjectID>> {
let mut filesystem = FileSystem::new(Stat::uninitialized());
let oc = crate::open_config(repo, config_name, config_verity)?;
let config = oc.config;
let map = oc.layer_refs;
for diff_id in config.rootfs().diff_ids() {
let layer_verity = map
.get(diff_id.as_str())
.context("OCI config splitstream missing named ref to layer {diff_id}")?;
if config_verity.is_none() {
let mut layer_stream =
repo.open_stream("", Some(layer_verity), Some(TAR_LAYER_CONTENT_TYPE))?;
let mut context = DigestWrite(Sha256::new());
layer_stream.cat(repo, &mut context)?;
let content_hash = crate::sha256_output_to_digest(context.finalize());
ensure!(
content_hash.as_ref() == diff_id,
"Layer has incorrect checksum"
);
}
let mut layer_stream =
repo.open_stream("", Some(layer_verity), Some(TAR_LAYER_CONTENT_TYPE))?;
while let Some(entry) = crate::tar::get_entry(&mut layer_stream)? {
process_entry(&mut filesystem, entry)?;
}
}
filesystem.transform_for_oci()?;
filesystem.compact();
debug_assert!(
filesystem.fsck().is_ok(),
"create_filesystem produced invalid filesystem"
);
Ok(filesystem)
}
#[cfg(test)]
mod test {
use composefs::{
dumpfile::write_dumpfile,
fsverity::Sha256HashValue,
repository::RepositoryConfig,
tree::{LeafContent, RegularFile, Stat},
};
use std::{collections::BTreeMap, io::BufRead, path::PathBuf};
use super::*;
fn file_entry<ObjectID: FsVerityHashValue>(path: &str) -> TarEntry<ObjectID> {
TarEntry {
path: PathBuf::from(path),
stat: Stat {
st_mode: 0o644,
st_uid: 0,
st_gid: 0,
st_mtim_sec: 0,
st_mtim_nsec: 0,
xattrs: BTreeMap::new(),
},
item: TarItem::Leaf(LeafContent::Regular(RegularFile::Inline([].into()))),
}
}
fn dir_entry<ObjectID: FsVerityHashValue>(path: &str) -> TarEntry<ObjectID> {
TarEntry {
path: PathBuf::from(path),
stat: Stat {
st_mode: 0o755,
st_uid: 0,
st_gid: 0,
st_mtim_sec: 0,
st_mtim_nsec: 0,
xattrs: BTreeMap::new(),
},
item: TarItem::Directory,
}
}
fn assert_files(fs: &FileSystem<impl FsVerityHashValue>, expected: &[&str]) -> Result<()> {
let mut out = vec![];
write_dumpfile(&mut out, fs)?;
let actual: Vec<String> = out
.lines()
.map(|line| line.unwrap().split_once(' ').unwrap().0.into())
.collect();
similar_asserts::assert_eq!(actual, expected);
Ok(())
}
fn append_tar_dir(builder: &mut ::tar::Builder<Vec<u8>>, name: &str) {
let mut header = ::tar::Header::new_ustar();
header.set_uid(0);
header.set_gid(0);
header.set_mode(0o755);
header.set_entry_type(::tar::EntryType::Directory);
header.set_size(0);
builder
.append_data(&mut header, name, std::io::empty())
.unwrap();
}
fn append_tar_file(builder: &mut ::tar::Builder<Vec<u8>>, name: &str, content: &[u8]) {
let mut header = ::tar::Header::new_ustar();
header.set_uid(0);
header.set_gid(0);
header.set_mode(0o644);
header.set_entry_type(::tar::EntryType::Regular);
header.set_size(content.len() as u64);
builder.append_data(&mut header, name, content).unwrap();
}
fn append_tar_symlink(builder: &mut ::tar::Builder<Vec<u8>>, name: &str, target: &str) {
let mut header = ::tar::Header::new_ustar();
header.set_uid(0);
header.set_gid(0);
header.set_mode(0o777);
header.set_entry_type(::tar::EntryType::Symlink);
header.set_size(0);
builder.append_link(&mut header, name, target).unwrap();
}
fn append_tar_hardlink(builder: &mut ::tar::Builder<Vec<u8>>, name: &str, target: &str) {
let mut header = ::tar::Header::new_ustar();
header.set_uid(0);
header.set_gid(0);
header.set_mode(0o644);
header.set_entry_type(::tar::EntryType::Link);
header.set_size(0);
builder.append_link(&mut header, name, target).unwrap();
}
fn build_baseimage() -> (Vec<u8>, String) {
let mut builder = ::tar::Builder::new(vec![]);
append_tar_dir(&mut builder, "bin"); append_tar_dir(&mut builder, "etc");
append_tar_dir(&mut builder, "tmp");
append_tar_dir(&mut builder, "usr");
append_tar_dir(&mut builder, "usr/bin");
append_tar_dir(&mut builder, "usr/lib");
append_tar_dir(&mut builder, "usr/share");
append_tar_dir(&mut builder, "usr/share/doc");
append_tar_dir(&mut builder, "var");
append_tar_dir(&mut builder, "var/log");
append_tar_file(&mut builder, "etc/hostname", b"busybox-container\n");
append_tar_file(
&mut builder,
"etc/resolv.conf",
b"nameserver 8.8.8.8\nnameserver 8.8.4.4\n",
);
append_tar_file(
&mut builder,
"etc/passwd",
b"root:x:0:0:root:/root:/bin/sh\nnobody:x:65534:65534:Nobody:/nonexistent:/usr/sbin/nologin\n\
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n",
);
let busybox_content: Vec<u8> = (0..65536u64).map(|i| (i % 251) as u8).collect();
append_tar_file(&mut builder, "usr/bin/busybox", &busybox_content);
let libc_content: Vec<u8> = (0..32768u64).map(|i| (i % 241) as u8).collect();
append_tar_file(&mut builder, "usr/lib/libc.so", &libc_content);
let readme_content = "composefs-rs test image\n\
This is a synthetic busybox-like filesystem used for round-trip testing.\n\
It exercises inline files, external files, symlinks, and hardlinks.\n\
The filesystem layout mimics a minimal container image with /usr merge.\n\
Generated by build_baseimage() in the composefs-oci test suite.\n\
----\n\
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod\n\
tempor incididunt ut labore et dolore magna aliqua.\n";
append_tar_file(
&mut builder,
"usr/share/doc/README",
readme_content.as_bytes(),
);
let messages_content: Vec<u8> = (0..8192u64).map(|i| (i % 239) as u8).collect();
append_tar_file(&mut builder, "var/log/messages", &messages_content);
append_tar_symlink(&mut builder, "usr/bin/cat", "busybox");
append_tar_symlink(&mut builder, "usr/bin/ls", "busybox");
append_tar_symlink(&mut builder, "usr/bin/sh", "busybox");
append_tar_symlink(&mut builder, "usr/lib/libc.so.6", "libc.so");
append_tar_hardlink(&mut builder, "usr/bin/cp", "usr/bin/busybox");
append_tar_symlink(&mut builder, "bin", "usr/bin");
let data = builder.into_inner().unwrap();
let diff_id = crate::sha256_content_digest(&data).to_string();
(data, diff_id)
}
#[tokio::test]
async fn test_build_baseimage_roundtrip() -> Result<()> {
use composefs::{
INLINE_CONTENT_MAX_V0,
repository::{Repository, RepositoryConfig},
test::tempdir,
};
use rustix::fs::CWD;
use std::ffi::OsStr;
use std::sync::Arc;
let (tar_data, diff_id_str) = build_baseimage();
let diff_id: OciDigest = diff_id_str.parse()?;
let repo_dir = tempdir();
let repo_path = repo_dir.path().join("repo");
let (repo, _) = Repository::<Sha256HashValue>::init_path(
CWD,
&repo_path,
RepositoryConfig::default().set_insecure(),
)?;
let repo = Arc::new(repo);
let (verity, _stats) =
crate::import_layer(&repo, &diff_id, Some("layer"), &tar_data[..]).await?;
let mut stream = repo.open_stream("refs/layer", Some(&verity), None)?;
let mut entries = vec![];
while let Some(entry) = crate::tar::get_entry(&mut stream)? {
entries.push(entry);
}
let by_path = |p: &str| -> &TarEntry<Sha256HashValue> {
entries
.iter()
.find(|e| e.path == PathBuf::from(p))
.unwrap_or_else(|| panic!("missing entry for {p}"))
};
let expected_dirs = [
"/bin", "/etc",
"/tmp",
"/usr",
"/usr/bin",
"/usr/lib",
"/usr/share",
"/usr/share/doc",
"/var",
"/var/log",
];
for dir in &expected_dirs {
let entry = by_path(dir);
assert!(
matches!(entry.item, TarItem::Directory),
"{dir} should be a directory, got {:?}",
entry.item
);
assert_eq!(entry.stat.st_mode, 0o755, "{dir} mode");
}
let hostname = by_path("/etc/hostname");
match &hostname.item {
TarItem::Leaf(LeafContent::Regular(RegularFile::Inline(data))) => {
assert_eq!(data.as_ref(), b"busybox-container\n");
assert!(
data.len() <= INLINE_CONTENT_MAX_V0,
"hostname should be inline ({} bytes <= {INLINE_CONTENT_MAX_V0})",
data.len()
);
}
other => panic!("expected inline file for /etc/hostname, got {other:?}"),
}
let resolv = by_path("/etc/resolv.conf");
match &resolv.item {
TarItem::Leaf(LeafContent::Regular(RegularFile::Inline(data))) => {
assert!(data.starts_with(b"nameserver"));
assert!(
data.len() <= INLINE_CONTENT_MAX_V0,
"resolv.conf should be inline ({} bytes <= {INLINE_CONTENT_MAX_V0})",
data.len()
);
}
other => panic!("expected inline file for /etc/resolv.conf, got {other:?}"),
}
let passwd = by_path("/etc/passwd");
match &passwd.item {
TarItem::Leaf(LeafContent::Regular(RegularFile::External(_, size))) => {
assert!(
*size as usize > INLINE_CONTENT_MAX_V0,
"passwd should be external ({size} bytes > {INLINE_CONTENT_MAX_V0})"
);
}
other => panic!("expected external file for /etc/passwd, got {other:?}"),
}
let busybox = by_path("/usr/bin/busybox");
match &busybox.item {
TarItem::Leaf(LeafContent::Regular(RegularFile::External(_, size))) => {
assert_eq!(*size, 65536, "busybox should be 64KB");
}
other => panic!("expected external file for /usr/bin/busybox, got {other:?}"),
}
let libc = by_path("/usr/lib/libc.so");
match &libc.item {
TarItem::Leaf(LeafContent::Regular(RegularFile::External(_, size))) => {
assert_eq!(*size, 32768, "libc.so should be 32KB");
}
other => panic!("expected external file for /usr/lib/libc.so, got {other:?}"),
}
let readme = by_path("/usr/share/doc/README");
match &readme.item {
TarItem::Leaf(LeafContent::Regular(RegularFile::External(_, size))) => {
assert!(
*size as usize > INLINE_CONTENT_MAX_V0,
"README should be external ({size} bytes)"
);
}
other => panic!("expected external file for README, got {other:?}"),
}
let messages = by_path("/var/log/messages");
match &messages.item {
TarItem::Leaf(LeafContent::Regular(RegularFile::External(_, size))) => {
assert_eq!(*size, 8192, "messages should be 8KB");
}
other => panic!("expected external file for /var/log/messages, got {other:?}"),
}
let symlinks = [
("/usr/bin/cat", "busybox"),
("/usr/bin/ls", "busybox"),
("/usr/bin/sh", "busybox"),
("/usr/lib/libc.so.6", "libc.so"),
];
for (path, target) in &symlinks {
let entry = by_path(path);
match &entry.item {
TarItem::Leaf(LeafContent::Symlink(t)) => {
assert_eq!(&**t, OsStr::new(target), "{path} symlink target");
}
other => panic!("expected symlink for {path}, got {other:?}"),
}
}
let cp = by_path("/usr/bin/cp");
match &cp.item {
TarItem::Hardlink(target) => {
assert_eq!(target, OsStr::new("/usr/bin/busybox"), "cp hardlink target");
}
other => panic!("expected hardlink for /usr/bin/cp, got {other:?}"),
}
let bin_entries: Vec<_> = entries
.iter()
.filter(|e| e.path == PathBuf::from("/bin"))
.collect();
assert!(
bin_entries.len() >= 2,
"/bin should appear as both a directory and a symlink"
);
let last_bin = bin_entries.last().unwrap();
match &last_bin.item {
TarItem::Leaf(LeafContent::Symlink(t)) => {
assert_eq!(&**t, OsStr::new("usr/bin"), "/bin symlink target");
}
other => panic!("expected symlink for final /bin, got {other:?}"),
}
let expected_count = 10 + 7 + 4 + 1 + 1; assert_eq!(
entries.len(),
expected_count,
"total entry count (dirs + files + symlinks + hardlinks)"
);
Ok(())
}
#[test]
fn test_process_entry() -> Result<()> {
let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
process_entry(&mut fs, dir_entry("/a"))?;
process_entry(&mut fs, dir_entry("b"))?;
process_entry(&mut fs, dir_entry("c"))?;
assert_files(&fs, &["/", "/a", "/b", "/c"])?;
process_entry(&mut fs, file_entry("/a/b"))?;
process_entry(&mut fs, file_entry("/a/c"))?;
process_entry(&mut fs, file_entry("/b/a"))?;
process_entry(&mut fs, file_entry("/b/c"))?;
process_entry(&mut fs, file_entry("/c/a"))?;
process_entry(&mut fs, file_entry("/c/c"))?;
assert_files(
&fs,
&[
"/", "/a", "/a/b", "/a/c", "/b", "/b/a", "/b/c", "/c", "/c/a", "/c/c",
],
)?;
process_entry(&mut fs, file_entry(".wh.a"))?; process_entry(&mut fs, file_entry("/b/.wh..wh..opq"))?; process_entry(&mut fs, file_entry("/c/.wh.c"))?; assert_files(&fs, &["/", "/b", "/c", "/c/a"])?;
Ok(())
}
#[test]
fn test_whiteout_file_removes_entry() -> Result<()> {
let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
process_entry(&mut fs, dir_entry("/etc"))?;
process_entry(&mut fs, file_entry("/etc/hosts"))?;
process_entry(&mut fs, file_entry("/etc/passwd"))?;
assert_files(&fs, &["/", "/etc", "/etc/hosts", "/etc/passwd"])?;
process_entry(&mut fs, file_entry("/etc/.wh.hosts"))?;
assert_files(&fs, &["/", "/etc", "/etc/passwd"])?;
Ok(())
}
#[test]
fn test_whiteout_nonexistent_file_is_noop() -> Result<()> {
let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
process_entry(&mut fs, dir_entry("/etc"))?;
process_entry(&mut fs, file_entry("/etc/hosts"))?;
assert_files(&fs, &["/", "/etc", "/etc/hosts"])?;
process_entry(&mut fs, file_entry("/etc/.wh.nosuchfile"))?;
assert_files(&fs, &["/", "/etc", "/etc/hosts"])?;
Ok(())
}
#[test]
fn test_whiteout_directory() -> Result<()> {
let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
process_entry(&mut fs, dir_entry("/usr"))?;
process_entry(&mut fs, dir_entry("/usr/local"))?;
process_entry(&mut fs, file_entry("/usr/local/bin"))?;
process_entry(&mut fs, dir_entry("/etc"))?;
assert_files(&fs, &["/", "/etc", "/usr", "/usr/local", "/usr/local/bin"])?;
process_entry(&mut fs, file_entry("/usr/.wh.local"))?;
assert_files(&fs, &["/", "/etc", "/usr"])?;
Ok(())
}
#[test]
fn test_whiteout_in_root_directory() -> Result<()> {
let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
process_entry(&mut fs, dir_entry("/mydir"))?;
process_entry(&mut fs, file_entry("/toplevel"))?;
assert_files(&fs, &["/", "/mydir", "/toplevel"])?;
process_entry(&mut fs, file_entry("/.wh.toplevel"))?;
assert_files(&fs, &["/", "/mydir"])?;
process_entry(&mut fs, file_entry(".wh.mydir"))?;
assert_files(&fs, &["/"])?;
Ok(())
}
#[test]
fn test_whiteout_in_nested_directory() -> Result<()> {
let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
process_entry(&mut fs, dir_entry("/a"))?;
process_entry(&mut fs, dir_entry("/a/b"))?;
process_entry(&mut fs, dir_entry("/a/b/c"))?;
process_entry(&mut fs, file_entry("/a/b/c/deep"))?;
assert_files(&fs, &["/", "/a", "/a/b", "/a/b/c", "/a/b/c/deep"])?;
process_entry(&mut fs, file_entry("/a/b/c/.wh.deep"))?;
assert_files(&fs, &["/", "/a", "/a/b", "/a/b/c"])?;
Ok(())
}
#[test]
fn test_opaque_whiteout_clears_directory() -> Result<()> {
let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
process_entry(&mut fs, dir_entry("/etc"))?;
process_entry(&mut fs, file_entry("/etc/hosts"))?;
process_entry(&mut fs, file_entry("/etc/passwd"))?;
process_entry(&mut fs, file_entry("/etc/resolv.conf"))?;
assert_files(
&fs,
&["/", "/etc", "/etc/hosts", "/etc/passwd", "/etc/resolv.conf"],
)?;
process_entry(&mut fs, file_entry("/etc/.wh..wh..opq"))?;
assert_files(&fs, &["/", "/etc"])?;
Ok(())
}
#[test]
fn test_opaque_whiteout_then_add_new_entries() -> Result<()> {
let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
process_entry(&mut fs, dir_entry("/etc"))?;
process_entry(&mut fs, file_entry("/etc/old_config"))?;
process_entry(&mut fs, file_entry("/etc/another_old"))?;
assert_files(&fs, &["/", "/etc", "/etc/another_old", "/etc/old_config"])?;
process_entry(&mut fs, file_entry("/etc/.wh..wh..opq"))?;
assert_files(&fs, &["/", "/etc"])?;
process_entry(&mut fs, file_entry("/etc/new_config"))?;
process_entry(&mut fs, file_entry("/etc/new_other"))?;
assert_files(&fs, &["/", "/etc", "/etc/new_config", "/etc/new_other"])?;
Ok(())
}
#[test]
fn test_multiple_whiteouts_in_single_layer() -> Result<()> {
let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
process_entry(&mut fs, dir_entry("/usr"))?;
process_entry(&mut fs, file_entry("/usr/a"))?;
process_entry(&mut fs, file_entry("/usr/b"))?;
process_entry(&mut fs, file_entry("/usr/c"))?;
process_entry(&mut fs, file_entry("/usr/d"))?;
assert_files(&fs, &["/", "/usr", "/usr/a", "/usr/b", "/usr/c", "/usr/d"])?;
process_entry(&mut fs, file_entry("/usr/.wh.a"))?;
process_entry(&mut fs, file_entry("/usr/.wh.c"))?;
assert_files(&fs, &["/", "/usr", "/usr/b", "/usr/d"])?;
Ok(())
}
#[test]
fn test_double_whiteout_is_idempotent() -> Result<()> {
let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
process_entry(&mut fs, dir_entry("/d"))?;
process_entry(&mut fs, file_entry("/d/target"))?;
assert_files(&fs, &["/", "/d", "/d/target"])?;
process_entry(&mut fs, file_entry("/d/.wh.target"))?;
assert_files(&fs, &["/", "/d"])?;
process_entry(&mut fs, file_entry("/d/.wh.target"))?;
assert_files(&fs, &["/", "/d"])?;
Ok(())
}
#[test]
fn test_whiteout_unusual_name_dot_wh_dot() -> Result<()> {
let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
process_entry(&mut fs, dir_entry("/d"))?;
process_entry(&mut fs, file_entry("/d/real_file"))?;
assert_files(&fs, &["/", "/d", "/d/real_file"])?;
process_entry(&mut fs, file_entry("/d/.wh..wh."))?;
assert_files(&fs, &["/", "/d", "/d/real_file"])?;
process_entry(&mut fs, file_entry("/d/.wh."))?;
assert_files(&fs, &["/", "/d", "/d/real_file"])?;
Ok(())
}
#[test]
fn test_whiteout_across_multiple_directories() -> Result<()> {
let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
process_entry(&mut fs, dir_entry("/a"))?;
process_entry(&mut fs, dir_entry("/b"))?;
process_entry(&mut fs, file_entry("/a/file1"))?;
process_entry(&mut fs, file_entry("/a/file2"))?;
process_entry(&mut fs, file_entry("/b/file1"))?;
process_entry(&mut fs, file_entry("/b/file2"))?;
assert_files(
&fs,
&[
"/", "/a", "/a/file1", "/a/file2", "/b", "/b/file1", "/b/file2",
],
)?;
process_entry(&mut fs, file_entry("/a/.wh.file1"))?;
process_entry(&mut fs, file_entry("/b/.wh.file2"))?;
assert_files(&fs, &["/", "/a", "/a/file2", "/b", "/b/file1"])?;
Ok(())
}
#[test]
fn test_opaque_whiteout_with_subdirectories() -> Result<()> {
let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
process_entry(&mut fs, dir_entry("/parent"))?;
process_entry(&mut fs, dir_entry("/parent/child"))?;
process_entry(&mut fs, file_entry("/parent/child/deep"))?;
process_entry(&mut fs, file_entry("/parent/sibling"))?;
assert_files(
&fs,
&[
"/",
"/parent",
"/parent/child",
"/parent/child/deep",
"/parent/sibling",
],
)?;
process_entry(&mut fs, file_entry("/parent/.wh..wh..opq"))?;
assert_files(&fs, &["/", "/parent"])?;
Ok(())
}
#[test]
fn test_whiteout_then_recreate() -> Result<()> {
let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
process_entry(&mut fs, dir_entry("/etc"))?;
process_entry(&mut fs, file_entry("/etc/config"))?;
assert_files(&fs, &["/", "/etc", "/etc/config"])?;
process_entry(&mut fs, file_entry("/etc/.wh.config"))?;
assert_files(&fs, &["/", "/etc"])?;
process_entry(&mut fs, file_entry("/etc/config"))?;
assert_files(&fs, &["/", "/etc", "/etc/config"])?;
Ok(())
}
}