Skip to main content

microsandbox_image/
filetree.rs

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