monty_fs/mount_table.rs
1//! Mount table for mapping virtual paths to host directories.
2//!
3//! The [`MountTable`] manages a collection of mount points, each mapping a
4//! virtual path to a real host directory with a specific access mode.
5
6use std::{
7 fs,
8 path::{Path, PathBuf},
9 sync::Arc,
10};
11
12use cap_std::{ambient_authority, fs::Dir};
13use monty_types::{MontyObject, OsFunctionCall};
14
15use super::{
16 common::MountContext,
17 dispatch,
18 error::MountError,
19 mount_mode::MountMode,
20 path_security::{contains_null_byte, normalize_virtual_path, reject_overlong_path},
21};
22
23/// Default aggregate memory budget for one mount: 100 MB in decimal bytes.
24pub const DEFAULT_MEMORY_USAGE_LIMIT: u64 = 100_000_000;
25
26/// Outcome of [`MountTable::handle_os_call`].
27///
28/// The call is consumed so write payloads can be moved into overlay storage;
29/// when no mount covers it, ownership is handed back so the caller can
30/// surface the call to its fallback handler (host callback, `on_no_handler`).
31#[derive(Debug)]
32pub enum MountCallOutcome {
33 /// A mount covered the call and serviced it (successfully or not).
34 Handled(Result<MontyObject, MountError>),
35 /// Non-filesystem op or no matching mount — the call, returned unchanged.
36 NotHandled(OsFunctionCall),
37}
38
39/// A collection of mount points mapping virtual paths to host directories.
40///
41/// Mounts are checked in longest-prefix-first order so that more specific
42/// mounts take precedence.
43#[derive(Debug, Default)]
44pub struct MountTable {
45 /// Sorted by `virtual_path` length descending (longest first).
46 mounts: Vec<Mount>,
47}
48
49impl MountTable {
50 /// Creates a new empty mount table.
51 #[must_use]
52 pub fn new() -> Self {
53 Self::default()
54 }
55
56 /// Adds a mount point mapping a virtual path to a host directory.
57 ///
58 /// The host directory is opened once here, and every later operation runs
59 /// relative to that descriptor — so the mount stays attached to the
60 /// directory that was named, whatever the host does to the path afterwards.
61 /// Mount memory uses [`DEFAULT_MEMORY_USAGE_LIMIT`] unless a pre-built
62 /// [`Mount`] overrides it.
63 ///
64 /// # Errors
65 ///
66 /// Returns [`MountError::InvalidMount`] if the virtual path is not absolute,
67 /// the host path doesn't exist or isn't a directory, or it cannot be opened
68 /// — on macOS/BSD that includes a search-only (`0o111`) directory, which
69 /// Linux accepts because it opens directories with `O_PATH`.
70 pub fn mount(
71 &mut self,
72 virtual_path: &str,
73 host_path: impl AsRef<Path>,
74 mode: MountMode,
75 write_bytes_limit: Option<u64>,
76 ) -> Result<(), MountError> {
77 let mount = Mount::new(virtual_path, host_path, mode, write_bytes_limit)?;
78 self.push_mount(mount);
79 Ok(())
80 }
81
82 /// Adds a pre-built [`Mount`] to the table.
83 ///
84 /// Use this when a mount was validated before the table was assembled.
85 pub fn push_mount(&mut self, mount: Mount) {
86 // Keep mounts sorted longest-prefix-first so dispatch can stop at the
87 // first match without re-sorting the whole table on every insertion.
88 let insert_at = self
89 .mounts
90 .partition_point(|existing| existing.virtual_path().len() > mount.virtual_path().len());
91 self.mounts.insert(insert_at, mount);
92 }
93
94 /// Handles an OS call using the mount table.
95 ///
96 /// Consumes the call so a covered write's payload is *moved* into the
97 /// backend (overlay storage retains it without a copy). Routing happens
98 /// on a borrow first, so [`MountCallOutcome::NotHandled`] hands the call
99 /// back untouched for the caller's fallback handler (a host callback or
100 /// [`OsFunctionCall::on_no_handler`]).
101 ///
102 /// Path length and null bytes are checked before anything else touches the
103 /// path, so both apply whether or not a mount covers it — as in CPython,
104 /// where neither reaches a syscall.
105 pub fn handle_os_call(&mut self, call: OsFunctionCall) -> MountCallOutcome {
106 if let Some(primary_path) = call.fs_primary_path() {
107 // Length first: it is the only check that stays O(1) on a hostile
108 // path, so a null scan must not run ahead of it. A path that is
109 // both reports its length, where CPython reports the null byte.
110 let rejection = reject_overlong_path(primary_path).err().or_else(|| {
111 contains_null_byte(primary_path)
112 .then(|| MountError::EmbeddedNullByte(call.embedded_null_message(false)))
113 });
114 if let Some(e) = rejection {
115 // Both make CPython's predicates answer `False` rather than
116 // raise — `pathlib` swallows `OSError` and `ValueError` alike.
117 MountCallOutcome::Handled(if call.is_existence_check() {
118 Ok(MontyObject::Bool(false))
119 } else {
120 Err(e)
121 })
122 } else {
123 match self.route_call(primary_path, &call) {
124 Some(Ok(index)) => MountCallOutcome::Handled(self.mounts[index].execute(call)),
125 Some(Err(err)) => MountCallOutcome::Handled(Err(err)),
126 None => MountCallOutcome::NotHandled(call),
127 }
128 }
129 } else {
130 MountCallOutcome::NotHandled(call)
131 }
132 }
133
134 /// Returns `true` if no mount points are configured.
135 #[must_use]
136 pub fn is_empty(&self) -> bool {
137 self.mounts.is_empty()
138 }
139
140 /// Returns the number of configured mount points.
141 #[must_use]
142 pub fn len(&self) -> usize {
143 self.mounts.len()
144 }
145
146 /// Selects the mount that should handle `call`, routing on borrowed paths
147 /// so the call itself stays intact for [`MountCallOutcome::NotHandled`].
148 ///
149 /// Rename requests require both source and destination to resolve to the
150 /// same longest-prefix mount. Other requests only route on the primary path.
151 ///
152 /// One side covered and the other not is refused rather than handed on: the
153 /// fallback answers on raw virtual paths, skipping the mount's access mode.
154 fn route_call(&self, primary_path: &str, call: &OsFunctionCall) -> Option<Result<usize, MountError>> {
155 let src_mount_index = self.find_mount_index(primary_path);
156
157 if let Some(dst_path) = call.rename_destination() {
158 // The destination gets the same pre-routing checks the source had
159 // above, so an unusable name is refused even when neither side is
160 // mounted. `in dst` is what tells the two apart to the caller.
161 if let Err(e) = reject_overlong_path(dst_path) {
162 return Some(Err(e));
163 }
164 if contains_null_byte(dst_path) {
165 return Some(Err(MountError::EmbeddedNullByte(call.embedded_null_message(true))));
166 }
167 match (src_mount_index, self.find_mount_index(dst_path)) {
168 // Neither side is ours; the whole call belongs to the fallback.
169 (None, None) => None,
170 (Some(src), Some(dst)) if src == dst => Some(Ok(src)),
171 _ => Some(Err(MountError::CrossMountRename {
172 src: primary_path.to_owned(),
173 dst: dst_path.to_owned(),
174 })),
175 }
176 } else {
177 src_mount_index.map(Ok)
178 }
179 }
180
181 /// Finds the longest-prefix mount index for `virtual_path`.
182 fn find_mount_index(&self, virtual_path: &str) -> Option<usize> {
183 let normalized = normalize_virtual_path(virtual_path);
184 self.mounts
185 .iter()
186 .position(|mount| path_matches_mount(&normalized, mount.virtual_path()))
187 }
188}
189
190/// A single mount point mapping a virtual path to a host directory.
191///
192/// Owns the [`MountMode`] which includes overlay state for
193/// [`MountMode::OverlayMemory`] mounts. It can be constructed before its table
194/// and transferred into it with [`MountTable::push_mount`].
195#[derive(Debug)]
196pub struct Mount {
197 /// The opened directory this mount serves, and the virtual path it answers on.
198 root: MountRoot,
199 /// Access mode (also owns overlay state for [`MountMode::OverlayMemory`]).
200 mode: MountMode,
201 /// Cumulative bytes written through this mount (monotonically increasing).
202 write_bytes_used: u64,
203 /// Optional cap on cumulative bytes written. When exceeded, writes raise `OSError`.
204 write_bytes_limit: Option<u64>,
205 /// Aggregate budget for retained overlay data and transient results.
206 memory_usage_limit: u64,
207}
208
209impl Mount {
210 /// Creates a new mount point, opening a descriptor on the host directory.
211 /// Mount memory defaults to [`DEFAULT_MEMORY_USAGE_LIMIT`].
212 ///
213 /// A host mounting the same directory repeatedly should open a [`MountRoot`]
214 /// once and use [`Mount::with_root`], resolving the name only once.
215 ///
216 /// # Errors
217 ///
218 /// Returns [`MountError::InvalidMount`] if the virtual path is not absolute,
219 /// or the host path cannot be opened as a directory or canonicalized.
220 pub fn new(
221 virtual_path: &str,
222 host_path: impl AsRef<Path>,
223 mode: MountMode,
224 write_bytes_limit: Option<u64>,
225 ) -> Result<Self, MountError> {
226 Ok(Self::with_root(
227 MountRoot::open(virtual_path, host_path)?,
228 mode,
229 write_bytes_limit,
230 ))
231 }
232
233 /// Mounts an already-opened [`MountRoot`], touching no filesystem at all.
234 /// Mount memory defaults to [`DEFAULT_MEMORY_USAGE_LIMIT`].
235 #[must_use]
236 pub fn with_root(root: MountRoot, mode: MountMode, write_bytes_limit: Option<u64>) -> Self {
237 Self {
238 root,
239 mode,
240 write_bytes_used: 0,
241 write_bytes_limit,
242 memory_usage_limit: DEFAULT_MEMORY_USAGE_LIMIT,
243 }
244 }
245
246 /// Returns the opened root, to clone into a later mount of the same directory.
247 #[must_use]
248 pub fn root(&self) -> &MountRoot {
249 &self.root
250 }
251
252 /// Returns the normalized virtual path prefix for this mount.
253 #[must_use]
254 pub fn virtual_path(&self) -> &str {
255 self.root.virtual_path()
256 }
257
258 /// Returns the canonical host directory path. Diagnostics only.
259 #[must_use]
260 pub fn host_path(&self) -> &Path {
261 self.root.host_path()
262 }
263
264 /// Returns the access mode for this mount.
265 #[must_use]
266 pub fn mode(&self) -> &MountMode {
267 &self.mode
268 }
269
270 /// Returns the optional write bytes limit for this mount.
271 #[must_use]
272 pub fn write_bytes_limit(&self) -> Option<u64> {
273 self.write_bytes_limit
274 }
275
276 /// Returns the aggregate mount memory budget.
277 #[must_use]
278 pub fn memory_usage_limit(&self) -> u64 {
279 self.memory_usage_limit
280 }
281
282 /// Overrides the aggregate mount memory budget.
283 #[must_use]
284 pub fn with_memory_usage_limit(mut self, limit: u64) -> Self {
285 self.memory_usage_limit = limit;
286 self
287 }
288
289 /// Returns memory currently retained by this mount's overlay.
290 #[must_use]
291 pub fn memory_usage(&self) -> u64 {
292 match &self.mode {
293 MountMode::OverlayMemory(state) => state.memory_usage(),
294 MountMode::ReadWrite | MountMode::ReadOnly => 0,
295 }
296 }
297
298 /// Returns the cumulative number of bytes written through this mount.
299 #[must_use]
300 pub fn write_bytes_used(&self) -> u64 {
301 self.write_bytes_used
302 }
303
304 /// Executes a filesystem call against this mount, consuming it so write
305 /// payloads move into the backend.
306 fn execute(&mut self, call: OsFunctionCall) -> Result<MontyObject, MountError> {
307 let mut ctx = MountContext {
308 mount_virtual: &self.root.virtual_path,
309 mount_dir: &self.root.dir,
310 write_bytes_used: &mut self.write_bytes_used,
311 write_bytes_limit: self.write_bytes_limit,
312 memory_usage_limit: self.memory_usage_limit,
313 };
314 dispatch::execute(dispatch::fs_request_from_call(call), &mut ctx, &mut self.mode)
315 }
316}
317
318/// A host directory opened once, mountable as often as the host likes; cloning
319/// shares the descriptor.
320///
321/// Reuse one instead of re-deriving a mount from its path: sandbox code that
322/// can rename inside a parent mount redirects that name between rebuilds, and
323/// an open descriptor cannot be redirected.
324#[derive(Debug, Clone)]
325pub struct MountRoot {
326 /// Virtual path prefix (absolute, normalized).
327 virtual_path: String,
328 /// Canonical host directory path. Diagnostics only — see `dir`.
329 host_path: PathBuf,
330 /// Descriptor for the mounted directory — the sandbox boundary, which
331 /// resolution cannot leave. Shared, so every mount built from this root is
332 /// the same directory rather than the same name resolved again.
333 dir: Arc<Dir>,
334}
335
336impl MountRoot {
337 /// Opens `host_path`, pinning the root to the directory that is there now.
338 ///
339 /// # Errors
340 ///
341 /// Returns [`MountError::InvalidMount`] if the virtual path is not absolute,
342 /// or the host path cannot be opened as a directory or canonicalized.
343 pub fn open(virtual_path: &str, host_path: impl AsRef<Path>) -> Result<Self, MountError> {
344 let host_path = host_path.as_ref();
345
346 if !virtual_path.starts_with('/') {
347 return Err(MountError::InvalidMount(format!(
348 "virtual path must be absolute, got: '{virtual_path}'"
349 )));
350 }
351
352 let normalized_virtual = normalize_virtual_path(virtual_path);
353
354 // The only use of ambient authority, and the mount's whole trust root.
355 // Deliberately first: resolving the name to a validated path and *then*
356 // opening that path would let whoever can rename in the parent swap a
357 // symlink into the gap. The directory check rides on the open itself
358 // (`O_DIRECTORY`, a handle `metadata()` on Windows), so it cannot.
359 let dir = Dir::open_ambient_dir(host_path, ambient_authority())
360 .map_err(|e| MountError::InvalidMount(format!("cannot open host path '{}': {e}", host_path.display())))?;
361
362 // Diagnostics only — nothing resolves through this path. Resolved after
363 // the open, so a host racing it leaves a stale label on the right
364 // descriptor, never the reverse. Still fatal on failure: callers copy
365 // this out as a mount's durable identity, and a relative path would
366 // later re-resolve against the process CWD.
367 let canonical_host = fs::canonicalize(host_path).map_err(|e| {
368 MountError::InvalidMount(format!("cannot resolve host path '{}': {e}", host_path.display()))
369 })?;
370
371 Ok(Self {
372 virtual_path: normalized_virtual,
373 host_path: canonical_host,
374 dir: Arc::new(dir),
375 })
376 }
377
378 /// Returns the normalized virtual path prefix this root answers on.
379 #[must_use]
380 pub fn virtual_path(&self) -> &str {
381 &self.virtual_path
382 }
383
384 /// Returns the canonical host directory path. Diagnostics only.
385 #[must_use]
386 pub fn host_path(&self) -> &Path {
387 &self.host_path
388 }
389}
390
391/// Checks whether `normalized_path` falls under `mount_virtual_path`.
392fn path_matches_mount(normalized_path: &str, mount_virtual_path: &str) -> bool {
393 if mount_virtual_path == "/" || normalized_path == mount_virtual_path {
394 true
395 } else {
396 normalized_path.starts_with(mount_virtual_path)
397 && normalized_path.as_bytes().get(mount_virtual_path.len()) == Some(&b'/')
398 }
399}