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}