Skip to content

Commit 8509cc6

Browse files
committed
generic_tree: Add Clone to Stat and try_map_regular to FileSystem
Add the ability to convert a FileSystem<T> to FileSystem<U> by mapping the regular file content type. This preserves Rc hardlink sharing: the mapping function is called exactly once per unique leaf, and all references to the same leaf produce references to the same mapped leaf. Prep for the async filesystem import, which scans the directory tree into a FileSystem<PendingFile> and then converts to the final FileSystem<RegularFile<ObjectID>> after parallel verity computation. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 82db53e commit 8509cc6

1 file changed

Lines changed: 236 additions & 2 deletions

File tree

crates/composefs/src/generic_tree.rs

Lines changed: 236 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
44
use std::{
55
cell::RefCell,
6-
collections::BTreeMap,
6+
collections::{BTreeMap, HashMap},
77
ffi::OsStr,
88
path::{Component, Path},
99
rc::Rc,
@@ -12,7 +12,7 @@ use std::{
1212
use thiserror::Error;
1313

1414
/// File metadata similar to `struct stat` from POSIX.
15-
#[derive(Debug)]
15+
#[derive(Debug, Clone)]
1616
pub struct Stat {
1717
/// File mode and permissions bits.
1818
pub st_mode: u32,
@@ -63,6 +63,21 @@ pub enum LeafContent<T> {
6363
Symlink(Box<OsStr>),
6464
}
6565

66+
impl<T> LeafContent<T> {
67+
/// Maps `Regular(&T)` to `Regular(U)` via a fallible function,
68+
/// passing all other variants through unchanged.
69+
fn try_map_ref<U, E>(&self, f: impl FnOnce(&T) -> Result<U, E>) -> Result<LeafContent<U>, E> {
70+
match self {
71+
LeafContent::Regular(t) => Ok(LeafContent::Regular(f(t)?)),
72+
LeafContent::BlockDevice(rdev) => Ok(LeafContent::BlockDevice(*rdev)),
73+
LeafContent::CharacterDevice(rdev) => Ok(LeafContent::CharacterDevice(*rdev)),
74+
LeafContent::Fifo => Ok(LeafContent::Fifo),
75+
LeafContent::Socket => Ok(LeafContent::Socket),
76+
LeafContent::Symlink(target) => Ok(LeafContent::Symlink(target.clone())),
77+
}
78+
}
79+
}
80+
6681
/// A leaf node representing a non-directory file.
6782
#[derive(Debug)]
6883
pub struct Leaf<T> {
@@ -118,9 +133,50 @@ impl<T> Inode<T> {
118133
Inode::Leaf(leaf) => &leaf.stat,
119134
}
120135
}
136+
137+
fn try_map_regular_impl<U, E>(
138+
self,
139+
f: &mut impl FnMut(&T) -> Result<U, E>,
140+
rc_map: &mut HashMap<*const Leaf<T>, Rc<Leaf<U>>>,
141+
) -> Result<Inode<U>, E> {
142+
match self {
143+
Inode::Directory(dir) => {
144+
let new_dir = dir.try_map_regular_impl(f, rc_map)?;
145+
Ok(Inode::Directory(Box::new(new_dir)))
146+
}
147+
Inode::Leaf(leaf_rc) => {
148+
let ptr = Rc::as_ptr(&leaf_rc);
149+
if let Some(existing) = rc_map.get(&ptr) {
150+
return Ok(Inode::Leaf(Rc::clone(existing)));
151+
}
152+
let new_content = leaf_rc.content.try_map_ref(f)?;
153+
let new_leaf = Rc::new(Leaf {
154+
stat: leaf_rc.stat.clone(),
155+
content: new_content,
156+
});
157+
rc_map.insert(ptr, Rc::clone(&new_leaf));
158+
Ok(Inode::Leaf(new_leaf))
159+
}
160+
}
161+
}
121162
}
122163

123164
impl<T> Directory<T> {
165+
fn try_map_regular_impl<U, E>(
166+
self,
167+
f: &mut impl FnMut(&T) -> Result<U, E>,
168+
rc_map: &mut HashMap<*const Leaf<T>, Rc<Leaf<U>>>,
169+
) -> Result<Directory<U>, E> {
170+
let mut new_entries = BTreeMap::new();
171+
for (name, inode) in self.entries {
172+
new_entries.insert(name, inode.try_map_regular_impl(f, rc_map)?);
173+
}
174+
Ok(Directory {
175+
stat: self.stat,
176+
entries: new_entries,
177+
})
178+
}
179+
124180
/// Creates a new directory with the given metadata.
125181
pub fn new(stat: Stat) -> Self {
126182
Self {
@@ -593,6 +649,23 @@ impl<T> FileSystem<T> {
593649
self.canonicalize_run()?;
594650
Ok(())
595651
}
652+
653+
/// Converts `FileSystem<T>` to `FileSystem<U>` by mapping regular file content.
654+
///
655+
/// Applies `f` to each `LeafContent::Regular(T)` to produce `LeafContent::Regular(U)`.
656+
/// All other leaf content variants (symlinks, devices, etc.) are passed through unchanged.
657+
///
658+
/// Hardlink sharing is preserved: if multiple directory entries point to the same
659+
/// `Rc<Leaf<T>>`, the resulting tree will have entries pointing to the same `Rc<Leaf<U>>`.
660+
/// The mapping function is called exactly once per unique leaf.
661+
pub fn try_map_regular<U, E>(
662+
self,
663+
mut f: impl FnMut(&T) -> Result<U, E>,
664+
) -> Result<FileSystem<U>, E> {
665+
let mut rc_map: HashMap<*const Leaf<T>, Rc<Leaf<U>>> = HashMap::new();
666+
let root = self.root.try_map_regular_impl(&mut f, &mut rc_map)?;
667+
Ok(FileSystem { root })
668+
}
596669
}
597670

598671
#[cfg(test)]
@@ -1021,6 +1094,167 @@ mod tests {
10211094
fs.canonicalize_run().unwrap();
10221095
}
10231096

1097+
#[test]
1098+
fn test_try_map_regular_basic() {
1099+
let mut fs = FileSystem::<u32>::new(stat_with_mtime(1));
1100+
let leaf = Rc::new(Leaf {
1101+
stat: stat_with_mtime(10),
1102+
content: LeafContent::Regular(42u32),
1103+
});
1104+
fs.root.insert(OsStr::new("file.txt"), Inode::Leaf(leaf));
1105+
1106+
let mapped = fs
1107+
.try_map_regular(|v: &u32| Ok::<String, std::fmt::Error>(format!("val={v}")))
1108+
.unwrap();
1109+
1110+
let content = mapped.root.get_file(OsStr::new("file.txt")).unwrap();
1111+
assert_eq!(content, "val=42");
1112+
assert_eq!(mapped.root.stat.st_mtim_sec, 1);
1113+
}
1114+
1115+
#[test]
1116+
fn test_try_map_regular_non_regular_passthrough() {
1117+
let mut fs = FileSystem::<u32>::new(default_stat());
1118+
fs.root.insert(
1119+
OsStr::new("link"),
1120+
Inode::Leaf(Rc::new(Leaf {
1121+
stat: stat_with_mtime(1),
1122+
content: LeafContent::Symlink(OsString::from("/target").into_boxed_os_str()),
1123+
})),
1124+
);
1125+
fs.root.insert(
1126+
OsStr::new("fifo"),
1127+
Inode::Leaf(Rc::new(Leaf {
1128+
stat: stat_with_mtime(2),
1129+
content: LeafContent::Fifo,
1130+
})),
1131+
);
1132+
fs.root.insert(
1133+
OsStr::new("sock"),
1134+
Inode::Leaf(Rc::new(Leaf {
1135+
stat: stat_with_mtime(3),
1136+
content: LeafContent::Socket,
1137+
})),
1138+
);
1139+
fs.root.insert(
1140+
OsStr::new("blk"),
1141+
Inode::Leaf(Rc::new(Leaf {
1142+
stat: stat_with_mtime(4),
1143+
content: LeafContent::BlockDevice(0x0801),
1144+
})),
1145+
);
1146+
fs.root.insert(
1147+
OsStr::new("chr"),
1148+
Inode::Leaf(Rc::new(Leaf {
1149+
stat: stat_with_mtime(5),
1150+
content: LeafContent::CharacterDevice(0x0501),
1151+
})),
1152+
);
1153+
1154+
let mapped = fs
1155+
.try_map_regular(|_: &u32| Ok::<String, std::fmt::Error>("unused".into()))
1156+
.unwrap();
1157+
1158+
// Verify each non-regular variant is preserved
1159+
match mapped.root.lookup(OsStr::new("link")) {
1160+
Some(Inode::Leaf(l)) => match &l.content {
1161+
LeafContent::Symlink(t) => assert_eq!(t.as_ref(), OsStr::new("/target")),
1162+
other => panic!("Expected Symlink, got {other:?}"),
1163+
},
1164+
other => panic!("Expected Leaf, got {other:?}"),
1165+
}
1166+
match mapped.root.lookup(OsStr::new("fifo")) {
1167+
Some(Inode::Leaf(l)) => assert!(matches!(l.content, LeafContent::Fifo)),
1168+
other => panic!("Expected Leaf/Fifo, got {other:?}"),
1169+
}
1170+
match mapped.root.lookup(OsStr::new("sock")) {
1171+
Some(Inode::Leaf(l)) => assert!(matches!(l.content, LeafContent::Socket)),
1172+
other => panic!("Expected Leaf/Socket, got {other:?}"),
1173+
}
1174+
match mapped.root.lookup(OsStr::new("blk")) {
1175+
Some(Inode::Leaf(l)) => match &l.content {
1176+
LeafContent::BlockDevice(rdev) => assert_eq!(*rdev, 0x0801),
1177+
other => panic!("Expected BlockDevice, got {other:?}"),
1178+
},
1179+
other => panic!("Expected Leaf, got {other:?}"),
1180+
}
1181+
match mapped.root.lookup(OsStr::new("chr")) {
1182+
Some(Inode::Leaf(l)) => match &l.content {
1183+
LeafContent::CharacterDevice(rdev) => assert_eq!(*rdev, 0x0501),
1184+
other => panic!("Expected CharacterDevice, got {other:?}"),
1185+
},
1186+
other => panic!("Expected Leaf, got {other:?}"),
1187+
}
1188+
}
1189+
1190+
#[test]
1191+
fn test_try_map_regular_hardlink_sharing() {
1192+
let mut fs = FileSystem::<u32>::new(default_stat());
1193+
let shared_leaf = Rc::new(Leaf {
1194+
stat: stat_with_mtime(10),
1195+
content: LeafContent::Regular(99u32),
1196+
});
1197+
// Insert the same Rc under two names (hardlink)
1198+
fs.root
1199+
.insert(OsStr::new("a"), Inode::Leaf(Rc::clone(&shared_leaf)));
1200+
fs.root
1201+
.insert(OsStr::new("b"), Inode::Leaf(Rc::clone(&shared_leaf)));
1202+
1203+
// Track how many times the mapping function is called
1204+
let call_count = RefCell::new(0u32);
1205+
let mapped = fs
1206+
.try_map_regular(|v: &u32| {
1207+
*call_count.borrow_mut() += 1;
1208+
Ok::<String, std::fmt::Error>(format!("mapped={v}"))
1209+
})
1210+
.unwrap();
1211+
1212+
// The mapping function should be called exactly once for the shared leaf
1213+
assert_eq!(*call_count.borrow(), 1);
1214+
1215+
// Both entries should point to the same Rc
1216+
let rc_a = match mapped.root.lookup(OsStr::new("a")) {
1217+
Some(Inode::Leaf(l)) => Rc::clone(l),
1218+
other => panic!("Expected Leaf, got {other:?}"),
1219+
};
1220+
let rc_b = match mapped.root.lookup(OsStr::new("b")) {
1221+
Some(Inode::Leaf(l)) => Rc::clone(l),
1222+
other => panic!("Expected Leaf, got {other:?}"),
1223+
};
1224+
assert!(Rc::ptr_eq(&rc_a, &rc_b));
1225+
assert_eq!(mapped.root.get_file(OsStr::new("a")).unwrap(), "mapped=99");
1226+
}
1227+
1228+
#[test]
1229+
fn test_try_map_regular_error_propagation() {
1230+
let mut fs = FileSystem::<u32>::new(default_stat());
1231+
fs.root.insert(
1232+
OsStr::new("ok"),
1233+
Inode::Leaf(Rc::new(Leaf {
1234+
stat: stat_with_mtime(1),
1235+
content: LeafContent::Regular(1u32),
1236+
})),
1237+
);
1238+
fs.root.insert(
1239+
OsStr::new("fail"),
1240+
Inode::Leaf(Rc::new(Leaf {
1241+
stat: stat_with_mtime(2),
1242+
content: LeafContent::Regular(0u32),
1243+
})),
1244+
);
1245+
1246+
let result = fs.try_map_regular(|v: &u32| {
1247+
if *v == 0 {
1248+
Err("cannot map zero")
1249+
} else {
1250+
Ok(v * 10)
1251+
}
1252+
});
1253+
1254+
assert!(result.is_err());
1255+
assert_eq!(result.unwrap_err(), "cannot map zero");
1256+
}
1257+
10241258
#[test]
10251259
fn test_transform_for_oci() {
10261260
let mut fs = FileSystem::<FileContents>::new(default_stat());

0 commit comments

Comments
 (0)