Skip to main content

microsandbox_image/tree/
model.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::ffi::OsString;
3use std::fmt;
4use std::io::{Read, Seek, SeekFrom};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use crate::path_bytes::os_string_from_bytes;
10
11//--------------------------------------------------------------------------------------------------
12// Constants
13//--------------------------------------------------------------------------------------------------
14
15const DEFAULT_MAX_TOTAL_SIZE: u64 = 10 * 1024 * 1024 * 1024; // 10 GiB
16const DEFAULT_MAX_FILE_SIZE: u64 = 5 * 1024 * 1024 * 1024; // 5 GiB
17const DEFAULT_MAX_ENTRY_COUNT: u64 = 1_000_000;
18const DEFAULT_MAX_PATH_LENGTH: usize = 4096;
19const DEFAULT_MAX_PATH_DEPTH: usize = 128;
20const DEFAULT_MAX_SYMLINK_TARGET: usize = 4096;
21
22const DEFAULT_DIR_MODE: u16 = 0o755;
23
24/// Overlayfs whiteout: char device with major=0, minor=0 signals deletion.
25pub(crate) const WHITEOUT_MAJOR: u32 = 0;
26pub(crate) const WHITEOUT_MINOR: u32 = 0;
27
28/// Overlayfs opaque directory xattr: hides all lower-layer entries.
29pub(crate) const OPAQUE_XATTR_NAME: &[u8] = b"trusted.overlay.opaque";
30pub(crate) const OPAQUE_XATTR_VALUE: &[u8] = b"y";
31
32//--------------------------------------------------------------------------------------------------
33// Types
34//--------------------------------------------------------------------------------------------------
35
36/// File content storage — either in-memory for small files or spooled to
37/// disk for large files to keep memory usage bounded.
38#[derive(Clone)]
39pub enum FileData {
40    /// Small file content held in memory.
41    Memory(Vec<u8>),
42    /// In-memory content shared by hardlinked regular-file aliases.
43    SharedMemory(Arc<[u8]>),
44    /// Large file content written to a shared spool file on disk.
45    /// Multiple `FileData::Spool` entries can reference different regions
46    /// of the same underlying spool file via `Arc`.
47    Spool {
48        spool: Arc<std::sync::Mutex<std::fs::File>>,
49        offset: u64,
50        len: u64,
51    },
52}
53
54impl std::fmt::Debug for FileData {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            FileData::Memory(data) => f.debug_tuple("Memory").field(&data.len()).finish(),
58            FileData::SharedMemory(data) => {
59                f.debug_tuple("SharedMemory").field(&data.len()).finish()
60            }
61            FileData::Spool { offset, len, .. } => f
62                .debug_struct("Spool")
63                .field("offset", offset)
64                .field("len", len)
65                .finish(),
66        }
67    }
68}
69
70impl PartialEq for FileData {
71    fn eq(&self, other: &Self) -> bool {
72        match (self, other) {
73            (FileData::Memory(a), FileData::Memory(b)) => a == b,
74            (FileData::Memory(a), FileData::SharedMemory(b))
75            | (FileData::SharedMemory(b), FileData::Memory(a)) => a.as_slice() == b.as_ref(),
76            (FileData::SharedMemory(a), FileData::SharedMemory(b)) => a.as_ref() == b.as_ref(),
77            _ => false,
78        }
79    }
80}
81
82/// Threshold below which file data is kept in memory (64 KiB).
83/// Files at or above this size are spooled to disk during tar ingestion.
84pub const SPOOL_THRESHOLD: u64 = 64 * 1024;
85
86/// A writable spool file for large file data during tar ingestion.
87pub struct DataSpool {
88    file: std::fs::File,
89    shared: Arc<std::sync::Mutex<std::fs::File>>,
90    offset: u64,
91}
92
93pub struct ResourceLimits {
94    pub max_total_size: u64,
95    pub max_file_size: u64,
96    pub max_entry_count: u64,
97    pub max_path_length: usize,
98    pub max_path_depth: usize,
99    pub max_symlink_target: usize,
100}
101
102#[derive(Clone)]
103pub struct InodeMetadata {
104    pub uid: u32,
105    pub gid: u32,
106    pub mode: u16,
107    pub mtime: u64,
108    pub mtime_nsec: u32,
109}
110
111#[derive(Clone)]
112pub struct Xattr {
113    pub name: Vec<u8>,
114    pub value: Vec<u8>,
115}
116
117#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
118pub struct RegularFileId(u64);
119
120#[derive(Clone)]
121pub enum TreeNode {
122    RegularFile(RegularFileNode),
123    Directory(DirectoryNode),
124    Symlink(SymlinkNode),
125    CharDevice(DeviceNode),
126    BlockDevice(DeviceNode),
127    Fifo(InodeMetadata),
128    Socket(InodeMetadata),
129}
130
131#[derive(Clone)]
132pub struct RegularFileNode {
133    pub id: RegularFileId,
134    pub metadata: InodeMetadata,
135    pub xattrs: Vec<Xattr>,
136    pub data: FileData,
137    pub nlink: u32,
138}
139
140#[derive(Clone)]
141pub struct DirectoryNode {
142    pub metadata: InodeMetadata,
143    pub xattrs: Vec<Xattr>,
144    pub entries: BTreeMap<OsString, TreeNode>,
145}
146
147#[derive(Clone)]
148pub struct SymlinkNode {
149    pub metadata: InodeMetadata,
150    pub target: Vec<u8>,
151}
152
153#[derive(Clone)]
154pub struct DeviceNode {
155    pub metadata: InodeMetadata,
156    pub major: u32,
157    pub minor: u32,
158}
159
160#[derive(Clone)]
161pub struct FileTree {
162    pub root: DirectoryNode,
163}
164
165#[derive(Debug)]
166pub enum FileTreeError {
167    PathEmpty,
168    PathTraversal(String),
169    InvalidPathEncoding(String),
170    NotADirectory(String),
171    EntryExists(String),
172}
173
174//--------------------------------------------------------------------------------------------------
175// Methods
176//--------------------------------------------------------------------------------------------------
177
178impl FileData {
179    /// Total byte length of the file content.
180    pub fn len(&self) -> usize {
181        match self {
182            FileData::Memory(v) => v.len(),
183            FileData::SharedMemory(v) => v.len(),
184            FileData::Spool { len, .. } => *len as usize,
185        }
186    }
187
188    pub fn is_empty(&self) -> bool {
189        self.len() == 0
190    }
191
192    /// Read the entire content into memory. For `Memory` variant this
193    /// clones; for `Spool` this reads from disk.
194    pub fn read_all(&self) -> std::io::Result<Vec<u8>> {
195        match self {
196            FileData::Memory(v) => Ok(v.clone()),
197            FileData::SharedMemory(v) => Ok(v.to_vec()),
198            FileData::Spool { spool, offset, len } => {
199                let mut buf = vec![0u8; *len as usize];
200                let mut file = spool
201                    .lock()
202                    .map_err(|_| std::io::Error::other("spool lock poisoned"))?;
203                file.seek(SeekFrom::Start(*offset))?;
204                file.read_exact(&mut buf)?;
205                Ok(buf)
206            }
207        }
208    }
209
210    /// Borrow the in-memory bytes directly (only for `Memory` variant).
211    pub fn as_bytes(&self) -> Option<&[u8]> {
212        match self {
213            FileData::Memory(v) => Some(v),
214            FileData::SharedMemory(v) => Some(v),
215            FileData::Spool { .. } => None,
216        }
217    }
218
219    /// Write content to an output writer, reading from spool if needed.
220    /// Avoids loading the entire file into memory for large spooled files.
221    pub fn write_to(&self, out: &mut impl std::io::Write) -> std::io::Result<()> {
222        self.write_range(0, self.len(), out)
223    }
224
225    /// Write a byte range of the content to an output writer.
226    pub fn write_range(
227        &self,
228        start: usize,
229        len: usize,
230        out: &mut impl std::io::Write,
231    ) -> std::io::Result<()> {
232        match self {
233            FileData::Memory(v) => out.write_all(&v[start..start + len]),
234            FileData::SharedMemory(v) => out.write_all(&v[start..start + len]),
235            FileData::Spool { spool, offset, .. } => {
236                let mut file = spool
237                    .lock()
238                    .map_err(|_| std::io::Error::other("spool lock poisoned"))?;
239                file.seek(SeekFrom::Start(*offset + start as u64))?;
240                let mut remaining = len;
241                let mut buf = [0u8; 65536];
242                while remaining > 0 {
243                    let to_read = remaining.min(buf.len());
244                    file.read_exact(&mut buf[..to_read])?;
245                    out.write_all(&buf[..to_read])?;
246                    remaining -= to_read;
247                }
248                Ok(())
249            }
250        }
251    }
252
253    /// Return a shared reference suitable for a hardlinked alias.
254    pub fn clone_ref(&mut self) -> FileData {
255        match self {
256            FileData::Memory(data) => {
257                let shared: Arc<[u8]> = std::mem::take(data).into();
258                *self = FileData::SharedMemory(Arc::clone(&shared));
259                FileData::SharedMemory(shared)
260            }
261            FileData::SharedMemory(data) => FileData::SharedMemory(Arc::clone(data)),
262            FileData::Spool { spool, offset, len } => FileData::Spool {
263                spool: Arc::clone(spool),
264                offset: *offset,
265                len: *len,
266            },
267        }
268    }
269}
270
271impl RegularFileId {
272    pub fn new() -> Self {
273        static NEXT_ID: AtomicU64 = AtomicU64::new(1);
274        Self(NEXT_ID.fetch_add(1, Ordering::Relaxed))
275    }
276}
277
278impl Default for RegularFileId {
279    fn default() -> Self {
280        Self::new()
281    }
282}
283
284impl DataSpool {
285    /// Create a new spool file at the given path.
286    pub fn new(path: &std::path::Path) -> std::io::Result<Self> {
287        let file = std::fs::OpenOptions::new()
288            .create(true)
289            .truncate(true)
290            .read(true)
291            .write(true)
292            .open(path)?;
293        let shared = Arc::new(std::sync::Mutex::new(file.try_clone()?));
294        Ok(Self {
295            file,
296            shared,
297            offset: 0,
298        })
299    }
300
301    /// Write data to the spool and return a `FileData::Spool` reference.
302    pub fn write_data(&mut self, data: &[u8]) -> std::io::Result<FileData> {
303        use std::io::Write;
304        let offset = self.offset;
305        self.file.write_all(data)?;
306        self.offset += data.len() as u64;
307        Ok(FileData::Spool {
308            spool: Arc::clone(&self.shared),
309            offset,
310            len: data.len() as u64,
311        })
312    }
313
314    pub fn current_offset(&self) -> u64 {
315        self.offset
316    }
317
318    pub fn write_chunk(&mut self, data: &[u8]) -> std::io::Result<()> {
319        use std::io::Write;
320        self.file.write_all(data)?;
321        self.offset += data.len() as u64;
322        Ok(())
323    }
324
325    pub fn data_ref(&self, offset: u64, len: u64) -> FileData {
326        FileData::Spool {
327            spool: Arc::clone(&self.shared),
328            offset,
329            len,
330        }
331    }
332}
333
334impl DirectoryNode {
335    pub fn new(metadata: InodeMetadata) -> Self {
336        Self {
337            metadata,
338            xattrs: Vec::new(),
339            entries: BTreeMap::new(),
340        }
341    }
342
343    pub fn entry_count(&self) -> usize {
344        self.entries.len()
345    }
346}
347
348impl Default for FileTree {
349    fn default() -> Self {
350        Self::new()
351    }
352}
353
354impl FileTree {
355    pub fn new() -> Self {
356        Self {
357            root: DirectoryNode::new(InodeMetadata::default()),
358        }
359    }
360
361    pub fn insert(&mut self, path: &[u8], node: TreeNode) -> Result<(), FileTreeError> {
362        use std::collections::btree_map::Entry;
363
364        let components = split_path(path)?;
365        if components.is_empty() {
366            return Err(FileTreeError::PathEmpty);
367        }
368
369        let (parent_components, file_name) = components.split_at(components.len() - 1);
370
371        // Traverse to the parent directory, creating missing intermediates.
372        // Uses the BTreeMap entry API to do a single lookup per component
373        // instead of contains_key + insert + get_mut (3 lookups).
374        let mut current = &mut self.root;
375        for component in parent_components {
376            let key = tree_key(component)?;
377            current = match current.entries.entry(key) {
378                Entry::Vacant(e) => {
379                    let dir = TreeNode::Directory(DirectoryNode::new(InodeMetadata::default()));
380                    match e.insert(dir) {
381                        TreeNode::Directory(d) => d,
382                        _ => unreachable!(),
383                    }
384                }
385                Entry::Occupied(e) => match e.into_mut() {
386                    TreeNode::Directory(d) => d,
387                    _ => {
388                        let path_str = String::from_utf8_lossy(component).into_owned();
389                        return Err(FileTreeError::NotADirectory(path_str));
390                    }
391                },
392            };
393        }
394
395        // Insert the final node. Directory-over-directory merges metadata
396        // but keeps existing entries. Non-directory replaces non-directory.
397        let key = tree_key(file_name[0])?;
398        match current.entries.entry(key) {
399            Entry::Vacant(e) => {
400                e.insert(node);
401            }
402            Entry::Occupied(mut e) => match (e.get(), &node) {
403                (TreeNode::Directory(_), TreeNode::Directory(_)) => {
404                    if let TreeNode::Directory(existing) = e.get_mut()
405                        && let TreeNode::Directory(new_dir) = node
406                    {
407                        existing.metadata = new_dir.metadata;
408                        existing.xattrs = new_dir.xattrs;
409                    }
410                }
411                (TreeNode::Directory(_), _) => {
412                    let path_str = String::from_utf8_lossy(file_name[0]).into_owned();
413                    return Err(FileTreeError::EntryExists(path_str));
414                }
415                _ => {
416                    e.insert(node);
417                }
418            },
419        }
420
421        Ok(())
422    }
423
424    pub fn get(&self, path: &[u8]) -> Option<&TreeNode> {
425        let components = split_path(path).ok()?;
426        if components.is_empty() {
427            return None;
428        }
429
430        let (parent_components, file_name) = components.split_at(components.len() - 1);
431
432        let mut current = &self.root;
433        for component in parent_components {
434            let key = tree_key(component).ok()?;
435            match current.entries.get(&key) {
436                Some(TreeNode::Directory(dir)) => {
437                    current = dir;
438                }
439                _ => return None,
440            }
441        }
442
443        let key = tree_key(file_name[0]).ok()?;
444        current.entries.get(&key)
445    }
446
447    pub fn get_mut(&mut self, path: &[u8]) -> Option<&mut TreeNode> {
448        let components = split_path(path).ok()?;
449        if components.is_empty() {
450            return None;
451        }
452
453        let (parent_components, file_name) = components.split_at(components.len() - 1);
454
455        let mut current = &mut self.root;
456        for component in parent_components {
457            let key = tree_key(component).ok()?;
458            match current.entries.get_mut(&key) {
459                Some(TreeNode::Directory(dir)) => {
460                    current = dir;
461                }
462                _ => return None,
463            }
464        }
465
466        let key = tree_key(file_name[0]).ok()?;
467        current.entries.get_mut(&key)
468    }
469
470    pub fn remove(&mut self, path: &[u8]) -> Option<TreeNode> {
471        let components = split_path(path).ok()?;
472        if components.is_empty() {
473            return None;
474        }
475
476        let (parent_components, file_name) = components.split_at(components.len() - 1);
477
478        let mut current = &mut self.root;
479        for component in parent_components {
480            let key = tree_key(component).ok()?;
481            match current.entries.get_mut(&key) {
482                Some(TreeNode::Directory(dir)) => {
483                    current = dir;
484                }
485                _ => return None,
486            }
487        }
488
489        let key = tree_key(file_name[0]).ok()?;
490        current.entries.remove(&key)
491    }
492
493    pub fn node_count(&self) -> u64 {
494        count_nodes_in_dir(&self.root)
495    }
496
497    pub fn total_data_size(&self) -> u64 {
498        data_size_in_dir(&self.root)
499    }
500
501    pub(crate) fn regular_file_link_counts(&self) -> HashMap<RegularFileId, u32> {
502        let mut counts = HashMap::new();
503        count_regular_links_in_dir(&self.root, &mut counts);
504        counts
505    }
506
507    pub(crate) fn refresh_regular_nlinks(&mut self) {
508        let counts = self.regular_file_link_counts();
509        refresh_regular_nlinks_in_dir(&mut self.root, &counts);
510    }
511
512    pub fn merge_layer(&mut self, layer: FileTree) {
513        merge_directory(&mut self.root, layer.root);
514        self.refresh_regular_nlinks();
515    }
516
517    /// Strip file data from this tree, keeping only directory structure and metadata.
518    ///
519    /// After calling this, all `RegularFile` nodes have empty `FileData::Memory(Vec::new())`.
520    /// Used to reduce memory after writing a per-layer EROFS while retaining the tree
521    /// for fsmeta merge.
522    pub fn strip_file_data(&mut self) {
523        strip_data_in_dir(&mut self.root);
524    }
525}
526
527//--------------------------------------------------------------------------------------------------
528// Trait Implementations
529//--------------------------------------------------------------------------------------------------
530
531impl Default for ResourceLimits {
532    fn default() -> Self {
533        Self {
534            max_total_size: DEFAULT_MAX_TOTAL_SIZE,
535            max_file_size: DEFAULT_MAX_FILE_SIZE,
536            max_entry_count: DEFAULT_MAX_ENTRY_COUNT,
537            max_path_length: DEFAULT_MAX_PATH_LENGTH,
538            max_path_depth: DEFAULT_MAX_PATH_DEPTH,
539            max_symlink_target: DEFAULT_MAX_SYMLINK_TARGET,
540        }
541    }
542}
543
544impl Default for InodeMetadata {
545    fn default() -> Self {
546        Self {
547            uid: 0,
548            gid: 0,
549            mode: DEFAULT_DIR_MODE,
550            mtime: 0,
551            mtime_nsec: 0,
552        }
553    }
554}
555
556impl fmt::Display for FileTreeError {
557    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
558        match self {
559            FileTreeError::PathEmpty => write!(f, "path is empty"),
560            FileTreeError::PathTraversal(p) => {
561                write!(f, "path traversal attempt: \"..\" in path \"{p}\"")
562            }
563            FileTreeError::InvalidPathEncoding(p) => {
564                write!(f, "path is not valid host encoding: \"{p}\"")
565            }
566            FileTreeError::NotADirectory(p) => {
567                write!(f, "not a directory: \"{p}\"")
568            }
569            FileTreeError::EntryExists(p) => {
570                write!(f, "entry already exists: \"{p}\"")
571            }
572        }
573    }
574}
575
576impl std::error::Error for FileTreeError {}
577
578//--------------------------------------------------------------------------------------------------
579// Functions
580//--------------------------------------------------------------------------------------------------
581
582fn split_path(path: &[u8]) -> Result<Vec<&[u8]>, FileTreeError> {
583    let components: Vec<&[u8]> = path
584        .split(|&b| b == b'/')
585        .filter(|c| !c.is_empty())
586        .collect();
587
588    if components.is_empty() {
589        return Err(FileTreeError::PathEmpty);
590    }
591
592    for component in &components {
593        if *component == b".." {
594            let path_str = String::from_utf8_lossy(path).into_owned();
595            return Err(FileTreeError::PathTraversal(path_str));
596        }
597    }
598
599    Ok(components)
600}
601
602fn tree_key(component: &[u8]) -> Result<OsString, FileTreeError> {
603    os_string_from_bytes(component)
604        .map_err(|_| FileTreeError::InvalidPathEncoding(String::from_utf8_lossy(component).into()))
605}
606
607fn count_nodes_in_dir(dir: &DirectoryNode) -> u64 {
608    let mut count = 0u64;
609    for node in dir.entries.values() {
610        count += 1;
611        if let TreeNode::Directory(child_dir) = node {
612            count += count_nodes_in_dir(child_dir);
613        }
614    }
615    count
616}
617
618fn data_size_in_dir(dir: &DirectoryNode) -> u64 {
619    let mut seen = HashSet::new();
620    data_size_in_dir_once(dir, &mut seen)
621}
622
623fn data_size_in_dir_once(dir: &DirectoryNode, seen: &mut HashSet<RegularFileId>) -> u64 {
624    let mut size = 0u64;
625    for node in dir.entries.values() {
626        match node {
627            TreeNode::RegularFile(file) if seen.insert(file.id) => {
628                size += file.data.len() as u64;
629            }
630            TreeNode::Directory(child_dir) => {
631                size += data_size_in_dir_once(child_dir, seen);
632            }
633            _ => {}
634        }
635    }
636    size
637}
638
639fn count_regular_links_in_dir(dir: &DirectoryNode, counts: &mut HashMap<RegularFileId, u32>) {
640    for node in dir.entries.values() {
641        match node {
642            TreeNode::RegularFile(file) => {
643                *counts.entry(file.id).or_insert(0) += 1;
644            }
645            TreeNode::Directory(child_dir) => {
646                count_regular_links_in_dir(child_dir, counts);
647            }
648            _ => {}
649        }
650    }
651}
652
653fn refresh_regular_nlinks_in_dir(dir: &mut DirectoryNode, counts: &HashMap<RegularFileId, u32>) {
654    for node in dir.entries.values_mut() {
655        match node {
656            TreeNode::RegularFile(file) => {
657                file.nlink = counts.get(&file.id).copied().unwrap_or(1);
658            }
659            TreeNode::Directory(child_dir) => {
660                refresh_regular_nlinks_in_dir(child_dir, counts);
661            }
662            _ => {}
663        }
664    }
665}
666
667fn strip_data_in_dir(dir: &mut DirectoryNode) {
668    for node in dir.entries.values_mut() {
669        match node {
670            TreeNode::RegularFile(f) => {
671                f.data = FileData::Memory(Vec::new());
672            }
673            TreeNode::Directory(d) => {
674                strip_data_in_dir(d);
675            }
676            _ => {}
677        }
678    }
679}
680
681/// Merge multiple layer trees into a single tree with provenance tracking.
682///
683/// Applies layers bottom-to-top, handling whiteouts and opaque directories.
684/// Unlike `merge_layer()`, whiteout entries and opaque xattrs are consumed
685/// and NOT propagated — the output is the clean final state.
686///
687/// Returns the merged tree and a map from file path to source layer index
688/// (0-based, bottom-to-top).
689pub fn merge_layers_with_provenance(layers: Vec<FileTree>) -> (FileTree, HashMap<PathBuf, usize>) {
690    let mut merged = FileTree::new();
691    let mut provenance: HashMap<PathBuf, usize> = HashMap::new();
692
693    for (layer_idx, layer) in layers.into_iter().enumerate() {
694        let path = PathBuf::new();
695        merge_directory_with_provenance(
696            &mut merged.root,
697            layer.root,
698            layer_idx,
699            &path,
700            &mut provenance,
701        );
702    }
703
704    // Strip opaque xattrs from the final merged tree — they are overlayfs
705    // directives consumed by the merge, not meaningful in fsmeta.
706    strip_opaque_xattrs(&mut merged.root);
707    merged.refresh_regular_nlinks();
708
709    (merged, provenance)
710}
711
712fn merge_directory_with_provenance(
713    base: &mut DirectoryNode,
714    layer: DirectoryNode,
715    layer_idx: usize,
716    current_path: &Path,
717    provenance: &mut HashMap<PathBuf, usize>,
718) {
719    for (name, layer_node) in layer.entries {
720        let child_path = current_path.join(&name);
721
722        // Whiteout: remove target from merged tree and its provenance.
723        if is_whiteout_device(&layer_node) {
724            // Remove provenance entries for the deleted item and all its descendants.
725            base.entries.remove(&name);
726            provenance.retain(|k, _| !k.starts_with(&child_path));
727            continue;
728        }
729
730        match layer_node {
731            TreeNode::Directory(layer_dir) => {
732                let opaque = has_opaque_xattr(&layer_dir);
733
734                match base.entries.get_mut(&name) {
735                    Some(TreeNode::Directory(base_dir)) => {
736                        if opaque {
737                            // Remove all provenance for entries under this directory.
738                            provenance.retain(|k, _| !k.starts_with(&child_path));
739                            base_dir.entries.clear();
740                        }
741                        base_dir.metadata = layer_dir.metadata;
742                        base_dir.xattrs = layer_dir.xattrs;
743                        merge_directory_with_provenance(
744                            base_dir,
745                            DirectoryNode {
746                                metadata: InodeMetadata::default(),
747                                xattrs: Vec::new(),
748                                entries: layer_dir.entries,
749                            },
750                            layer_idx,
751                            &child_path,
752                            provenance,
753                        );
754                    }
755                    _ => {
756                        // New directory replaces whatever was there.
757                        provenance.retain(|k, _| !k.starts_with(&child_path));
758                        // Record provenance for all entries in the new directory.
759                        record_provenance_recursive(&layer_dir, layer_idx, &child_path, provenance);
760                        base.entries.insert(name, TreeNode::Directory(layer_dir));
761                    }
762                }
763            }
764            other => {
765                // Non-directory entry: record provenance.
766                provenance.insert(child_path, layer_idx);
767                base.entries.insert(name, other);
768            }
769        }
770    }
771}
772
773fn record_provenance_recursive(
774    dir: &DirectoryNode,
775    layer_idx: usize,
776    current_path: &Path,
777    provenance: &mut HashMap<PathBuf, usize>,
778) {
779    for (name, child) in &dir.entries {
780        let child_path = current_path.join(name);
781        match child {
782            TreeNode::Directory(child_dir) => {
783                record_provenance_recursive(child_dir, layer_idx, &child_path, provenance);
784            }
785            _ => {
786                provenance.insert(child_path, layer_idx);
787            }
788        }
789    }
790}
791
792fn strip_opaque_xattrs(dir: &mut DirectoryNode) {
793    dir.xattrs
794        .retain(|x| !(x.name == OPAQUE_XATTR_NAME && x.value == OPAQUE_XATTR_VALUE));
795    for node in dir.entries.values_mut() {
796        if let TreeNode::Directory(child_dir) = node {
797            strip_opaque_xattrs(child_dir);
798        }
799    }
800}
801
802fn is_whiteout_device(node: &TreeNode) -> bool {
803    matches!(node, TreeNode::CharDevice(dev) if dev.major == WHITEOUT_MAJOR && dev.minor == WHITEOUT_MINOR)
804}
805
806fn has_opaque_xattr(dir: &DirectoryNode) -> bool {
807    dir.xattrs
808        .iter()
809        .any(|x| x.name == OPAQUE_XATTR_NAME && x.value == OPAQUE_XATTR_VALUE)
810}
811
812fn merge_directory(base: &mut DirectoryNode, layer: DirectoryNode) {
813    for (name, layer_node) in layer.entries {
814        if is_whiteout_device(&layer_node) {
815            base.entries.remove(&name);
816            continue;
817        }
818
819        match layer_node {
820            TreeNode::Directory(layer_dir) => {
821                let opaque = has_opaque_xattr(&layer_dir);
822
823                match base.entries.get_mut(&name) {
824                    Some(TreeNode::Directory(base_dir)) => {
825                        if opaque {
826                            base_dir.entries.clear();
827                        }
828                        base_dir.metadata = layer_dir.metadata;
829                        base_dir.xattrs = layer_dir.xattrs;
830                        merge_directory(
831                            base_dir,
832                            DirectoryNode {
833                                metadata: InodeMetadata::default(),
834                                xattrs: Vec::new(),
835                                entries: layer_dir.entries,
836                            },
837                        );
838                    }
839                    _ => {
840                        base.entries.insert(name, TreeNode::Directory(layer_dir));
841                    }
842                }
843            }
844            other => {
845                base.entries.insert(name, other);
846            }
847        }
848    }
849}
850
851//--------------------------------------------------------------------------------------------------
852// Tests
853//--------------------------------------------------------------------------------------------------
854
855#[cfg(test)]
856mod tests {
857    use super::*;
858
859    fn make_regular_file(data: &[u8]) -> TreeNode {
860        make_regular_file_with_id(data, RegularFileId::new())
861    }
862
863    fn make_regular_file_with_id(data: &[u8], id: RegularFileId) -> TreeNode {
864        TreeNode::RegularFile(RegularFileNode {
865            id,
866            metadata: InodeMetadata::default(),
867            xattrs: Vec::new(),
868            data: FileData::Memory(data.to_vec()),
869            nlink: 1,
870        })
871    }
872
873    fn make_directory() -> TreeNode {
874        TreeNode::Directory(DirectoryNode::new(InodeMetadata::default()))
875    }
876
877    fn make_whiteout() -> TreeNode {
878        TreeNode::CharDevice(DeviceNode {
879            metadata: InodeMetadata::default(),
880            major: 0,
881            minor: 0,
882        })
883    }
884
885    fn make_opaque_directory() -> DirectoryNode {
886        DirectoryNode {
887            metadata: InodeMetadata::default(),
888            xattrs: vec![Xattr {
889                name: OPAQUE_XATTR_NAME.to_vec(),
890                value: OPAQUE_XATTR_VALUE.to_vec(),
891            }],
892            entries: BTreeMap::new(),
893        }
894    }
895
896    #[test]
897    fn insert_and_get_file() {
898        let mut tree = FileTree::new();
899        tree.insert(b"hello.txt", make_regular_file(b"hello world"))
900            .unwrap();
901
902        let node = tree.get(b"hello.txt").unwrap();
903        match node {
904            TreeNode::RegularFile(f) => {
905                assert_eq!(f.data, FileData::Memory(b"hello world".to_vec()))
906            }
907            _ => panic!("expected regular file"),
908        }
909    }
910
911    #[test]
912    fn insert_with_missing_parents_creates_them() {
913        let mut tree = FileTree::new();
914        tree.insert(b"a/b/c/file.txt", make_regular_file(b"deep"))
915            .unwrap();
916
917        // Intermediate directories should exist.
918        let node = tree.get(b"a").unwrap();
919        assert!(matches!(node, TreeNode::Directory(_)));
920
921        let node = tree.get(b"a/b").unwrap();
922        assert!(matches!(node, TreeNode::Directory(_)));
923
924        let node = tree.get(b"a/b/c").unwrap();
925        assert!(matches!(node, TreeNode::Directory(_)));
926
927        let node = tree.get(b"a/b/c/file.txt").unwrap();
928        assert!(matches!(node, TreeNode::RegularFile(_)));
929    }
930
931    #[test]
932    fn reject_dotdot_in_path() {
933        let mut tree = FileTree::new();
934        let result = tree.insert(b"a/../etc/passwd", make_regular_file(b"bad"));
935        assert!(matches!(result, Err(FileTreeError::PathTraversal(_))));
936    }
937
938    #[test]
939    fn merge_layer_replaces_file() {
940        let mut base = FileTree::new();
941        base.insert(b"config.txt", make_regular_file(b"old"))
942            .unwrap();
943
944        let mut layer = FileTree::new();
945        layer
946            .insert(b"config.txt", make_regular_file(b"new"))
947            .unwrap();
948
949        base.merge_layer(layer);
950
951        match base.get(b"config.txt").unwrap() {
952            TreeNode::RegularFile(f) => assert_eq!(f.data, FileData::Memory(b"new".to_vec())),
953            _ => panic!("expected regular file"),
954        }
955    }
956
957    #[test]
958    fn merge_layer_whiteout_removes_file() {
959        let mut base = FileTree::new();
960        base.insert(b"dir/secret.txt", make_regular_file(b"sensitive"))
961            .unwrap();
962
963        let mut layer = FileTree::new();
964        layer.insert(b"dir", make_directory()).unwrap();
965        layer.insert(b"dir/secret.txt", make_whiteout()).unwrap();
966
967        base.merge_layer(layer);
968
969        assert!(base.get(b"dir/secret.txt").is_none());
970        // The parent directory should still exist.
971        assert!(base.get(b"dir").is_some());
972    }
973
974    #[test]
975    fn merge_layer_opaque_dir_clears_existing_entries() {
976        let mut base = FileTree::new();
977        base.insert(b"dir/a.txt", make_regular_file(b"a")).unwrap();
978        base.insert(b"dir/b.txt", make_regular_file(b"b")).unwrap();
979
980        let mut layer = FileTree::new();
981        let mut opaque_dir = make_opaque_directory();
982        opaque_dir
983            .entries
984            .insert(OsString::from("c.txt"), make_regular_file(b"c"));
985        layer
986            .root
987            .entries
988            .insert(OsString::from("dir"), TreeNode::Directory(opaque_dir));
989
990        base.merge_layer(layer);
991
992        // Old entries should be gone.
993        assert!(base.get(b"dir/a.txt").is_none());
994        assert!(base.get(b"dir/b.txt").is_none());
995        // New entry should be present.
996        match base.get(b"dir/c.txt").unwrap() {
997            TreeNode::RegularFile(f) => assert_eq!(f.data, FileData::Memory(b"c".to_vec())),
998            _ => panic!("expected regular file"),
999        }
1000    }
1001
1002    #[test]
1003    fn node_count_and_data_size() {
1004        let mut tree = FileTree::new();
1005        tree.insert(b"a/file1.txt", make_regular_file(b"hello"))
1006            .unwrap();
1007        tree.insert(b"a/file2.txt", make_regular_file(b"world!"))
1008            .unwrap();
1009        tree.insert(b"b/nested/file3.txt", make_regular_file(b"!"))
1010            .unwrap();
1011
1012        // a, a/file1.txt, a/file2.txt, b, b/nested, b/nested/file3.txt = 6
1013        assert_eq!(tree.node_count(), 6);
1014        // 5 + 6 + 1 = 12
1015        assert_eq!(tree.total_data_size(), 12);
1016    }
1017
1018    #[test]
1019    fn data_size_counts_hardlinked_regular_file_once() {
1020        let mut tree = FileTree::new();
1021        let file_id = RegularFileId::new();
1022
1023        tree.insert(b"a.txt", make_regular_file_with_id(b"shared", file_id))
1024            .unwrap();
1025        tree.insert(b"b.txt", make_regular_file_with_id(b"shared", file_id))
1026            .unwrap();
1027
1028        tree.refresh_regular_nlinks();
1029
1030        assert_eq!(tree.total_data_size(), b"shared".len() as u64);
1031        for path in [b"a.txt".as_slice(), b"b.txt".as_slice()] {
1032            match tree.get(path).unwrap() {
1033                TreeNode::RegularFile(file) => assert_eq!(file.nlink, 2),
1034                _ => panic!("expected regular file"),
1035            }
1036        }
1037    }
1038
1039    #[test]
1040    fn remove_node() {
1041        let mut tree = FileTree::new();
1042        tree.insert(b"a/b.txt", make_regular_file(b"data")).unwrap();
1043        assert!(tree.get(b"a/b.txt").is_some());
1044
1045        let removed = tree.remove(b"a/b.txt");
1046        assert!(removed.is_some());
1047        assert!(tree.get(b"a/b.txt").is_none());
1048    }
1049
1050    #[test]
1051    fn empty_path_is_rejected() {
1052        let mut tree = FileTree::new();
1053        let result = tree.insert(b"", make_regular_file(b"data"));
1054        assert!(matches!(result, Err(FileTreeError::PathEmpty)));
1055    }
1056
1057    #[test]
1058    fn not_a_directory_error() {
1059        let mut tree = FileTree::new();
1060        tree.insert(b"a", make_regular_file(b"file")).unwrap();
1061
1062        let result = tree.insert(b"a/b", make_regular_file(b"nested"));
1063        assert!(matches!(result, Err(FileTreeError::NotADirectory(_))));
1064    }
1065
1066    #[test]
1067    fn resource_limits_default() {
1068        let limits = ResourceLimits::default();
1069        assert_eq!(limits.max_total_size, 10 * 1024 * 1024 * 1024);
1070        assert_eq!(limits.max_file_size, 5 * 1024 * 1024 * 1024);
1071        assert_eq!(limits.max_entry_count, 1_000_000);
1072        assert_eq!(limits.max_path_length, 4096);
1073        assert_eq!(limits.max_path_depth, 128);
1074        assert_eq!(limits.max_symlink_target, 4096);
1075    }
1076}