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};
10
11use monty_types::{MontyObject, OsFunctionCall};
12
13use super::{
14    common::MountContext, dispatch, error::MountError, mount_mode::MountMode, path_security::normalize_virtual_path,
15};
16
17/// Default aggregate memory budget for one mount: 100 MB in decimal bytes.
18pub const DEFAULT_MEMORY_USAGE_LIMIT: u64 = 100_000_000;
19
20/// Outcome of [`MountTable::handle_os_call`].
21///
22/// The call is consumed so write payloads can be moved into overlay storage;
23/// when no mount covers it, ownership is handed back so the caller can
24/// surface the call to its fallback handler (host callback, `on_no_handler`).
25#[derive(Debug)]
26pub enum MountCallOutcome {
27    /// A mount covered the call and serviced it (successfully or not).
28    Handled(Result<MontyObject, MountError>),
29    /// Non-filesystem op or no matching mount — the call, returned unchanged.
30    NotHandled(OsFunctionCall),
31}
32
33/// A collection of mount points mapping virtual paths to host directories.
34///
35/// Mounts are checked in longest-prefix-first order so that more specific
36/// mounts take precedence.
37#[derive(Debug, Default)]
38pub struct MountTable {
39    /// Sorted by `virtual_path` length descending (longest first).
40    mounts: Vec<Mount>,
41}
42
43impl MountTable {
44    /// Creates a new empty mount table.
45    #[must_use]
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Adds a mount point mapping a virtual path to a host directory.
51    ///
52    /// The host path is canonicalized at mount time so that all subsequent
53    /// boundary checks compare canonical-to-canonical. Mount memory uses
54    /// [`DEFAULT_MEMORY_USAGE_LIMIT`] unless a pre-built [`Mount`] overrides it.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`MountError::InvalidMount`] if the virtual path is not absolute,
59    /// or the host path doesn't exist or isn't a directory.
60    pub fn mount(
61        &mut self,
62        virtual_path: &str,
63        host_path: impl AsRef<Path>,
64        mode: MountMode,
65        write_bytes_limit: Option<u64>,
66    ) -> Result<(), MountError> {
67        let mount = Mount::new(virtual_path, host_path, mode, write_bytes_limit)?;
68        self.push_mount(mount);
69        Ok(())
70    }
71
72    /// Adds a pre-built [`Mount`] to the table.
73    ///
74    /// Use this when a mount was validated before the table was assembled.
75    pub fn push_mount(&mut self, mount: Mount) {
76        // Keep mounts sorted longest-prefix-first so dispatch can stop at the
77        // first match without re-sorting the whole table on every insertion.
78        let insert_at = self
79            .mounts
80            .partition_point(|existing| existing.virtual_path.len() > mount.virtual_path.len());
81        self.mounts.insert(insert_at, mount);
82    }
83
84    /// Handles an OS call using the mount table.
85    ///
86    /// Consumes the call so a covered write's payload is *moved* into the
87    /// backend (overlay storage retains it without a copy). Routing happens
88    /// on a borrow first, so [`MountCallOutcome::NotHandled`] hands the call
89    /// back untouched for the caller's fallback handler (a host callback or
90    /// [`OsFunctionCall::on_no_handler`]).
91    pub fn handle_os_call(&mut self, call: OsFunctionCall) -> MountCallOutcome {
92        if call.is_filesystem() {
93            match self.route_call(&call) {
94                Some(Ok(index)) => MountCallOutcome::Handled(self.mounts[index].execute(call)),
95                Some(Err(err)) => MountCallOutcome::Handled(Err(err)),
96                None => MountCallOutcome::NotHandled(call),
97            }
98        } else {
99            MountCallOutcome::NotHandled(call)
100        }
101    }
102
103    /// Returns `true` if no mount points are configured.
104    #[must_use]
105    pub fn is_empty(&self) -> bool {
106        self.mounts.is_empty()
107    }
108
109    /// Returns the number of configured mount points.
110    #[must_use]
111    pub fn len(&self) -> usize {
112        self.mounts.len()
113    }
114
115    /// Selects the mount that should handle `call`, routing on borrowed paths
116    /// so the call itself stays intact for [`MountCallOutcome::NotHandled`].
117    ///
118    /// Rename requests require both source and destination to resolve to the
119    /// same longest-prefix mount. Other requests only route on the primary path.
120    fn route_call(&self, call: &OsFunctionCall) -> Option<Result<usize, MountError>> {
121        let primary_path = call.primary_path().expect("filesystem call always has a primary path");
122        let src_mount_index = self.find_mount_index(primary_path)?;
123
124        if let Some(dst_path) = call.rename_destination() {
125            let dst_mount_index = self.find_mount_index(dst_path)?;
126            if src_mount_index != dst_mount_index {
127                return Some(Err(MountError::CrossMountRename {
128                    src: primary_path.to_owned(),
129                    dst: dst_path.to_owned(),
130                }));
131            }
132        }
133
134        Some(Ok(src_mount_index))
135    }
136
137    /// Finds the longest-prefix mount index for `virtual_path`.
138    fn find_mount_index(&self, virtual_path: &str) -> Option<usize> {
139        let normalized = normalize_virtual_path(virtual_path);
140        self.mounts
141            .iter()
142            .position(|mount| path_matches_mount(&normalized, &mount.virtual_path))
143    }
144}
145
146/// A single mount point mapping a virtual path to a host directory.
147///
148/// Owns the [`MountMode`] which includes overlay state for
149/// [`MountMode::OverlayMemory`] mounts. It can be constructed before its table
150/// and transferred into it with [`MountTable::push_mount`].
151#[derive(Debug)]
152pub struct Mount {
153    /// Virtual path prefix (absolute, normalized).
154    virtual_path: String,
155    /// Canonical host directory path (resolved at construction time).
156    host_path: PathBuf,
157    /// Access mode (also owns overlay state for [`MountMode::OverlayMemory`]).
158    mode: MountMode,
159    /// Cumulative bytes written through this mount (monotonically increasing).
160    write_bytes_used: u64,
161    /// Optional cap on cumulative bytes written. When exceeded, writes raise `OSError`.
162    write_bytes_limit: Option<u64>,
163    /// Aggregate budget for retained overlay data and transient results.
164    memory_usage_limit: u64,
165}
166
167impl Mount {
168    /// Creates a new mount point, canonicalizing the host path.
169    /// Mount memory defaults to [`DEFAULT_MEMORY_USAGE_LIMIT`].
170    ///
171    /// # Errors
172    ///
173    /// Returns [`MountError::InvalidMount`] if the virtual path is not absolute,
174    /// or the host path doesn't exist or isn't a directory.
175    pub fn new(
176        virtual_path: &str,
177        host_path: impl AsRef<Path>,
178        mode: MountMode,
179        write_bytes_limit: Option<u64>,
180    ) -> Result<Self, MountError> {
181        let host_path = host_path.as_ref();
182
183        if !virtual_path.starts_with('/') {
184            return Err(MountError::InvalidMount(format!(
185                "virtual path must be absolute, got: '{virtual_path}'"
186            )));
187        }
188
189        let normalized_virtual = normalize_virtual_path(virtual_path);
190
191        let canonical_host = fs::canonicalize(host_path).map_err(|e| {
192            MountError::InvalidMount(format!("cannot canonicalize host path '{}': {e}", host_path.display()))
193        })?;
194
195        if !canonical_host.is_dir() {
196            return Err(MountError::InvalidMount(format!(
197                "host path is not a directory: '{}'",
198                host_path.display()
199            )));
200        }
201
202        Ok(Self {
203            virtual_path: normalized_virtual,
204            host_path: canonical_host,
205            mode,
206            write_bytes_used: 0,
207            write_bytes_limit,
208            memory_usage_limit: DEFAULT_MEMORY_USAGE_LIMIT,
209        })
210    }
211
212    /// Returns the normalized virtual path prefix for this mount.
213    #[must_use]
214    pub fn virtual_path(&self) -> &str {
215        &self.virtual_path
216    }
217
218    /// Returns the canonical host directory path.
219    #[must_use]
220    pub fn host_path(&self) -> &Path {
221        &self.host_path
222    }
223
224    /// Returns the access mode for this mount.
225    #[must_use]
226    pub fn mode(&self) -> &MountMode {
227        &self.mode
228    }
229
230    /// Returns the optional write bytes limit for this mount.
231    #[must_use]
232    pub fn write_bytes_limit(&self) -> Option<u64> {
233        self.write_bytes_limit
234    }
235
236    /// Returns the aggregate mount memory budget.
237    #[must_use]
238    pub fn memory_usage_limit(&self) -> u64 {
239        self.memory_usage_limit
240    }
241
242    /// Overrides the aggregate mount memory budget.
243    #[must_use]
244    pub fn with_memory_usage_limit(mut self, limit: u64) -> Self {
245        self.memory_usage_limit = limit;
246        self
247    }
248
249    /// Returns memory currently retained by this mount's overlay.
250    #[must_use]
251    pub fn memory_usage(&self) -> u64 {
252        match &self.mode {
253            MountMode::OverlayMemory(state) => state.memory_usage(),
254            MountMode::ReadWrite | MountMode::ReadOnly => 0,
255        }
256    }
257
258    /// Returns the cumulative number of bytes written through this mount.
259    #[must_use]
260    pub fn write_bytes_used(&self) -> u64 {
261        self.write_bytes_used
262    }
263
264    /// Executes a filesystem call against this mount, consuming it so write
265    /// payloads move into the backend.
266    fn execute(&mut self, call: OsFunctionCall) -> Result<MontyObject, MountError> {
267        let mut ctx = MountContext {
268            mount_virtual: &self.virtual_path,
269            mount_host: &self.host_path,
270            write_bytes_used: &mut self.write_bytes_used,
271            write_bytes_limit: self.write_bytes_limit,
272            memory_usage_limit: self.memory_usage_limit,
273        };
274        dispatch::execute(dispatch::fs_request_from_call(call), &mut ctx, &mut self.mode)
275    }
276}
277
278/// Checks whether `normalized_path` falls under `mount_virtual_path`.
279fn path_matches_mount(normalized_path: &str, mount_virtual_path: &str) -> bool {
280    if mount_virtual_path == "/" || normalized_path == mount_virtual_path {
281        true
282    } else {
283        normalized_path.starts_with(mount_virtual_path)
284            && normalized_path.as_bytes().get(mount_virtual_path.len()) == Some(&b'/')
285    }
286}