Skip to main content

mlua_pkg/
sandbox.rs

1//! Sandboxed file I/O abstraction and implementation.
2//!
3//! The [`SandboxedFs`] trait defines the I/O interface, and
4//! [`FsSandbox`] provides the real filesystem implementation.
5//!
6//! During testing, inject a mock implementation for I/O-free verification.
7//!
8//! # Design
9//!
10//! ```text
11//! FsResolver / AssetResolver
12//!       |
13//!       v
14//! Box<dyn SandboxedFs>   <- Dependency inversion. Implementation is swappable
15//!       |
16//!   +---+---+
17//!   |       |
18//! FsSandbox  CapSandbox (cap-std)  MockSandbox (for testing)
19//! ```
20//!
21//! Rationale for using `Box<dyn SandboxedFs>` (dynamic dispatch):
22//! - [`Resolver`](crate::Resolver) itself uses `Vec<Box<dyn Resolver>>` with dynamic dispatch
23//! - Making it generic would ultimately be converted to a trait object anyway, providing no benefit
24//! - vtable overhead (~ns) is negligible compared to I/O (~us to ~ms)
25//!
26//! # Error type separation
27//!
28//! Construction-time and read-time errors are separated by type:
29//! - [`InitError`] -- returned from [`FsSandbox::new()`]. Root directory validation errors.
30//! - [`ReadError`] -- returned from [`SandboxedFs::read()`]. Individual file access errors.
31//!
32//! Rationale: construction failure is a configuration error (should be fixed at startup),
33//! while read failure is a runtime error (fallback or retry may be possible).
34//! This separation lets callers choose the appropriate recovery strategy.
35//!
36//! # NotFound representation
37//!
38//! File not found is returned as `Ok(None)` (not `Err`).
39//! [`SandboxedFs::read()`] is a "search" operation where absence is a normal result.
40//! This fits naturally with [`FsResolver`](crate::resolvers::FsResolver)'s candidate chain
41//! (`{name}.lua` -> `{name}/init.lua`).
42
43use std::path::{Path, PathBuf};
44
45/// File read result.
46pub struct FileContent {
47    /// File content (UTF-8 text).
48    pub content: String,
49    /// Canonicalized real path. Used as source name in error messages.
50    pub resolved_path: PathBuf,
51}
52
53/// Error during sandbox construction.
54///
55/// Returned from [`FsSandbox::new()`].
56/// Contains only errors related to root directory validation.
57#[derive(Debug, thiserror::Error)]
58pub enum InitError {
59    /// Root directory does not exist.
60    #[error("root directory not found: {}", path.display())]
61    RootNotFound { path: PathBuf },
62
63    /// I/O error on root directory (e.g. permission denied).
64    #[error("I/O error on {}: {source}", path.display())]
65    Io {
66        path: PathBuf,
67        source: std::io::Error,
68    },
69}
70
71/// Error during file read.
72///
73/// Returned from [`SandboxedFs::read()`].
74/// Contains only errors related to individual file access.
75#[derive(Debug, thiserror::Error)]
76pub enum ReadError {
77    /// Access outside the sandbox boundary detected.
78    #[error("path traversal detected: {}", attempted.display())]
79    Traversal { attempted: PathBuf },
80
81    /// File I/O error (e.g. permission denied, reading a directory).
82    ///
83    /// `NotFound` is not included here (represented as `Ok(None)`).
84    #[error("I/O error on {}: {source}", path.display())]
85    Io {
86        path: PathBuf,
87        source: std::io::Error,
88    },
89}
90
91/// Interface for sandboxed file reading.
92///
93/// An I/O abstraction. Swap the implementation for test mocks or
94/// alternative backends (in-memory FS, embedded assets, etc.).
95pub trait SandboxedFs: Send + Sync {
96    /// Read a file by relative path.
97    ///
98    /// - `Ok(Some(file))`: Read succeeded
99    /// - `Ok(None)`: File does not exist
100    /// - `Err(Traversal)`: Access outside sandbox boundary
101    /// - `Err(Io)`: I/O error (e.g. permission denied)
102    fn read(&self, relative: &Path) -> Result<Option<FileContent>, ReadError>;
103}
104
105/// Real filesystem-based sandbox implementation.
106///
107/// Canonicalizes the root at construction time and performs traversal
108/// validation on every read.
109///
110/// # Security boundary
111///
112/// This sandbox provides **casual escape prevention for trusted directories**,
113/// not a security guarantee for adversarial environments.
114///
115/// ## Known limitations
116///
117/// - **TOCTOU**: Vulnerable to symlink swap attacks between `canonicalize()`
118///   and `read_to_string()`. For adversarial inputs, use [`CapSandbox`]
119///   (requires the `sandbox-cap-std` feature) which eliminates the gap via
120///   OS-level capability-based file access.
121///
122/// - **Windows device names**: No defense against reserved device names like
123///   `NUL`, `CON`, `PRN`, etc. Risk of DoS/hang on Windows.
124pub struct FsSandbox {
125    root: PathBuf,
126}
127
128impl FsSandbox {
129    pub fn new(root: impl Into<PathBuf>) -> Result<Self, InitError> {
130        let raw = root.into();
131        let canonical = match raw.canonicalize() {
132            Ok(p) => p,
133            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
134                return Err(InitError::RootNotFound { path: raw });
135            }
136            Err(e) => {
137                return Err(InitError::Io {
138                    path: raw,
139                    source: e,
140                });
141            }
142        };
143        Ok(Self { root: canonical })
144    }
145}
146
147impl SandboxedFs for FsSandbox {
148    fn read(&self, relative: &Path) -> Result<Option<FileContent>, ReadError> {
149        let path = self.root.join(relative);
150        let canonical = match path.canonicalize() {
151            Ok(p) => p,
152            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
153            Err(e) => {
154                return Err(ReadError::Io { path, source: e });
155            }
156        };
157
158        if !canonical.starts_with(&self.root) {
159            return Err(ReadError::Traversal {
160                attempted: canonical,
161            });
162        }
163
164        match std::fs::read_to_string(&canonical) {
165            Ok(content) => Ok(Some(FileContent {
166                content,
167                resolved_path: canonical,
168            })),
169            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
170            Err(e) => Err(ReadError::Io {
171                path: canonical,
172                source: e,
173            }),
174        }
175    }
176}
177
178// -- SymlinkAwareSandbox --
179
180/// Sandbox that follows symlinks in the root directory.
181///
182/// Like [`FsSandbox`], but also allows access to targets of symlinks
183/// found directly under the root. This is designed for package managers
184/// (e.g. `npm link` / `alc_pkg_link`) where the root directory contains
185/// symlinks pointing to external source directories.
186///
187/// # How it works
188///
189/// At construction time, scans the root for symlink entries and records
190/// their canonical targets as additional allowed roots. During `read()`,
191/// a file is permitted if its canonical path is under the root **or**
192/// under any of the recorded symlink targets.
193///
194/// # Security boundary
195///
196/// Same as [`FsSandbox`]: casual escape prevention for trusted directories.
197/// The allowed target set is fixed at construction time; symlinks added
198/// after construction are not recognized until a new instance is created.
199pub struct SymlinkAwareSandbox {
200    root: PathBuf,
201    /// Canonical paths of symlink targets found under root at construction time.
202    allowed_targets: Vec<PathBuf>,
203}
204
205impl SymlinkAwareSandbox {
206    pub fn new(root: impl Into<PathBuf>) -> Result<Self, InitError> {
207        let raw = root.into();
208        let canonical = match raw.canonicalize() {
209            Ok(p) => p,
210            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
211                return Err(InitError::RootNotFound { path: raw });
212            }
213            Err(e) => {
214                return Err(InitError::Io {
215                    path: raw,
216                    source: e,
217                });
218            }
219        };
220
221        let mut allowed_targets = Vec::new();
222        if let Ok(entries) = std::fs::read_dir(&canonical) {
223            for entry in entries.flatten() {
224                let meta = match entry.path().symlink_metadata() {
225                    Ok(m) => m,
226                    Err(_) => continue,
227                };
228                if meta.file_type().is_symlink() {
229                    if let Ok(target) = entry.path().canonicalize() {
230                        allowed_targets.push(target);
231                    }
232                }
233            }
234        }
235
236        Ok(Self {
237            root: canonical,
238            allowed_targets,
239        })
240    }
241}
242
243impl SandboxedFs for SymlinkAwareSandbox {
244    fn read(&self, relative: &Path) -> Result<Option<FileContent>, ReadError> {
245        let path = self.root.join(relative);
246        let canonical = match path.canonicalize() {
247            Ok(p) => p,
248            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
249            Err(e) => {
250                return Err(ReadError::Io { path, source: e });
251            }
252        };
253
254        // Allow if under root (normal case, no symlinks involved)
255        if canonical.starts_with(&self.root) {
256            return read_file(&canonical);
257        }
258
259        // Allow if under any registered symlink target
260        for target in &self.allowed_targets {
261            if canonical.starts_with(target) {
262                return read_file(&canonical);
263            }
264        }
265
266        Err(ReadError::Traversal {
267            attempted: canonical,
268        })
269    }
270}
271
272/// Shared file reading logic.
273fn read_file(canonical: &Path) -> Result<Option<FileContent>, ReadError> {
274    match std::fs::read_to_string(canonical) {
275        Ok(content) => Ok(Some(FileContent {
276            content,
277            resolved_path: canonical.to_path_buf(),
278        })),
279        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
280        Err(e) => Err(ReadError::Io {
281            path: canonical.to_path_buf(),
282            source: e,
283        }),
284    }
285}
286
287// -- CapSandbox --
288
289/// Capability-based sandbox using [`cap_std`].
290///
291/// Eliminates the TOCTOU gap present in [`FsSandbox`] by using OS-level
292/// capability-based file access (`openat2` / `RESOLVE_BENEATH` on Linux,
293/// equivalent mechanisms on other platforms).
294///
295/// # Security properties
296///
297/// - **No TOCTOU gap**: Path resolution and file open happen atomically
298///   within the OS kernel (on supported platforms).
299/// - **Symlink escape prevention**: Handled by the OS, not userspace checks.
300/// - **No `canonicalize()` step**: The directory capability itself defines
301///   the sandbox boundary.
302///
303/// # Symlink behavior
304///
305/// Symlinks that resolve outside the sandbox are always blocked.
306/// Handling of symlinks within the sandbox is platform-dependent
307/// (Linux `RESOLVE_BENEATH` follows them; other platforms may not).
308/// For portable behavior, avoid symlinks inside sandbox directories.
309///
310/// # Behavioral differences from [`FsSandbox`]
311///
312/// | Aspect | `FsSandbox` | `CapSandbox` |
313/// |--------|-------------|--------------|
314/// | Traversal error | `ReadError::Traversal` | `ReadError::Io` (OS-level denial) |
315/// | `resolved_path` | Absolute canonical path | Relative path as given |
316/// | TOCTOU | Vulnerable | Eliminated |
317///
318/// Traversal attempts are blocked by the OS before reaching userspace.
319/// The returned `ReadError::Io` will carry the platform-specific error
320/// (e.g. `EXDEV`, `EACCES`).
321///
322/// # Example
323///
324/// ```rust,no_run
325/// use mlua_pkg::{resolvers::FsResolver, sandbox::CapSandbox};
326///
327/// let sandbox = CapSandbox::new("./scripts")?;
328/// let resolver = FsResolver::with_sandbox(sandbox);
329/// # Ok::<(), mlua_pkg::sandbox::InitError>(())
330/// ```
331///
332/// # Availability
333///
334/// Requires the `sandbox-cap-std` feature:
335///
336/// ```toml
337/// mlua-pkg = { version = "0.1", features = ["sandbox-cap-std"] }
338/// ```
339#[cfg(feature = "sandbox-cap-std")]
340pub struct CapSandbox {
341    dir: cap_std::fs::Dir,
342}
343
344#[cfg(feature = "sandbox-cap-std")]
345impl CapSandbox {
346    /// Open a directory as a capability-based sandbox.
347    ///
348    /// Uses [`cap_std::fs::Dir::open_ambient_dir`] to obtain a directory
349    /// handle. All subsequent reads are confined to this directory by the OS.
350    pub fn new(root: impl Into<PathBuf>) -> Result<Self, InitError> {
351        let raw = root.into();
352        let dir = match cap_std::fs::Dir::open_ambient_dir(&raw, cap_std::ambient_authority()) {
353            Ok(d) => d,
354            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
355                return Err(InitError::RootNotFound { path: raw });
356            }
357            Err(e) => {
358                return Err(InitError::Io {
359                    path: raw,
360                    source: e,
361                });
362            }
363        };
364        Ok(Self { dir })
365    }
366}
367
368#[cfg(feature = "sandbox-cap-std")]
369impl SandboxedFs for CapSandbox {
370    fn read(&self, relative: &Path) -> Result<Option<FileContent>, ReadError> {
371        match self.dir.read_to_string(relative) {
372            Ok(content) => Ok(Some(FileContent {
373                content,
374                resolved_path: relative.to_path_buf(),
375            })),
376            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
377            Err(e) => Err(ReadError::Io {
378                path: relative.to_path_buf(),
379                source: e,
380            }),
381        }
382    }
383}