Skip to main content

composefs_oci/
image.rs

1//! OCI image processing and filesystem construction.
2//!
3//! This module handles the conversion of OCI container image layers into composefs filesystems.
4//! It processes tar entries from container layers, handles overlayfs semantics like whiteouts,
5//! and constructs the final filesystem tree that can be mounted or analyzed.
6//!
7//! The main functionality centers around `create_filesystem()` which takes an OCI image configuration
8//! and builds a complete filesystem by processing all layers in order. The `process_entry()` function
9//! handles individual tar entries and implements overlayfs whiteout semantics for proper layer merging.
10
11use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
12
13use anyhow::{Context, Result, ensure};
14use composefs::util::DigestWrite;
15use fn_error_context::context;
16use sha2::{Digest, Sha256};
17
18use composefs::{
19    fsverity::FsVerityHashValue,
20    repository::Repository,
21    tree::{Directory, FileSystem, Inode, Stat},
22};
23
24use containers_image_proxy::oci_spec::image::Digest as OciDigest;
25
26use crate::skopeo::TAR_LAYER_CONTENT_TYPE;
27use crate::tar::{TarEntry, TarItem};
28
29/// Processes a single tar entry and adds it to the filesystem.
30///
31/// Handles various tar entry types (regular files, directories, symlinks, hardlinks, devices, fifos)
32/// and implements overlayfs whiteout semantics for proper layer merging. Files named `.wh.<name>`
33/// delete the corresponding file, and `.wh..wh.opq` marks a directory as opaque (clearing all contents).
34///
35/// Returns an error if the entry cannot be processed or added to the filesystem.
36#[context("Processing tar entry")]
37pub fn process_entry<ObjectID: FsVerityHashValue>(
38    filesystem: &mut FileSystem<ObjectID>,
39    entry: TarEntry<ObjectID>,
40) -> Result<()> {
41    if entry.path.file_name().is_none() {
42        // special handling for the root directory
43        ensure!(
44            matches!(entry.item, TarItem::Directory),
45            "Unpacking layer tar: filename {:?} must be a directory",
46            entry.path
47        );
48
49        // Update the stat, but don't do anything else
50        filesystem.set_root_stat(entry.stat);
51        return Ok(());
52    }
53
54    let inode = match entry.item {
55        TarItem::Directory => Inode::Directory(Box::from(Directory::new(entry.stat))),
56        TarItem::Leaf(content) => {
57            let id = filesystem.push_leaf(entry.stat, content);
58            Inode::leaf(id)
59        }
60        TarItem::Hardlink(target) => {
61            let (dir, filename) = filesystem.root.split(&target)?;
62            Inode::leaf(dir.leaf_id(filename)?)
63        }
64    };
65
66    let (dir, filename) = filesystem
67        .root
68        .split_mut(entry.path.as_os_str())
69        .with_context(|| {
70            format!(
71                "Error unpacking container layer file {:?} {:?}",
72                entry.path, inode
73            )
74        })?;
75
76    let bytes = filename.as_bytes();
77    if let Some(whiteout) = bytes.strip_prefix(b".wh.") {
78        if whiteout == b".wh..opq" {
79            // complete name is '.wh..wh..opq'
80            dir.clear();
81        } else {
82            dir.remove(OsStr::from_bytes(whiteout));
83        }
84    } else {
85        dir.merge(filename, inode);
86    }
87
88    Ok(())
89}
90
91/// Creates a filesystem from the given OCI container.  No special transformations are performed to
92/// make the filesystem bootable.
93///
94/// OCI container layer tars often don't include a root directory entry, and when they do,
95/// container runtimes typically ignore it (using hardcoded defaults instead). This makes
96/// root metadata non-deterministic. To ensure consistent digests, this function copies
97/// root metadata from `/usr` after processing all layers.
98/// See: <https://github.com/containers/storage/pull/743>
99///
100/// If `config_verity` is given it is used to get the OCI config splitstream by its fs-verity ID
101/// and the entire process is substantially faster.  If it is not given, the config and layers will
102/// be hashed to ensure that they match their claimed blob IDs.
103pub fn create_filesystem<ObjectID: FsVerityHashValue>(
104    repo: &Repository<ObjectID>,
105    config_name: &OciDigest,
106    config_verity: Option<&ObjectID>,
107) -> Result<FileSystem<ObjectID>> {
108    let mut filesystem = FileSystem::new(Stat::uninitialized());
109
110    let oc = crate::open_config(repo, config_name, config_verity)?;
111    let config = oc.config;
112    let map = oc.layer_refs;
113
114    for diff_id in config.rootfs().diff_ids() {
115        let layer_verity = map
116            .get(diff_id.as_str())
117            .context("OCI config splitstream missing named ref to layer {diff_id}")?;
118
119        if config_verity.is_none() {
120            // We don't have any proof that the named references in the config splitstream are
121            // trustworthy. We have no choice but to perform expensive validation of the layer
122            // stream.
123            let mut layer_stream =
124                repo.open_stream("", Some(layer_verity), Some(TAR_LAYER_CONTENT_TYPE))?;
125            let mut context = DigestWrite(Sha256::new());
126            layer_stream.cat(repo, &mut context)?;
127            let content_hash = crate::sha256_output_to_digest(context.finalize());
128            ensure!(
129                content_hash.as_ref() == diff_id,
130                "Layer has incorrect checksum"
131            );
132        }
133
134        let mut layer_stream =
135            repo.open_stream("", Some(layer_verity), Some(TAR_LAYER_CONTENT_TYPE))?;
136        while let Some(entry) = crate::tar::get_entry(&mut layer_stream)? {
137            process_entry(&mut filesystem, entry)?;
138        }
139    }
140
141    // Apply OCI container transformations for consistent digests.
142    // See https://github.com/containers/composefs-rs/issues/132
143    filesystem.transform_for_oci()?;
144
145    // Whiteout processing and layer merging can leave orphaned leaves.
146    filesystem.compact();
147
148    debug_assert!(
149        filesystem.fsck().is_ok(),
150        "create_filesystem produced invalid filesystem"
151    );
152    Ok(filesystem)
153}
154
155#[cfg(test)]
156mod test {
157    use composefs::{
158        dumpfile::write_dumpfile,
159        fsverity::Sha256HashValue,
160        repository::RepositoryConfig,
161        tree::{LeafContent, RegularFile, Stat},
162    };
163    use std::{collections::BTreeMap, io::BufRead, path::PathBuf};
164
165    use super::*;
166
167    fn file_entry<ObjectID: FsVerityHashValue>(path: &str) -> TarEntry<ObjectID> {
168        TarEntry {
169            path: PathBuf::from(path),
170            stat: Stat {
171                st_mode: 0o644,
172                st_uid: 0,
173                st_gid: 0,
174                st_mtim_sec: 0,
175                st_mtim_nsec: 0,
176                xattrs: BTreeMap::new(),
177            },
178            item: TarItem::Leaf(LeafContent::Regular(RegularFile::Inline([].into()))),
179        }
180    }
181
182    fn dir_entry<ObjectID: FsVerityHashValue>(path: &str) -> TarEntry<ObjectID> {
183        TarEntry {
184            path: PathBuf::from(path),
185            stat: Stat {
186                st_mode: 0o755,
187                st_uid: 0,
188                st_gid: 0,
189                st_mtim_sec: 0,
190                st_mtim_nsec: 0,
191                xattrs: BTreeMap::new(),
192            },
193            item: TarItem::Directory,
194        }
195    }
196
197    fn assert_files(fs: &FileSystem<impl FsVerityHashValue>, expected: &[&str]) -> Result<()> {
198        let mut out = vec![];
199        write_dumpfile(&mut out, fs)?;
200        let actual: Vec<String> = out
201            .lines()
202            .map(|line| line.unwrap().split_once(' ').unwrap().0.into())
203            .collect();
204
205        similar_asserts::assert_eq!(actual, expected);
206        Ok(())
207    }
208
209    fn append_tar_dir(builder: &mut ::tar::Builder<Vec<u8>>, name: &str) {
210        let mut header = ::tar::Header::new_ustar();
211        header.set_uid(0);
212        header.set_gid(0);
213        header.set_mode(0o755);
214        header.set_entry_type(::tar::EntryType::Directory);
215        header.set_size(0);
216        builder
217            .append_data(&mut header, name, std::io::empty())
218            .unwrap();
219    }
220
221    /// Append a regular file with explicit content bytes to a tar builder.
222    fn append_tar_file(builder: &mut ::tar::Builder<Vec<u8>>, name: &str, content: &[u8]) {
223        let mut header = ::tar::Header::new_ustar();
224        header.set_uid(0);
225        header.set_gid(0);
226        header.set_mode(0o644);
227        header.set_entry_type(::tar::EntryType::Regular);
228        header.set_size(content.len() as u64);
229        builder.append_data(&mut header, name, content).unwrap();
230    }
231
232    /// Append a symlink entry to a tar builder.
233    fn append_tar_symlink(builder: &mut ::tar::Builder<Vec<u8>>, name: &str, target: &str) {
234        let mut header = ::tar::Header::new_ustar();
235        header.set_uid(0);
236        header.set_gid(0);
237        header.set_mode(0o777);
238        header.set_entry_type(::tar::EntryType::Symlink);
239        header.set_size(0);
240        builder.append_link(&mut header, name, target).unwrap();
241    }
242
243    /// Append a hardlink entry to a tar builder.
244    fn append_tar_hardlink(builder: &mut ::tar::Builder<Vec<u8>>, name: &str, target: &str) {
245        let mut header = ::tar::Header::new_ustar();
246        header.set_uid(0);
247        header.set_gid(0);
248        header.set_mode(0o644);
249        header.set_entry_type(::tar::EntryType::Link);
250        header.set_size(0);
251        builder.append_link(&mut header, name, target).unwrap();
252    }
253
254    /// Build a realistic busybox-like container filesystem as a tar archive.
255    ///
256    /// Exercises directories, regular files (both inline and external), symlinks,
257    /// and hardlinks. Returns `(tar_bytes, "sha256:<hex>")`.
258    fn build_baseimage() -> (Vec<u8>, String) {
259        let mut builder = ::tar::Builder::new(vec![]);
260
261        // Directories (sorted at each level for deterministic output)
262        append_tar_dir(&mut builder, "bin"); // will be replaced by symlink below
263        append_tar_dir(&mut builder, "etc");
264        append_tar_dir(&mut builder, "tmp");
265        append_tar_dir(&mut builder, "usr");
266        append_tar_dir(&mut builder, "usr/bin");
267        append_tar_dir(&mut builder, "usr/lib");
268        append_tar_dir(&mut builder, "usr/share");
269        append_tar_dir(&mut builder, "usr/share/doc");
270        append_tar_dir(&mut builder, "var");
271        append_tar_dir(&mut builder, "var/log");
272
273        // Regular files — inline (<=64 bytes, the INLINE_CONTENT_MAX_V0 threshold)
274        append_tar_file(&mut builder, "etc/hostname", b"busybox-container\n");
275        append_tar_file(
276            &mut builder,
277            "etc/resolv.conf",
278            b"nameserver 8.8.8.8\nnameserver 8.8.4.4\n",
279        );
280
281        // Regular files — external (>64 bytes)
282        append_tar_file(
283            &mut builder,
284            "etc/passwd",
285            b"root:x:0:0:root:/root:/bin/sh\nnobody:x:65534:65534:Nobody:/nonexistent:/usr/sbin/nologin\n\
286              daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n",
287        );
288
289        // Large external files with recognizable byte patterns
290        let busybox_content: Vec<u8> = (0..65536u64).map(|i| (i % 251) as u8).collect();
291        append_tar_file(&mut builder, "usr/bin/busybox", &busybox_content);
292
293        let libc_content: Vec<u8> = (0..32768u64).map(|i| (i % 241) as u8).collect();
294        append_tar_file(&mut builder, "usr/lib/libc.so", &libc_content);
295
296        let readme_content = "composefs-rs test image\n\
297            This is a synthetic busybox-like filesystem used for round-trip testing.\n\
298            It exercises inline files, external files, symlinks, and hardlinks.\n\
299            The filesystem layout mimics a minimal container image with /usr merge.\n\
300            Generated by build_baseimage() in the composefs-oci test suite.\n\
301            ----\n\
302            Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod\n\
303            tempor incididunt ut labore et dolore magna aliqua.\n";
304        append_tar_file(
305            &mut builder,
306            "usr/share/doc/README",
307            readme_content.as_bytes(),
308        );
309
310        let messages_content: Vec<u8> = (0..8192u64).map(|i| (i % 239) as u8).collect();
311        append_tar_file(&mut builder, "var/log/messages", &messages_content);
312
313        // Symlinks (sorted within each directory)
314        append_tar_symlink(&mut builder, "usr/bin/cat", "busybox");
315        append_tar_symlink(&mut builder, "usr/bin/ls", "busybox");
316        append_tar_symlink(&mut builder, "usr/bin/sh", "busybox");
317        append_tar_symlink(&mut builder, "usr/lib/libc.so.6", "libc.so");
318
319        // Hardlink: /usr/bin/cp -> /usr/bin/busybox (must appear after busybox)
320        append_tar_hardlink(&mut builder, "usr/bin/cp", "usr/bin/busybox");
321
322        // Directory symlink: /bin -> usr/bin (after /usr/bin directory exists)
323        // We already created /bin as a directory above; overwrite it with a symlink.
324        // In tar, later entries replace earlier ones, so this replaces the dir.
325        append_tar_symlink(&mut builder, "bin", "usr/bin");
326
327        let data = builder.into_inner().unwrap();
328        let diff_id = crate::sha256_content_digest(&data).to_string();
329        (data, diff_id)
330    }
331
332    /// Comprehensive round-trip test: build a busybox-like tar layer via
333    /// `build_baseimage()`, import it with `import_layer()`, read it back
334    /// with `get_entry()`, and verify every entry type round-trips correctly.
335    #[tokio::test]
336    async fn test_build_baseimage_roundtrip() -> Result<()> {
337        use composefs::{
338            INLINE_CONTENT_MAX_V0,
339            repository::{Repository, RepositoryConfig},
340            test::tempdir,
341        };
342        use rustix::fs::CWD;
343        use std::ffi::OsStr;
344        use std::sync::Arc;
345
346        let (tar_data, diff_id_str) = build_baseimage();
347        let diff_id: OciDigest = diff_id_str.parse()?;
348
349        let repo_dir = tempdir();
350        let repo_path = repo_dir.path().join("repo");
351        let (repo, _) = Repository::<Sha256HashValue>::init_path(
352            CWD,
353            &repo_path,
354            RepositoryConfig::default().set_insecure(),
355        )?;
356        let repo = Arc::new(repo);
357        let (verity, _stats) =
358            crate::import_layer(&repo, &diff_id, Some("layer"), &tar_data[..]).await?;
359
360        let mut stream = repo.open_stream("refs/layer", Some(&verity), None)?;
361        let mut entries = vec![];
362        while let Some(entry) = crate::tar::get_entry(&mut stream)? {
363            entries.push(entry);
364        }
365
366        // Build a lookup by path for easier assertions
367        let by_path = |p: &str| -> &TarEntry<Sha256HashValue> {
368            entries
369                .iter()
370                .find(|e| e.path == PathBuf::from(p))
371                .unwrap_or_else(|| panic!("missing entry for {p}"))
372        };
373
374        // --- Directories ---
375        let expected_dirs = [
376            "/bin", // initial dir entry (later overwritten by symlink in tar, but splitstream preserves order)
377            "/etc",
378            "/tmp",
379            "/usr",
380            "/usr/bin",
381            "/usr/lib",
382            "/usr/share",
383            "/usr/share/doc",
384            "/var",
385            "/var/log",
386        ];
387        for dir in &expected_dirs {
388            let entry = by_path(dir);
389            assert!(
390                matches!(entry.item, TarItem::Directory),
391                "{dir} should be a directory, got {:?}",
392                entry.item
393            );
394            assert_eq!(entry.stat.st_mode, 0o755, "{dir} mode");
395        }
396
397        // --- Inline files (<=INLINE_CONTENT_MAX_V0 bytes) ---
398        let hostname = by_path("/etc/hostname");
399        match &hostname.item {
400            TarItem::Leaf(LeafContent::Regular(RegularFile::Inline(data))) => {
401                assert_eq!(data.as_ref(), b"busybox-container\n");
402                assert!(
403                    data.len() <= INLINE_CONTENT_MAX_V0,
404                    "hostname should be inline ({} bytes <= {INLINE_CONTENT_MAX_V0})",
405                    data.len()
406                );
407            }
408            other => panic!("expected inline file for /etc/hostname, got {other:?}"),
409        }
410
411        let resolv = by_path("/etc/resolv.conf");
412        match &resolv.item {
413            TarItem::Leaf(LeafContent::Regular(RegularFile::Inline(data))) => {
414                assert!(data.starts_with(b"nameserver"));
415                assert!(
416                    data.len() <= INLINE_CONTENT_MAX_V0,
417                    "resolv.conf should be inline ({} bytes <= {INLINE_CONTENT_MAX_V0})",
418                    data.len()
419                );
420            }
421            other => panic!("expected inline file for /etc/resolv.conf, got {other:?}"),
422        }
423
424        // --- External files (>INLINE_CONTENT_MAX_V0 bytes) ---
425        let passwd = by_path("/etc/passwd");
426        match &passwd.item {
427            TarItem::Leaf(LeafContent::Regular(RegularFile::External(_, size))) => {
428                assert!(
429                    *size as usize > INLINE_CONTENT_MAX_V0,
430                    "passwd should be external ({size} bytes > {INLINE_CONTENT_MAX_V0})"
431                );
432            }
433            other => panic!("expected external file for /etc/passwd, got {other:?}"),
434        }
435
436        let busybox = by_path("/usr/bin/busybox");
437        match &busybox.item {
438            TarItem::Leaf(LeafContent::Regular(RegularFile::External(_, size))) => {
439                assert_eq!(*size, 65536, "busybox should be 64KB");
440            }
441            other => panic!("expected external file for /usr/bin/busybox, got {other:?}"),
442        }
443
444        let libc = by_path("/usr/lib/libc.so");
445        match &libc.item {
446            TarItem::Leaf(LeafContent::Regular(RegularFile::External(_, size))) => {
447                assert_eq!(*size, 32768, "libc.so should be 32KB");
448            }
449            other => panic!("expected external file for /usr/lib/libc.so, got {other:?}"),
450        }
451
452        let readme = by_path("/usr/share/doc/README");
453        match &readme.item {
454            TarItem::Leaf(LeafContent::Regular(RegularFile::External(_, size))) => {
455                assert!(
456                    *size as usize > INLINE_CONTENT_MAX_V0,
457                    "README should be external ({size} bytes)"
458                );
459            }
460            other => panic!("expected external file for README, got {other:?}"),
461        }
462
463        let messages = by_path("/var/log/messages");
464        match &messages.item {
465            TarItem::Leaf(LeafContent::Regular(RegularFile::External(_, size))) => {
466                assert_eq!(*size, 8192, "messages should be 8KB");
467            }
468            other => panic!("expected external file for /var/log/messages, got {other:?}"),
469        }
470
471        // --- Symlinks ---
472        let symlinks = [
473            ("/usr/bin/cat", "busybox"),
474            ("/usr/bin/ls", "busybox"),
475            ("/usr/bin/sh", "busybox"),
476            ("/usr/lib/libc.so.6", "libc.so"),
477        ];
478        for (path, target) in &symlinks {
479            let entry = by_path(path);
480            match &entry.item {
481                TarItem::Leaf(LeafContent::Symlink(t)) => {
482                    assert_eq!(&**t, OsStr::new(target), "{path} symlink target");
483                }
484                other => panic!("expected symlink for {path}, got {other:?}"),
485            }
486        }
487
488        // --- Hardlink ---
489        // The hardlink /usr/bin/cp -> /usr/bin/busybox appears as a Hardlink variant
490        let cp = by_path("/usr/bin/cp");
491        match &cp.item {
492            TarItem::Hardlink(target) => {
493                assert_eq!(target, OsStr::new("/usr/bin/busybox"), "cp hardlink target");
494            }
495            other => panic!("expected hardlink for /usr/bin/cp, got {other:?}"),
496        }
497
498        // The /bin symlink replaces the earlier /bin directory in the tar stream.
499        // Both entries appear in the splitstream since it preserves raw tar order.
500        // Find the *last* /bin entry, which should be the symlink.
501        let bin_entries: Vec<_> = entries
502            .iter()
503            .filter(|e| e.path == PathBuf::from("/bin"))
504            .collect();
505        assert!(
506            bin_entries.len() >= 2,
507            "/bin should appear as both a directory and a symlink"
508        );
509        let last_bin = bin_entries.last().unwrap();
510        match &last_bin.item {
511            TarItem::Leaf(LeafContent::Symlink(t)) => {
512                assert_eq!(&**t, OsStr::new("usr/bin"), "/bin symlink target");
513            }
514            other => panic!("expected symlink for final /bin, got {other:?}"),
515        }
516
517        // --- Total entry count ---
518        // 10 dirs + 7 files + 4 symlinks + 1 hardlink + 1 /bin symlink = 23
519        // Plus the original /bin dir entry = 24 total
520        let expected_count = 10  // directories (including initial /bin)
521            + 7   // regular files
522            + 4   // symlinks (cat, ls, sh, libc.so.6)
523            + 1   // hardlink (cp)
524            + 1; // /bin symlink (replaces the dir)
525        assert_eq!(
526            entries.len(),
527            expected_count,
528            "total entry count (dirs + files + symlinks + hardlinks)"
529        );
530
531        Ok(())
532    }
533
534    #[test]
535    fn test_process_entry() -> Result<()> {
536        let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
537
538        // both with and without leading slash should be supported
539        process_entry(&mut fs, dir_entry("/a"))?;
540        process_entry(&mut fs, dir_entry("b"))?;
541        process_entry(&mut fs, dir_entry("c"))?;
542        assert_files(&fs, &["/", "/a", "/b", "/c"])?;
543
544        // add some files
545        process_entry(&mut fs, file_entry("/a/b"))?;
546        process_entry(&mut fs, file_entry("/a/c"))?;
547        process_entry(&mut fs, file_entry("/b/a"))?;
548        process_entry(&mut fs, file_entry("/b/c"))?;
549        process_entry(&mut fs, file_entry("/c/a"))?;
550        process_entry(&mut fs, file_entry("/c/c"))?;
551        assert_files(
552            &fs,
553            &[
554                "/", "/a", "/a/b", "/a/c", "/b", "/b/a", "/b/c", "/c", "/c/a", "/c/c",
555            ],
556        )?;
557
558        // try some whiteouts
559        process_entry(&mut fs, file_entry(".wh.a"))?; // entire dir
560        process_entry(&mut fs, file_entry("/b/.wh..wh..opq"))?; // opaque dir
561        process_entry(&mut fs, file_entry("/c/.wh.c"))?; // single file
562        assert_files(&fs, &["/", "/b", "/c", "/c/a"])?;
563
564        Ok(())
565    }
566
567    // --- Whiteout-specific tests ---
568
569    #[test]
570    fn test_whiteout_file_removes_entry() -> Result<()> {
571        let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
572
573        process_entry(&mut fs, dir_entry("/etc"))?;
574        process_entry(&mut fs, file_entry("/etc/hosts"))?;
575        process_entry(&mut fs, file_entry("/etc/passwd"))?;
576        assert_files(&fs, &["/", "/etc", "/etc/hosts", "/etc/passwd"])?;
577
578        // Whiteout hosts — only hosts should be removed
579        process_entry(&mut fs, file_entry("/etc/.wh.hosts"))?;
580        assert_files(&fs, &["/", "/etc", "/etc/passwd"])?;
581
582        Ok(())
583    }
584
585    #[test]
586    fn test_whiteout_nonexistent_file_is_noop() -> Result<()> {
587        let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
588
589        process_entry(&mut fs, dir_entry("/etc"))?;
590        process_entry(&mut fs, file_entry("/etc/hosts"))?;
591        assert_files(&fs, &["/", "/etc", "/etc/hosts"])?;
592
593        // Whiteout a file that doesn't exist — should be a no-op
594        process_entry(&mut fs, file_entry("/etc/.wh.nosuchfile"))?;
595        assert_files(&fs, &["/", "/etc", "/etc/hosts"])?;
596
597        Ok(())
598    }
599
600    #[test]
601    fn test_whiteout_directory() -> Result<()> {
602        let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
603
604        process_entry(&mut fs, dir_entry("/usr"))?;
605        process_entry(&mut fs, dir_entry("/usr/local"))?;
606        process_entry(&mut fs, file_entry("/usr/local/bin"))?;
607        process_entry(&mut fs, dir_entry("/etc"))?;
608        assert_files(&fs, &["/", "/etc", "/usr", "/usr/local", "/usr/local/bin"])?;
609
610        // Whiteout the directory /usr/local (removes the entire subtree)
611        process_entry(&mut fs, file_entry("/usr/.wh.local"))?;
612        assert_files(&fs, &["/", "/etc", "/usr"])?;
613
614        Ok(())
615    }
616
617    #[test]
618    fn test_whiteout_in_root_directory() -> Result<()> {
619        let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
620
621        process_entry(&mut fs, dir_entry("/mydir"))?;
622        process_entry(&mut fs, file_entry("/toplevel"))?;
623        assert_files(&fs, &["/", "/mydir", "/toplevel"])?;
624
625        // Whiteout in root (no leading dir component)
626        process_entry(&mut fs, file_entry("/.wh.toplevel"))?;
627        assert_files(&fs, &["/", "/mydir"])?;
628
629        // Also works without leading slash
630        process_entry(&mut fs, file_entry(".wh.mydir"))?;
631        assert_files(&fs, &["/"])?;
632
633        Ok(())
634    }
635
636    #[test]
637    fn test_whiteout_in_nested_directory() -> Result<()> {
638        let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
639
640        process_entry(&mut fs, dir_entry("/a"))?;
641        process_entry(&mut fs, dir_entry("/a/b"))?;
642        process_entry(&mut fs, dir_entry("/a/b/c"))?;
643        process_entry(&mut fs, file_entry("/a/b/c/deep"))?;
644        assert_files(&fs, &["/", "/a", "/a/b", "/a/b/c", "/a/b/c/deep"])?;
645
646        process_entry(&mut fs, file_entry("/a/b/c/.wh.deep"))?;
647        assert_files(&fs, &["/", "/a", "/a/b", "/a/b/c"])?;
648
649        Ok(())
650    }
651
652    #[test]
653    fn test_opaque_whiteout_clears_directory() -> Result<()> {
654        let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
655
656        process_entry(&mut fs, dir_entry("/etc"))?;
657        process_entry(&mut fs, file_entry("/etc/hosts"))?;
658        process_entry(&mut fs, file_entry("/etc/passwd"))?;
659        process_entry(&mut fs, file_entry("/etc/resolv.conf"))?;
660        assert_files(
661            &fs,
662            &["/", "/etc", "/etc/hosts", "/etc/passwd", "/etc/resolv.conf"],
663        )?;
664
665        // Opaque whiteout — clears all entries in /etc
666        process_entry(&mut fs, file_entry("/etc/.wh..wh..opq"))?;
667        assert_files(&fs, &["/", "/etc"])?;
668
669        Ok(())
670    }
671
672    #[test]
673    fn test_opaque_whiteout_then_add_new_entries() -> Result<()> {
674        // This is a very common pattern in container images: the layer
675        // marks a dir opaque (hiding all lower-layer contents), then
676        // adds new entries in the same directory.
677        let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
678
679        process_entry(&mut fs, dir_entry("/etc"))?;
680        process_entry(&mut fs, file_entry("/etc/old_config"))?;
681        process_entry(&mut fs, file_entry("/etc/another_old"))?;
682        assert_files(&fs, &["/", "/etc", "/etc/another_old", "/etc/old_config"])?;
683
684        // Opaque whiteout clears everything
685        process_entry(&mut fs, file_entry("/etc/.wh..wh..opq"))?;
686        assert_files(&fs, &["/", "/etc"])?;
687
688        // Then re-add new entries
689        process_entry(&mut fs, file_entry("/etc/new_config"))?;
690        process_entry(&mut fs, file_entry("/etc/new_other"))?;
691        assert_files(&fs, &["/", "/etc", "/etc/new_config", "/etc/new_other"])?;
692
693        Ok(())
694    }
695
696    #[test]
697    fn test_multiple_whiteouts_in_single_layer() -> Result<()> {
698        let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
699
700        process_entry(&mut fs, dir_entry("/usr"))?;
701        process_entry(&mut fs, file_entry("/usr/a"))?;
702        process_entry(&mut fs, file_entry("/usr/b"))?;
703        process_entry(&mut fs, file_entry("/usr/c"))?;
704        process_entry(&mut fs, file_entry("/usr/d"))?;
705        assert_files(&fs, &["/", "/usr", "/usr/a", "/usr/b", "/usr/c", "/usr/d"])?;
706
707        // Multiple whiteouts in the same directory
708        process_entry(&mut fs, file_entry("/usr/.wh.a"))?;
709        process_entry(&mut fs, file_entry("/usr/.wh.c"))?;
710        assert_files(&fs, &["/", "/usr", "/usr/b", "/usr/d"])?;
711
712        Ok(())
713    }
714
715    #[test]
716    fn test_double_whiteout_is_idempotent() -> Result<()> {
717        let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
718
719        process_entry(&mut fs, dir_entry("/d"))?;
720        process_entry(&mut fs, file_entry("/d/target"))?;
721        assert_files(&fs, &["/", "/d", "/d/target"])?;
722
723        // Whiteout the same file twice — the second is a no-op
724        process_entry(&mut fs, file_entry("/d/.wh.target"))?;
725        assert_files(&fs, &["/", "/d"])?;
726
727        process_entry(&mut fs, file_entry("/d/.wh.target"))?;
728        assert_files(&fs, &["/", "/d"])?;
729
730        Ok(())
731    }
732
733    #[test]
734    fn test_whiteout_unusual_name_dot_wh_dot() -> Result<()> {
735        // ".wh..wh." (without trailing "opq") is a whiteout for a file
736        // literally named ".wh." — it is NOT an opaque whiteout.
737        // The code checks `whiteout == b".wh..opq"` for the complete
738        // filename ".wh..wh..opq", so ".wh..wh." won't match.
739        let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
740
741        process_entry(&mut fs, dir_entry("/d"))?;
742        process_entry(&mut fs, file_entry("/d/real_file"))?;
743        assert_files(&fs, &["/", "/d", "/d/real_file"])?;
744
745        // ".wh..wh." is interpreted as a whiteout for the file named ".wh."
746        // (strip ".wh." prefix → ".wh." remainder). Since no file named ".wh."
747        // exists, it's a no-op. Crucially, it is NOT treated as an opaque
748        // whiteout — those require the exact name ".wh..wh..opq".
749        process_entry(&mut fs, file_entry("/d/.wh..wh."))?;
750        assert_files(&fs, &["/", "/d", "/d/real_file"])?;
751
752        // Note: a tar entry named ".wh." is consumed as a whiteout for "" (empty
753        // name), which is effectively a no-op — the file is never stored.
754        process_entry(&mut fs, file_entry("/d/.wh."))?;
755        assert_files(&fs, &["/", "/d", "/d/real_file"])?;
756
757        Ok(())
758    }
759
760    #[test]
761    fn test_whiteout_across_multiple_directories() -> Result<()> {
762        let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
763
764        process_entry(&mut fs, dir_entry("/a"))?;
765        process_entry(&mut fs, dir_entry("/b"))?;
766        process_entry(&mut fs, file_entry("/a/file1"))?;
767        process_entry(&mut fs, file_entry("/a/file2"))?;
768        process_entry(&mut fs, file_entry("/b/file1"))?;
769        process_entry(&mut fs, file_entry("/b/file2"))?;
770        assert_files(
771            &fs,
772            &[
773                "/", "/a", "/a/file1", "/a/file2", "/b", "/b/file1", "/b/file2",
774            ],
775        )?;
776
777        // Whiteout file1 in /a and file2 in /b independently
778        process_entry(&mut fs, file_entry("/a/.wh.file1"))?;
779        process_entry(&mut fs, file_entry("/b/.wh.file2"))?;
780        assert_files(&fs, &["/", "/a", "/a/file2", "/b", "/b/file1"])?;
781
782        Ok(())
783    }
784
785    #[test]
786    fn test_opaque_whiteout_with_subdirectories() -> Result<()> {
787        // Opaque whiteout should clear subdirectories too
788        let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
789
790        process_entry(&mut fs, dir_entry("/parent"))?;
791        process_entry(&mut fs, dir_entry("/parent/child"))?;
792        process_entry(&mut fs, file_entry("/parent/child/deep"))?;
793        process_entry(&mut fs, file_entry("/parent/sibling"))?;
794        assert_files(
795            &fs,
796            &[
797                "/",
798                "/parent",
799                "/parent/child",
800                "/parent/child/deep",
801                "/parent/sibling",
802            ],
803        )?;
804
805        process_entry(&mut fs, file_entry("/parent/.wh..wh..opq"))?;
806        assert_files(&fs, &["/", "/parent"])?;
807
808        Ok(())
809    }
810
811    #[test]
812    fn test_whiteout_then_recreate() -> Result<()> {
813        // Delete a file with whiteout, then re-add it in the same layer
814        let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
815
816        process_entry(&mut fs, dir_entry("/etc"))?;
817        process_entry(&mut fs, file_entry("/etc/config"))?;
818        assert_files(&fs, &["/", "/etc", "/etc/config"])?;
819
820        // Whiteout and then re-add
821        process_entry(&mut fs, file_entry("/etc/.wh.config"))?;
822        assert_files(&fs, &["/", "/etc"])?;
823
824        process_entry(&mut fs, file_entry("/etc/config"))?;
825        assert_files(&fs, &["/", "/etc", "/etc/config"])?;
826
827        Ok(())
828    }
829}