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
9const DEFAULT_MAX_TOTAL_SIZE: u64 = 10 * 1024 * 1024 * 1024; const DEFAULT_MAX_FILE_SIZE: u64 = 5 * 1024 * 1024 * 1024; const 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
22pub(crate) const WHITEOUT_MAJOR: u32 = 0;
24pub(crate) const WHITEOUT_MINOR: u32 = 0;
25
26pub(crate) const OPAQUE_XATTR_NAME: &[u8] = b"trusted.overlay.opaque";
28pub(crate) const OPAQUE_XATTR_VALUE: &[u8] = b"y";
29
30#[derive(Clone)]
37pub enum FileData {
38 Memory(Vec<u8>),
40 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
72pub const SPOOL_THRESHOLD: u64 = 64 * 1024;
75
76pub 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
159impl FileData {
164 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 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 pub fn as_bytes(&self) -> Option<&[u8]> {
195 match self {
196 FileData::Memory(v) => Some(v),
197 FileData::Spool { .. } => None,
198 }
199 }
200
201 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 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 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 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 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 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 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 pub fn strip_file_data(&mut self) {
452 strip_data_in_dir(&mut self.root);
453 }
454}
455
456impl 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
504fn 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
569pub 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(&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 if is_whiteout_device(&layer_node) {
611 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 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 provenance.retain(|k, _| !k.starts_with(&child_path));
645 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 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#[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 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 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 assert!(base.get(b"dir/a.txt").is_none());
876 assert!(base.get(b"dir/b.txt").is_none());
877 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 assert_eq!(tree.node_count(), 6);
896 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}