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// -- CapSandbox --
179
180/// Capability-based sandbox using [`cap_std`].
181///
182/// Eliminates the TOCTOU gap present in [`FsSandbox`] by using OS-level
183/// capability-based file access (`openat2` / `RESOLVE_BENEATH` on Linux,
184/// equivalent mechanisms on other platforms).
185///
186/// # Security properties
187///
188/// - **No TOCTOU gap**: Path resolution and file open happen atomically
189///   within the OS kernel (on supported platforms).
190/// - **Symlink escape prevention**: Handled by the OS, not userspace checks.
191/// - **No `canonicalize()` step**: The directory capability itself defines
192///   the sandbox boundary.
193///
194/// # Symlink behavior
195///
196/// Symlinks that resolve outside the sandbox are always blocked.
197/// Handling of symlinks within the sandbox is platform-dependent
198/// (Linux `RESOLVE_BENEATH` follows them; other platforms may not).
199/// For portable behavior, avoid symlinks inside sandbox directories.
200///
201/// # Behavioral differences from [`FsSandbox`]
202///
203/// | Aspect | `FsSandbox` | `CapSandbox` |
204/// |--------|-------------|--------------|
205/// | Traversal error | `ReadError::Traversal` | `ReadError::Io` (OS-level denial) |
206/// | `resolved_path` | Absolute canonical path | Relative path as given |
207/// | TOCTOU | Vulnerable | Eliminated |
208///
209/// Traversal attempts are blocked by the OS before reaching userspace.
210/// The returned `ReadError::Io` will carry the platform-specific error
211/// (e.g. `EXDEV`, `EACCES`).
212///
213/// # Example
214///
215/// ```rust,no_run
216/// use mlua_pkg::{resolvers::FsResolver, sandbox::CapSandbox};
217///
218/// let sandbox = CapSandbox::new("./scripts")?;
219/// let resolver = FsResolver::with_sandbox(sandbox);
220/// # Ok::<(), mlua_pkg::sandbox::InitError>(())
221/// ```
222///
223/// # Availability
224///
225/// Requires the `sandbox-cap-std` feature:
226///
227/// ```toml
228/// mlua-pkg = { version = "0.1", features = ["sandbox-cap-std"] }
229/// ```
230#[cfg(feature = "sandbox-cap-std")]
231pub struct CapSandbox {
232    dir: cap_std::fs::Dir,
233}
234
235#[cfg(feature = "sandbox-cap-std")]
236impl CapSandbox {
237    /// Open a directory as a capability-based sandbox.
238    ///
239    /// Uses [`cap_std::fs::Dir::open_ambient_dir`] to obtain a directory
240    /// handle. All subsequent reads are confined to this directory by the OS.
241    pub fn new(root: impl Into<PathBuf>) -> Result<Self, InitError> {
242        let raw = root.into();
243        let dir = match cap_std::fs::Dir::open_ambient_dir(&raw, cap_std::ambient_authority()) {
244            Ok(d) => d,
245            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
246                return Err(InitError::RootNotFound { path: raw });
247            }
248            Err(e) => {
249                return Err(InitError::Io {
250                    path: raw,
251                    source: e,
252                });
253            }
254        };
255        Ok(Self { dir })
256    }
257}
258
259#[cfg(feature = "sandbox-cap-std")]
260impl SandboxedFs for CapSandbox {
261    fn read(&self, relative: &Path) -> Result<Option<FileContent>, ReadError> {
262        match self.dir.read_to_string(relative) {
263            Ok(content) => Ok(Some(FileContent {
264                content,
265                resolved_path: relative.to_path_buf(),
266            })),
267            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
268            Err(e) => Err(ReadError::Io {
269                path: relative.to_path_buf(),
270                source: e,
271            }),
272        }
273    }
274}