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}