Skip to main content

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}