composefs/
generic_tree.rs

1//! A generic metadata-only filesystem tree where regular files can be stored
2//! however the caller wants.
3
4use std::{
5    cell::RefCell,
6    collections::BTreeMap,
7    ffi::OsStr,
8    path::{Component, Path},
9    rc::Rc,
10};
11
12use thiserror::Error;
13
14/// File metadata similar to `struct stat` from POSIX.
15#[derive(Debug)]
16pub struct Stat {
17    /// File mode and permissions bits.
18    pub st_mode: u32,
19    /// User ID of owner.
20    pub st_uid: u32,
21    /// Group ID of owner.
22    pub st_gid: u32,
23    /// Modification time in seconds since Unix epoch.
24    pub st_mtim_sec: i64,
25    /// Extended attributes as key-value pairs.
26    pub xattrs: RefCell<BTreeMap<Box<OsStr>, Box<[u8]>>>,
27}
28
29impl Stat {
30    /// Creates a placeholder stat for uninitialized root directories.
31    ///
32    /// This stat has obviously invalid metadata (mode 0) that must be overwritten
33    /// before computing digests. It is intended for use when building a filesystem
34    /// incrementally (e.g., from OCI layers) where the final root metadata will be
35    /// set via `copy_root_metadata_from_usr()`.
36    ///
37    /// NOTE: If changing this, also update `doc/oci.md`.
38    pub fn uninitialized() -> Self {
39        Self {
40            st_mode: 0,
41            st_uid: 0,
42            st_gid: 0,
43            st_mtim_sec: 0,
44            xattrs: RefCell::new(BTreeMap::new()),
45        }
46    }
47}
48
49/// Content types for leaf nodes (non-directory files).
50#[derive(Debug)]
51pub enum LeafContent<T> {
52    /// A regular file with content of type `T`.
53    Regular(T),
54    /// A block device with the given device number.
55    BlockDevice(u64),
56    /// A character device with the given device number.
57    CharacterDevice(u64),
58    /// A named pipe (FIFO).
59    Fifo,
60    /// A Unix domain socket.
61    Socket,
62    /// A symbolic link pointing to the given target path.
63    Symlink(Box<OsStr>),
64}
65
66/// A leaf node representing a non-directory file.
67#[derive(Debug)]
68pub struct Leaf<T> {
69    /// Metadata for this leaf node.
70    pub stat: Stat,
71    /// The content and type of this leaf node.
72    pub content: LeafContent<T>,
73}
74
75/// A directory node containing named entries.
76#[derive(Debug)]
77pub struct Directory<T> {
78    /// Metadata for this directory.
79    pub stat: Stat,
80    /// Map of filenames to inodes within this directory.
81    pub(crate) entries: BTreeMap<Box<OsStr>, Inode<T>>,
82}
83
84/// A filesystem inode representing either a directory or a leaf node.
85#[derive(Debug)]
86pub enum Inode<T> {
87    /// A directory inode.
88    Directory(Box<Directory<T>>),
89    /// A leaf inode (reference-counted to support hardlinks).
90    Leaf(Rc<Leaf<T>>),
91}
92
93/// Errors that can occur when working with filesystem images.
94#[derive(Error, Debug)]
95pub enum ImageError {
96    /// The filename contains invalid components (e.g., "..", ".", or Windows prefixes).
97    #[error("Invalid filename {0:?}")]
98    InvalidFilename(Box<OsStr>),
99    /// The specified directory entry does not exist.
100    #[error("Directory entry {0:?} does not exist")]
101    NotFound(Box<OsStr>),
102    /// The entry exists but is not a directory when a directory was expected.
103    #[error("Directory entry {0:?} is not a subdirectory")]
104    NotADirectory(Box<OsStr>),
105    /// The entry is a directory when a non-directory was expected.
106    #[error("Directory entry {0:?} is a directory")]
107    IsADirectory(Box<OsStr>),
108    /// The entry exists but is not a regular file when a regular file was expected.
109    #[error("Directory entry {0:?} is not a regular file")]
110    IsNotRegular(Box<OsStr>),
111}
112
113impl<T> Inode<T> {
114    /// Returns a reference to the metadata for this inode.
115    pub fn stat(&self) -> &Stat {
116        match self {
117            Inode::Directory(dir) => &dir.stat,
118            Inode::Leaf(leaf) => &leaf.stat,
119        }
120    }
121}
122
123impl<T> Directory<T> {
124    /// Creates a new directory with the given metadata.
125    pub fn new(stat: Stat) -> Self {
126        Self {
127            stat,
128            entries: BTreeMap::new(),
129        }
130    }
131
132    /// Iterates over all inodes in the current directory, in no particular order.
133    pub fn inodes(&self) -> impl Iterator<Item = &Inode<T>> + use<'_, T> {
134        self.entries.values()
135    }
136
137    /// Iterates over all entries in the current directory, in no particular order.  The iterator
138    /// returns pairs of `(&OsStr, &Inode)` and is probably used like so:
139    ///
140    /// Currently this is equivalent to `Directory::sorted_entries()` but that might change at some
141    /// point.
142    ///
143    /// ```
144    /// use composefs::{tree::{FileSystem, Stat}, fsverity::Sha256HashValue};
145    /// let fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
146    ///
147    /// // populate the fs...
148    ///
149    /// for (name, inode) in fs.root.entries() {
150    ///   // name: &OsStr, inode: &Inode
151    /// }
152    /// ```
153    pub fn entries(&self) -> impl Iterator<Item = (&OsStr, &Inode<T>)> + use<'_, T> {
154        self.entries.iter().map(|(k, v)| (k.as_ref(), v))
155    }
156
157    /// Iterates over all entries in the current directory, in asciibetical order of name.  The
158    /// iterator returns pairs of `(&OsStr, &Inode)`.
159    pub fn sorted_entries(&self) -> impl Iterator<Item = (&OsStr, &Inode<T>)> + use<'_, T> {
160        self.entries.iter().map(|(k, v)| (k.as_ref(), v))
161    }
162
163    /// Gets a reference to a subdirectory of this directory.
164    ///
165    /// The given path may be absolute or relative and it makes no difference.  It may not contain
166    /// any Windows-like prefixes, or "." or ".." components.  It may or may not end in "/" and it
167    /// makes no difference.
168    ///
169    /// See `Directory::get_directory_mut()` for the mutable verison of this function.
170    ///
171    /// # Arguments
172    ///
173    ///  * `pathname`: the full pathname of the directory to fetch, taken as being relative to the
174    ///    current directory even if it starts with '/'
175    ///
176    /// # Return value
177    ///
178    /// On success, this returns a reference to the named directory.
179    ///
180    /// On failure, can return any number of errors from ImageError.
181    pub fn get_directory(&self, pathname: &OsStr) -> Result<&Directory<T>, ImageError> {
182        match self.get_directory_opt(pathname)? {
183            Some(r) => Ok(r),
184            None => Err(ImageError::NotFound(Box::from(pathname))),
185        }
186    }
187
188    /// Like [`Self::get_directory()`] but maps [`ImageError::NotFound`] to [`Option`].
189    pub fn get_directory_opt(&self, pathname: &OsStr) -> Result<Option<&Directory<T>>, ImageError> {
190        let path = Path::new(pathname);
191        let mut dir = self;
192
193        for component in path.components() {
194            dir = match component {
195                Component::RootDir => dir,
196                Component::Prefix(..) | Component::CurDir | Component::ParentDir => {
197                    return Err(ImageError::InvalidFilename(pathname.into()));
198                }
199                Component::Normal(filename) => match dir.entries.get(filename) {
200                    Some(Inode::Directory(subdir)) => subdir,
201                    Some(_) => return Err(ImageError::NotADirectory(filename.into())),
202                    None => return Ok(None),
203                },
204            }
205        }
206
207        Ok(Some(dir))
208    }
209
210    /// Gets a mutable reference to a subdirectory of this directory.
211    ///
212    /// This is the mutable version of `Directory::get_directory()`.
213    pub fn get_directory_mut(&mut self, pathname: &OsStr) -> Result<&mut Directory<T>, ImageError> {
214        let path = Path::new(pathname);
215        let mut dir = self;
216
217        for component in path.components() {
218            dir = match component {
219                Component::RootDir => dir,
220                Component::Prefix(..) | Component::CurDir | Component::ParentDir => {
221                    return Err(ImageError::InvalidFilename(pathname.into()));
222                }
223                Component::Normal(filename) => match dir.entries.get_mut(filename) {
224                    Some(Inode::Directory(subdir)) => subdir,
225                    Some(_) => return Err(ImageError::NotADirectory(filename.into())),
226                    None => return Err(ImageError::NotFound(filename.into())),
227                },
228            };
229        }
230
231        Ok(dir)
232    }
233
234    /// Splits a pathname into a directory and the filename within that directory.  The directory
235    /// must already exist.  The filename within the directory may or may not exist.
236    ///
237    /// This is the main entry point for most operations based on pathname.  The expectation is
238    /// that the returned filename will be used to perform a more concrete operation on the
239    /// returned directory.
240    ///
241    /// See `Directory::get_directory()` for more information about path traversal.  See
242    /// `Directory::split_mut()` for the mutable version of this function.
243    ///
244    /// # Arguments
245    ///
246    ///  * `pathname`: the full pathname to the file of interest
247    ///
248    /// # Return value
249    ///
250    /// On success (the pathname is not invalid and the directory exists), returns a tuple of the
251    /// `Directory` containing the file at the given path, and the basename of that file.
252    ///
253    /// On failure, can return any number of errors from ImageError.
254    pub fn split<'d, 'n>(
255        &'d self,
256        pathname: &'n OsStr,
257    ) -> Result<(&'d Directory<T>, &'n OsStr), ImageError> {
258        let path = Path::new(pathname);
259
260        let Some(filename) = path.file_name() else {
261            return Err(ImageError::InvalidFilename(Box::from(pathname)));
262        };
263
264        let dir = match path.parent() {
265            Some(parent) => self.get_directory(parent.as_os_str())?,
266            None => self,
267        };
268
269        Ok((dir, filename))
270    }
271
272    /// Splits a pathname into a directory and the filename within that directory.  The directory
273    /// must already exist.  The filename within the directory may or may not exist.
274    ///
275    /// This is the `_mut` version of `Directory::split()`.
276    pub fn split_mut<'d, 'n>(
277        &'d mut self,
278        pathname: &'n OsStr,
279    ) -> Result<(&'d mut Directory<T>, &'n OsStr), ImageError> {
280        let path = Path::new(pathname);
281
282        let Some(filename) = path.file_name() else {
283            return Err(ImageError::InvalidFilename(Box::from(pathname)));
284        };
285
286        let dir = match path.parent() {
287            Some(parent) => self.get_directory_mut(parent.as_os_str())?,
288            None => self,
289        };
290
291        Ok((dir, filename))
292    }
293
294    /// Takes a reference to the "leaf" file (not directory) with the given filename directly
295    /// contained in this directory.  This is usually done in preparation for creating a hardlink
296    /// or in order to avoid issues with the borrow checker when mutating the tree.
297    ///
298    /// # Arguments
299    ///
300    ///  * `filename`: the filename in the current directory.  If you need to support full
301    ///    pathnames then you should call `Directory::split()` first.
302    ///
303    /// # Return value
304    ///
305    /// On success (the entry exists and is not a directory) the Rc is cloned and a new reference
306    /// is returned.
307    ///
308    /// On failure, can return any number of errors from ImageError.
309    pub fn ref_leaf(&self, filename: &OsStr) -> Result<Rc<Leaf<T>>, ImageError> {
310        match self.entries.get(filename) {
311            Some(Inode::Leaf(leaf)) => Ok(Rc::clone(leaf)),
312            Some(Inode::Directory(..)) => Err(ImageError::IsADirectory(Box::from(filename))),
313            None => Err(ImageError::NotFound(Box::from(filename))),
314        }
315    }
316
317    /// Obtains information about the regular file with the given filename directly contained in
318    /// this directory.
319    ///
320    /// # Arguments
321    ///
322    ///  * `filename`: the filename in the current directory.  If you need to support full
323    ///    pathnames then you should call `Directory::split()` first.
324    ///
325    /// # Return value
326    ///
327    /// On success (the entry exists and is a regular file) then the return value is either:
328    ///  * the inline data
329    ///  * an external reference, with size information
330    ///
331    /// On failure, can return any number of errors from ImageError.
332    pub fn get_file<'a>(&'a self, filename: &OsStr) -> Result<&'a T, ImageError> {
333        self.get_file_opt(filename)?
334            .ok_or_else(|| ImageError::NotFound(Box::from(filename)))
335    }
336
337    /// Like [`Self::get_file()`] but maps [`ImageError::NotFound`] to [`Option`].
338    pub fn get_file_opt<'a>(&'a self, filename: &OsStr) -> Result<Option<&'a T>, ImageError> {
339        match self.entries.get(filename) {
340            Some(Inode::Leaf(leaf)) => match &leaf.content {
341                LeafContent::Regular(file) => Ok(Some(file)),
342                _ => Err(ImageError::IsNotRegular(filename.into())),
343            },
344            Some(Inode::Directory(..)) => Err(ImageError::IsADirectory(filename.into())),
345            None => Ok(None),
346        }
347    }
348
349    /// Inserts the given inode into the directory with special handling for directories.  In case
350    /// the inode is a directory and there is already a subdirectory with the given filename, the
351    /// `stat` field will be updated with the value from the provided `inode` but the old directory
352    /// entries will be left in place.
353    ///
354    /// In all other cases, this function is equivalent to `Directory::insert()`.
355    ///
356    /// This is something like extracting an archive or an overlay: directories are merged with
357    /// existing directories, but otherwise the new content replaces what was there before.
358    ///
359    /// # Arguments
360    ///
361    ///  * `filename`: the filename in the current directory.  If you need to support full
362    ///    pathnames then you should call `Directory::split()` first.
363    ///  * `inode`: the inode to store under the `filename`
364    pub fn merge(&mut self, filename: &OsStr, inode: Inode<T>) {
365        // If we're putting a directory on top of a directory, then update the stat information but
366        // keep the old entries in place.
367        if let Inode::Directory(new_dir) = inode {
368            if let Some(Inode::Directory(old_dir)) = self.entries.get_mut(filename) {
369                old_dir.stat = new_dir.stat;
370            } else {
371                // Unfortunately we already deconstructed the original inode and we can't get it
372                // back again.  This is necessary because we wanted to move the stat field (above)
373                // without cloning it which can't be done through a reference (mutable or not).
374                self.insert(filename, Inode::Directory(new_dir));
375            }
376        } else {
377            self.insert(filename, inode);
378        }
379    }
380
381    /// Inserts the given inode into the directory.
382    ///
383    /// If the `filename` existed previously, the content is completely overwritten, including the
384    /// case that it was a directory.
385    ///
386    /// # Arguments
387    ///
388    ///  * `filename`: the filename in the current directory.  If you need to support full
389    ///    pathnames then you should call `Directory::split()` first.
390    ///  * `inode`: the inode to store under the `filename`
391    pub fn insert(&mut self, filename: &OsStr, inode: Inode<T>) {
392        self.entries.insert(Box::from(filename), inode);
393    }
394
395    /// Removes the named file from the directory, if it exists.  If it doesn't exist, this is a
396    /// no-op.
397    ///
398    /// # Arguments
399    ///
400    ///  * `filename`: the filename in the current directory.  If you need to support full
401    ///    pathnames then you should call `Directory::split()` first.
402    pub fn remove(&mut self, filename: &OsStr) {
403        self.entries.remove(filename);
404    }
405
406    /// Does a directory lookup on the given filename, returning the Inode if it exists.
407    ///
408    /// # Arguments
409    ///
410    ///  * `filename`: the filename in the current directory.  If you need to support full
411    ///    pathnames then you should call `Directory::split()` first.
412    pub fn lookup(&self, filename: &OsStr) -> Option<&Inode<T>> {
413        self.entries.get(filename)
414    }
415
416    /// Removes an item from the directory, if it exists, returning the Inode value.
417    ///
418    /// # Arguments
419    ///
420    ///  * `filename`: the filename in the current directory.  If you need to support full
421    ///    pathnames then you should call `Directory::split_mut()` first.
422    pub fn pop(&mut self, filename: &OsStr) -> Option<Inode<T>> {
423        self.entries.remove(filename)
424    }
425
426    /// Removes all content from this directory, making the directory empty.  The `stat` data
427    /// remains unmodified.
428    pub fn clear(&mut self) {
429        self.entries.clear();
430    }
431
432    /// Recursively finds the newest modification time in this directory tree.
433    ///
434    /// Returns the maximum modification time among this directory's metadata
435    /// and all files and subdirectories it contains.
436    pub fn newest_file(&self) -> i64 {
437        let mut newest = self.stat.st_mtim_sec;
438        for inode in self.entries.values() {
439            let mtime = match inode {
440                Inode::Leaf(ref leaf) => leaf.stat.st_mtim_sec,
441                Inode::Directory(ref dir) => dir.newest_file(),
442            };
443            if mtime > newest {
444                newest = mtime;
445            }
446        }
447        newest
448    }
449}
450
451/// A complete filesystem tree with a root directory.
452#[derive(Debug)]
453pub struct FileSystem<T> {
454    /// The root directory of the filesystem.
455    pub root: Directory<T>,
456}
457
458impl<T> FileSystem<T> {
459    /// Creates a new filesystem with a root directory having the given metadata.
460    pub fn new(root_stat: Stat) -> Self {
461        Self {
462            root: Directory::new(root_stat),
463        }
464    }
465
466    /// Sets the metadata for the root directory.
467    pub fn set_root_stat(&mut self, stat: Stat) {
468        self.root.stat = stat;
469    }
470
471    /// Copies metadata from `/usr` to the root directory.
472    ///
473    /// OCI container layer tars often don't include a root directory entry,
474    /// and when they do, container runtimes typically ignore it. This makes
475    /// root metadata non-deterministic. This method provides a way to derive
476    /// consistent root metadata by copying it from `/usr`, which is always
477    /// present in standard filesystem layouts.
478    ///
479    /// The copied metadata includes:
480    /// - Mode (permissions)
481    /// - Modification time
482    /// - User ID (uid)
483    /// - Group ID (gid)
484    /// - Extended attributes (xattrs)
485    ///
486    /// NOTE: If changing this behavior, also update `doc/oci.md`.
487    ///
488    /// # Errors
489    ///
490    /// Returns an error if `/usr` does not exist or is not a directory.
491    pub fn copy_root_metadata_from_usr(&mut self) -> Result<(), ImageError> {
492        let usr = self.root.get_directory(OsStr::new("usr"))?;
493
494        // Copy values to local variables to avoid borrow conflicts
495        let st_mode = usr.stat.st_mode;
496        let st_uid = usr.stat.st_uid;
497        let st_gid = usr.stat.st_gid;
498        let st_mtim_sec = usr.stat.st_mtim_sec;
499        let xattrs = usr.stat.xattrs.clone();
500
501        // Apply copied metadata to root
502        self.root.stat.st_mode = st_mode;
503        self.root.stat.st_uid = st_uid;
504        self.root.stat.st_gid = st_gid;
505        self.root.stat.st_mtim_sec = st_mtim_sec;
506        self.root.stat.xattrs = xattrs;
507
508        Ok(())
509    }
510
511    /// Applies a function to every [`Stat`] in the filesystem tree.
512    ///
513    /// This visits the root directory and all descendants (directories and leaves),
514    /// calling the provided function with each node's `Stat`.
515    pub fn for_each_stat<F>(&self, f: F)
516    where
517        F: Fn(&Stat),
518    {
519        fn visit_inode<T, F: Fn(&Stat)>(inode: &Inode<T>, f: &F) {
520            match inode {
521                Inode::Directory(ref dir) => visit_dir(dir, f),
522                Inode::Leaf(ref leaf) => f(&leaf.stat),
523            }
524        }
525
526        fn visit_dir<T, F: Fn(&Stat)>(dir: &Directory<T>, f: &F) {
527            f(&dir.stat);
528            for (_name, inode) in dir.entries.iter() {
529                visit_inode(inode, f);
530            }
531        }
532
533        visit_dir(&self.root, &f);
534    }
535
536    /// Filters extended attributes across the entire filesystem tree.
537    ///
538    /// Retains only xattrs whose names match the given predicate.
539    /// This is useful for stripping build-time xattrs that shouldn't
540    /// leak into the final image (e.g., `security.selinux` labels from
541    /// the build host).
542    pub fn filter_xattrs<F>(&self, predicate: F)
543    where
544        F: Fn(&OsStr) -> bool,
545    {
546        self.for_each_stat(|stat| {
547            stat.xattrs.borrow_mut().retain(|k, _| predicate(k));
548        });
549    }
550
551    /// Empties the `/run` directory if present, using `/usr`'s mtime.
552    ///
553    /// `/run` is a tmpfs at runtime and should always be empty in container images.
554    /// This also works around podman/buildah's `RUN --mount` behavior where bind
555    /// mount targets leave directory stubs in the filesystem that shouldn't be
556    /// part of the image content.
557    ///
558    /// The mtime is set to match `/usr` for consistency with [`Self::copy_root_metadata_from_usr`].
559    ///
560    /// NOTE: If changing this behavior, also update `doc/oci.md`.
561    ///
562    /// # Errors
563    ///
564    /// Returns an error if `/usr` does not exist (needed to get the mtime).
565    pub fn canonicalize_run(&mut self) -> Result<(), ImageError> {
566        if self.root.get_directory_opt(OsStr::new("run"))?.is_some() {
567            let usr_mtime = self.root.get_directory(OsStr::new("usr"))?.stat.st_mtim_sec;
568            let run_dir = self.root.get_directory_mut(OsStr::new("run"))?;
569            run_dir.stat.st_mtim_sec = usr_mtime;
570            run_dir.clear();
571        }
572        Ok(())
573    }
574
575    /// Transforms the filesystem for OCI container image consistency.
576    ///
577    /// This applies the standard transformations needed to ensure consistent
578    /// composefs digests between build-time (mounted filesystem) and install-time
579    /// (OCI tar layers) views:
580    ///
581    /// 1. [`Self::copy_root_metadata_from_usr`] - copies `/usr` metadata to root directory
582    /// 2. [`Self::canonicalize_run`] - empties `/run` directory
583    ///
584    /// This is the recommended single entry point for OCI container processing.
585    ///
586    /// NOTE: If changing this behavior, also update `doc/oci.md`.
587    ///
588    /// # Errors
589    ///
590    /// Returns an error if `/usr` does not exist.
591    pub fn transform_for_oci(&mut self) -> Result<(), ImageError> {
592        self.copy_root_metadata_from_usr()?;
593        self.canonicalize_run()?;
594        Ok(())
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use std::cell::RefCell;
602    use std::collections::BTreeMap;
603    use std::ffi::{OsStr, OsString};
604    use std::rc::Rc;
605
606    // We never store any actual data here
607    #[derive(Debug, Default)]
608    struct FileContents {}
609
610    // Helper to create a default stat for tests
611    fn default_stat() -> Stat {
612        Stat {
613            st_mode: 0o755,
614            st_uid: 0,
615            st_gid: 0,
616            st_mtim_sec: 0,
617            xattrs: RefCell::new(BTreeMap::new()),
618        }
619    }
620
621    // Helper to create a Stat with a specific mtime
622    fn stat_with_mtime(mtime: i64) -> Stat {
623        Stat {
624            st_mode: 0o755,
625            st_uid: 1000,
626            st_gid: 1000,
627            st_mtim_sec: mtime,
628            xattrs: RefCell::new(BTreeMap::new()),
629        }
630    }
631
632    // Helper to create a simple Leaf (e.g., an empty inline file)
633    fn new_leaf_file(mtime: i64) -> Rc<Leaf<FileContents>> {
634        Rc::new(Leaf {
635            stat: stat_with_mtime(mtime),
636            content: LeafContent::Regular(FileContents::default()),
637        })
638    }
639
640    // Helper to create a simple Leaf (symlink)
641    fn new_leaf_symlink(target: &str, mtime: i64) -> Rc<Leaf<FileContents>> {
642        Rc::new(Leaf {
643            stat: stat_with_mtime(mtime),
644            content: LeafContent::Symlink(OsString::from(target).into_boxed_os_str()),
645        })
646    }
647
648    // Helper to create an empty Directory Inode with a specific mtime
649    fn new_dir_inode<T>(mtime: i64) -> Inode<T> {
650        Inode::Directory(Box::new(Directory {
651            stat: stat_with_mtime(mtime),
652            entries: BTreeMap::new(),
653        }))
654    }
655
656    // Helper to create a Directory Inode with specific stat
657    fn new_dir_inode_with_stat<T>(stat: Stat) -> Inode<T> {
658        Inode::Directory(Box::new(Directory {
659            stat,
660            entries: BTreeMap::new(),
661        }))
662    }
663
664    #[test]
665    fn test_directory_new() {
666        let stat = stat_with_mtime(123);
667        let dir = Directory::<()>::new(stat);
668        assert_eq!(dir.stat.st_mtim_sec, 123);
669        assert!(dir.entries.is_empty());
670    }
671
672    #[test]
673    fn test_insert_and_get_leaf() {
674        let mut dir = Directory::<FileContents>::new(default_stat());
675        let leaf = new_leaf_file(10);
676        dir.insert(OsStr::new("file.txt"), Inode::Leaf(Rc::clone(&leaf)));
677        assert_eq!(dir.entries.len(), 1);
678
679        let retrieved_leaf_rc = dir.ref_leaf(OsStr::new("file.txt")).unwrap();
680        assert!(Rc::ptr_eq(&retrieved_leaf_rc, &leaf));
681
682        let regular_file_content = dir.get_file(OsStr::new("file.txt")).unwrap();
683        assert!(matches!(regular_file_content, FileContents {}));
684    }
685
686    #[test]
687    fn test_insert_and_get_directory() {
688        let mut dir = Directory::<()>::new(default_stat());
689        let sub_dir_inode = new_dir_inode(20);
690        dir.insert(OsStr::new("subdir"), sub_dir_inode);
691        assert_eq!(dir.entries.len(), 1);
692
693        let retrieved_subdir = dir.get_directory(OsStr::new("subdir")).unwrap();
694        assert_eq!(retrieved_subdir.stat.st_mtim_sec, 20);
695
696        let retrieved_subdir_opt = dir
697            .get_directory_opt(OsStr::new("subdir"))
698            .unwrap()
699            .unwrap();
700        assert_eq!(retrieved_subdir_opt.stat.st_mtim_sec, 20);
701    }
702
703    #[test]
704    fn test_get_directory_errors() {
705        let mut root = Directory::new(default_stat());
706        root.insert(OsStr::new("dir1"), new_dir_inode(10));
707        root.insert(OsStr::new("file1"), Inode::Leaf(new_leaf_file(30)));
708
709        match root.get_directory(OsStr::new("nonexistent")) {
710            Err(ImageError::NotFound(name)) => assert_eq!(name.to_str().unwrap(), "nonexistent"),
711            _ => panic!("Expected NotFound"),
712        }
713        assert!(root
714            .get_directory_opt(OsStr::new("nonexistent"))
715            .unwrap()
716            .is_none());
717
718        match root.get_directory(OsStr::new("file1")) {
719            Err(ImageError::NotADirectory(name)) => assert_eq!(name.to_str().unwrap(), "file1"),
720            _ => panic!("Expected NotADirectory"),
721        }
722    }
723
724    #[test]
725    fn test_get_file_errors() {
726        let mut dir = Directory::new(default_stat());
727        dir.insert(OsStr::new("subdir"), new_dir_inode(10));
728        dir.insert(
729            OsStr::new("link.txt"),
730            Inode::Leaf(new_leaf_symlink("target", 20)),
731        );
732
733        match dir.get_file(OsStr::new("nonexistent.txt")) {
734            Err(ImageError::NotFound(name)) => {
735                assert_eq!(name.to_str().unwrap(), "nonexistent.txt")
736            }
737            _ => panic!("Expected NotFound"),
738        }
739        assert!(dir
740            .get_file_opt(OsStr::new("nonexistent.txt"))
741            .unwrap()
742            .is_none());
743
744        match dir.get_file(OsStr::new("subdir")) {
745            Err(ImageError::IsADirectory(name)) => assert_eq!(name.to_str().unwrap(), "subdir"),
746            _ => panic!("Expected IsADirectory"),
747        }
748        match dir.get_file(OsStr::new("link.txt")) {
749            Err(ImageError::IsNotRegular(name)) => assert_eq!(name.to_str().unwrap(), "link.txt"),
750            res => panic!("Expected IsNotRegular, got {res:?}"),
751        }
752    }
753
754    #[test]
755    fn test_remove() {
756        let mut dir = Directory::new(default_stat());
757        dir.insert(OsStr::new("file1.txt"), Inode::Leaf(new_leaf_file(10)));
758        dir.insert(OsStr::new("subdir"), new_dir_inode(20));
759        assert_eq!(dir.entries.len(), 2);
760
761        dir.remove(OsStr::new("file1.txt"));
762        assert_eq!(dir.entries.len(), 1);
763        assert!(!dir.entries.contains_key(OsStr::new("file1.txt")));
764
765        dir.remove(OsStr::new("nonexistent")); // Should be no-op
766        assert_eq!(dir.entries.len(), 1);
767    }
768
769    #[test]
770    fn test_merge() {
771        let mut dir = Directory::new(default_stat());
772
773        // Merge Leaf onto empty
774        dir.merge(OsStr::new("item"), Inode::Leaf(new_leaf_file(10)));
775        assert_eq!(
776            dir.entries
777                .get(OsStr::new("item"))
778                .unwrap()
779                .stat()
780                .st_mtim_sec,
781            10
782        );
783
784        // Merge Directory onto existing Directory
785        let mut existing_dir_inode = new_dir_inode_with_stat(stat_with_mtime(80));
786        if let Inode::Directory(ref mut ed_box) = existing_dir_inode {
787            ed_box.insert(OsStr::new("inner_file"), Inode::Leaf(new_leaf_file(85)));
788        }
789        dir.insert(OsStr::new("merged_dir"), existing_dir_inode);
790
791        let new_merging_dir_inode = new_dir_inode_with_stat(stat_with_mtime(90));
792        dir.merge(OsStr::new("merged_dir"), new_merging_dir_inode);
793
794        match dir.entries.get(OsStr::new("merged_dir")) {
795            Some(Inode::Directory(d)) => {
796                assert_eq!(d.stat.st_mtim_sec, 90); // Stat updated
797                assert_eq!(d.entries.len(), 1); // Inner file preserved
798                assert!(d.entries.contains_key(OsStr::new("inner_file")));
799            }
800            _ => panic!("Expected directory after merge"),
801        }
802
803        // Merge Leaf onto Directory (replaces)
804        dir.merge(OsStr::new("merged_dir"), Inode::Leaf(new_leaf_file(100)));
805        assert!(matches!(
806            dir.entries.get(OsStr::new("merged_dir")),
807            Some(Inode::Leaf(_))
808        ));
809        assert_eq!(
810            dir.entries
811                .get(OsStr::new("merged_dir"))
812                .unwrap()
813                .stat()
814                .st_mtim_sec,
815            100
816        );
817    }
818
819    #[test]
820    fn test_clear() {
821        let mut dir = Directory::new(default_stat());
822        dir.insert(OsStr::new("file1"), Inode::Leaf(new_leaf_file(10)));
823        dir.stat.st_mtim_sec = 100;
824
825        dir.clear();
826        assert!(dir.entries.is_empty());
827        assert_eq!(dir.stat.st_mtim_sec, 100); // Stat should be unmodified
828    }
829
830    #[test]
831    fn test_newest_file() {
832        let mut root = Directory::new(stat_with_mtime(5));
833        assert_eq!(root.newest_file(), 5);
834
835        root.insert(OsStr::new("file1"), Inode::Leaf(new_leaf_file(10)));
836        assert_eq!(root.newest_file(), 10);
837
838        let subdir_stat = stat_with_mtime(15);
839        let mut subdir = Box::new(Directory::new(subdir_stat));
840        subdir.insert(OsStr::new("subfile1"), Inode::Leaf(new_leaf_file(12)));
841        root.insert(OsStr::new("subdir"), Inode::Directory(subdir));
842        assert_eq!(root.newest_file(), 15);
843
844        if let Some(Inode::Directory(sd)) = root.entries.get_mut(OsStr::new("subdir")) {
845            sd.insert(OsStr::new("subfile2"), Inode::Leaf(new_leaf_file(20)));
846        }
847        assert_eq!(root.newest_file(), 20);
848
849        root.stat.st_mtim_sec = 25;
850        assert_eq!(root.newest_file(), 25);
851    }
852
853    #[test]
854    fn test_iteration_entries_sorted_inodes() {
855        let mut dir = Directory::new(default_stat());
856        dir.insert(OsStr::new("b_file"), Inode::Leaf(new_leaf_file(10)));
857        dir.insert(OsStr::new("a_dir"), new_dir_inode(20));
858        dir.insert(
859            OsStr::new("c_link"),
860            Inode::Leaf(new_leaf_symlink("target", 30)),
861        );
862
863        let names_from_entries: Vec<&OsStr> = dir.entries().map(|(name, _)| name).collect();
864        assert_eq!(names_from_entries.len(), 3); // BTreeMap iter is sorted
865        assert!(names_from_entries.contains(&OsStr::new("a_dir")));
866        assert!(names_from_entries.contains(&OsStr::new("b_file")));
867        assert!(names_from_entries.contains(&OsStr::new("c_link")));
868
869        let sorted_names: Vec<&OsStr> = dir.sorted_entries().map(|(name, _)| name).collect();
870        assert_eq!(
871            sorted_names,
872            vec![
873                OsStr::new("a_dir"),
874                OsStr::new("b_file"),
875                OsStr::new("c_link")
876            ]
877        );
878
879        let mut inode_types = vec![];
880        for inode in dir.inodes() {
881            match inode {
882                Inode::Directory(_) => inode_types.push("dir"),
883                Inode::Leaf(_) => inode_types.push("leaf"),
884            }
885        }
886        assert_eq!(inode_types.len(), 3);
887        assert_eq!(inode_types.iter().filter(|&&t| t == "dir").count(), 1);
888        assert_eq!(inode_types.iter().filter(|&&t| t == "leaf").count(), 2);
889    }
890
891    #[test]
892    fn test_copy_root_metadata_from_usr() {
893        let mut fs = FileSystem::<FileContents>::new(default_stat());
894
895        // Create /usr with specific metadata
896        let usr_stat = Stat {
897            st_mode: 0o755,
898            st_uid: 42,
899            st_gid: 43,
900            st_mtim_sec: 1234567890,
901            xattrs: RefCell::new(BTreeMap::from([(
902                Box::from(OsStr::new("security.selinux")),
903                Box::from(b"system_u:object_r:usr_t:s0".as_slice()),
904            )])),
905        };
906        let usr_dir = Directory {
907            stat: usr_stat,
908            entries: BTreeMap::new(),
909        };
910        fs.root.entries.insert(
911            Box::from(OsStr::new("usr")),
912            Inode::Directory(Box::new(usr_dir)),
913        );
914
915        fs.copy_root_metadata_from_usr().unwrap();
916
917        assert_eq!(fs.root.stat.st_mode, 0o755);
918        assert_eq!(fs.root.stat.st_uid, 42);
919        assert_eq!(fs.root.stat.st_gid, 43);
920        assert_eq!(fs.root.stat.st_mtim_sec, 1234567890);
921        assert!(fs
922            .root
923            .stat
924            .xattrs
925            .borrow()
926            .contains_key(OsStr::new("security.selinux")));
927    }
928
929    #[test]
930    fn test_copy_root_metadata_from_usr_missing() {
931        let mut fs = FileSystem::<FileContents>::new(default_stat());
932
933        match fs.copy_root_metadata_from_usr() {
934            Err(ImageError::NotFound(name)) => assert_eq!(name.to_str().unwrap(), "usr"),
935            other => panic!("Expected NotFound error, got {:?}", other),
936        }
937    }
938
939    #[test]
940    fn test_filter_xattrs() {
941        let root_stat = Stat {
942            st_mode: 0o755,
943            st_uid: 0,
944            st_gid: 0,
945            st_mtim_sec: 0,
946            xattrs: RefCell::new(BTreeMap::from([
947                (
948                    Box::from(OsStr::new("security.selinux")),
949                    Box::from(b"label".as_slice()),
950                ),
951                (
952                    Box::from(OsStr::new("security.capability")),
953                    Box::from(b"cap".as_slice()),
954                ),
955                (
956                    Box::from(OsStr::new("user.custom")),
957                    Box::from(b"value".as_slice()),
958                ),
959            ])),
960        };
961        let fs = FileSystem::<FileContents>::new(root_stat);
962
963        // Filter to keep only xattrs starting with "user."
964        fs.filter_xattrs(|name| name.as_encoded_bytes().starts_with(b"user."));
965
966        let root_xattrs = fs.root.stat.xattrs.borrow();
967        assert_eq!(root_xattrs.len(), 1);
968        assert!(root_xattrs.contains_key(OsStr::new("user.custom")));
969    }
970
971    #[test]
972    fn test_canonicalize_run() {
973        let mut fs = FileSystem::<FileContents>::new(default_stat());
974
975        // Create /usr with specific mtime
976        let usr_dir = Directory::new(stat_with_mtime(12345));
977        fs.root
978            .insert(OsStr::new("usr"), Inode::Directory(Box::new(usr_dir)));
979
980        // Create /run with content and different mtime
981        let mut run_dir = Directory::new(stat_with_mtime(99999));
982        run_dir.insert(OsStr::new("somefile"), Inode::Leaf(new_leaf_file(11111)));
983        let mut subdir = Directory::new(stat_with_mtime(22222));
984        subdir.insert(OsStr::new("nested"), Inode::Leaf(new_leaf_file(33333)));
985        run_dir.insert(OsStr::new("subdir"), Inode::Directory(Box::new(subdir)));
986        fs.root
987            .insert(OsStr::new("run"), Inode::Directory(Box::new(run_dir)));
988
989        // Verify /run has content before
990        assert_eq!(
991            fs.root
992                .get_directory(OsStr::new("run"))
993                .unwrap()
994                .entries
995                .len(),
996            2
997        );
998
999        // Canonicalize
1000        fs.canonicalize_run().unwrap();
1001
1002        // Verify /run is now empty with /usr's mtime
1003        let run = fs.root.get_directory(OsStr::new("run")).unwrap();
1004        assert!(run.entries.is_empty());
1005        assert_eq!(run.stat.st_mtim_sec, 12345);
1006    }
1007
1008    #[test]
1009    fn test_canonicalize_run_no_run_dir() {
1010        let mut fs = FileSystem::<FileContents>::new(default_stat());
1011
1012        // Create /usr but no /run
1013        let usr_dir = Directory::new(stat_with_mtime(12345));
1014        fs.root
1015            .insert(OsStr::new("usr"), Inode::Directory(Box::new(usr_dir)));
1016
1017        // Should succeed without error
1018        fs.canonicalize_run().unwrap();
1019    }
1020
1021    #[test]
1022    fn test_transform_for_oci() {
1023        let mut fs = FileSystem::<FileContents>::new(default_stat());
1024
1025        // Create /usr with specific metadata
1026        let usr_stat = Stat {
1027            st_mode: 0o750,
1028            st_uid: 100,
1029            st_gid: 200,
1030            st_mtim_sec: 54321,
1031            xattrs: RefCell::new(BTreeMap::from([(
1032                Box::from(OsStr::new("user.test")),
1033                Box::from(b"val".as_slice()),
1034            )])),
1035        };
1036        fs.root
1037            .insert(OsStr::new("usr"), new_dir_inode_with_stat(usr_stat));
1038
1039        // Create /run with content
1040        let mut run_dir = Directory::new(stat_with_mtime(99999));
1041        run_dir.insert(OsStr::new("file"), Inode::Leaf(new_leaf_file(11111)));
1042        fs.root
1043            .insert(OsStr::new("run"), Inode::Directory(Box::new(run_dir)));
1044
1045        // Transform for OCI
1046        fs.transform_for_oci().unwrap();
1047
1048        // Verify root metadata copied from /usr
1049        assert_eq!(fs.root.stat.st_mode, 0o750);
1050        assert_eq!(fs.root.stat.st_uid, 100);
1051        assert_eq!(fs.root.stat.st_gid, 200);
1052        assert_eq!(fs.root.stat.st_mtim_sec, 54321);
1053
1054        // Verify /run is emptied with /usr's mtime
1055        let run = fs.root.get_directory(OsStr::new("run")).unwrap();
1056        assert!(run.entries.is_empty());
1057        assert_eq!(run.stat.st_mtim_sec, 54321);
1058    }
1059}