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