mlua_batteries/policy/mod.rs
1//! Path access policy for sandboxing filesystem operations.
2//!
3//! Implement [`PathPolicy`] to control which paths Lua scripts can access.
4//!
5//! # Built-in policies
6//!
7//! | Policy | Behaviour |
8//! |--------|-----------|
9//! | [`Unrestricted`] | No checks (default) |
10//! | [`Sandboxed`] | Capability-based sandbox via [`cap_std`] |
11//!
12//! # Security architecture
13//!
14//! The sandbox uses a **two-layer** design:
15//!
16//! 1. **Routing layer** (`normalize_for_matching`) — best-effort path
17//! resolution to select the correct `Dir` handle. This layer resolves
18//! platform symlinks (e.g. `/tmp` → `/private/tmp` on macOS) but is
19//! **not** the security boundary.
20//!
21//! 2. **Enforcement layer** ([`cap_std`]) — all actual I/O goes through
22//! `cap_std::fs::Dir`, which uses `openat2` + `RESOLVE_BENEATH` on
23//! Linux 5.6+ and manual per-component resolution on other platforms.
24//! This prevents symlink escapes, `..` traversal, and absolute-path
25//! breakout at the OS level.
26//!
27//! ## TOCTOU note
28//!
29//! There is an inherent window between `normalize_for_matching` (which
30//! may call `canonicalize()`) and the subsequent `cap_std` I/O. A
31//! symlink replaced in that window cannot escape the sandbox because
32//! `cap_std` re-validates the path at I/O time, but it may cause
33//! unexpected errors or access a different file within the same sandbox.
34//!
35//! ## Encoding — UTF-8 only (by design)
36//!
37//! All path arguments are received as Rust [`String`] (UTF-8).
38//! Non-UTF-8 Lua strings are rejected at the `FromLua` boundary.
39//! Returned paths use [`to_string_lossy`](std::path::Path::to_string_lossy),
40//! replacing any non-UTF-8 bytes with U+FFFD.
41//!
42//! Raw byte (`OsStr`) round-tripping is intentionally unsupported —
43//! see crate-level docs for rationale.
44//! Ref: <https://docs.rs/mlua/latest/mlua/struct.String.html>
45
46mod env_policy;
47mod http;
48mod llm_policy;
49#[cfg(feature = "sandbox")]
50mod sandbox;
51
52pub use env_policy::*;
53pub use http::*;
54pub use llm_policy::*;
55#[cfg(feature = "sandbox")]
56pub use sandbox::Sandboxed;
57
58use std::io;
59#[cfg(any(feature = "fs", feature = "hash"))]
60use std::io::Read;
61use std::path::{Path, PathBuf};
62#[cfg(feature = "sandbox")]
63use std::sync::Arc;
64
65/// Filesystem operation kind.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum PathOp {
68 Read,
69 Write,
70 Delete,
71 List,
72}
73
74impl std::fmt::Display for PathOp {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 match self {
77 PathOp::Read => f.write_str("read"),
78 PathOp::Write => f.write_str("write"),
79 PathOp::Delete => f.write_str("delete"),
80 PathOp::List => f.write_str("list"),
81 }
82 }
83}
84
85// ─── PolicyError ─────────────────────────
86
87/// Error type returned by policy `check` / `resolve` methods.
88///
89/// Wraps a human-readable denial reason. Implements [`std::error::Error`]
90/// so it composes naturally with `mlua::LuaError::external`.
91#[derive(Debug, Clone)]
92pub struct PolicyError(String);
93
94impl PolicyError {
95 /// Create a new policy error from a message.
96 pub fn new(message: impl Into<String>) -> Self {
97 Self(message.into())
98 }
99
100 /// The denial reason.
101 pub fn message(&self) -> &str {
102 &self.0
103 }
104}
105
106impl std::fmt::Display for PolicyError {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.write_str(&self.0)
109 }
110}
111
112impl std::error::Error for PolicyError {}
113
114impl From<String> for PolicyError {
115 fn from(s: String) -> Self {
116 Self(s)
117 }
118}
119
120impl From<&str> for PolicyError {
121 fn from(s: &str) -> Self {
122 Self(s.to_string())
123 }
124}
125
126// ─── FsAccess ────────────────────────────
127
128/// Opaque handle to a policy-resolved filesystem path.
129///
130/// Returned by [`PathPolicy::resolve`]. All I/O MUST go through
131/// the methods on this type — never convert back to a raw path and
132/// call `std::fs` directly.
133///
134/// For custom [`PathPolicy`] implementations, construct with
135/// [`FsAccess::direct`].
136pub struct FsAccess(pub(crate) FsAccessInner);
137
138impl std::fmt::Debug for FsAccess {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 match &self.0 {
141 FsAccessInner::Direct(p) => f.debug_tuple("FsAccess::Direct").field(p).finish(),
142 #[cfg(feature = "sandbox")]
143 FsAccessInner::Capped { relative, .. } => {
144 f.debug_tuple("FsAccess::Capped").field(relative).finish()
145 }
146 }
147 }
148}
149
150pub(crate) enum FsAccessInner {
151 /// No sandbox — delegates to `std::fs`.
152 Direct(PathBuf),
153 /// Capability-based sandbox via `cap_std::fs::Dir`.
154 #[cfg(feature = "sandbox")]
155 Capped {
156 dir: Arc<cap_std::fs::Dir>,
157 relative: PathBuf,
158 },
159}
160
161impl FsAccess {
162 /// Create a direct (unsandboxed) filesystem access handle.
163 pub fn direct(path: impl Into<PathBuf>) -> Self {
164 Self(FsAccessInner::Direct(path.into()))
165 }
166
167 // ── I/O operations (crate-internal) ──────────────
168
169 pub(crate) fn file_size(&self) -> io::Result<u64> {
170 match &self.0 {
171 FsAccessInner::Direct(p) => Ok(std::fs::metadata(p)?.len()),
172 #[cfg(feature = "sandbox")]
173 FsAccessInner::Capped { dir, relative } => Ok(dir.metadata(relative)?.len()),
174 }
175 }
176
177 pub(crate) fn read_to_string(&self) -> io::Result<String> {
178 match &self.0 {
179 FsAccessInner::Direct(p) => std::fs::read_to_string(p),
180 #[cfg(feature = "sandbox")]
181 FsAccessInner::Capped { dir, relative } => dir.read_to_string(relative),
182 }
183 }
184
185 /// Read the file as raw bytes.
186 ///
187 /// Available when the `fs` feature is enabled.
188 #[cfg(feature = "fs")]
189 pub(crate) fn read_bytes(&self) -> io::Result<Vec<u8>> {
190 match &self.0 {
191 FsAccessInner::Direct(p) => std::fs::read(p),
192 #[cfg(feature = "sandbox")]
193 FsAccessInner::Capped { dir, relative } => dir.read(relative),
194 }
195 }
196
197 pub(crate) fn write(&self, content: impl AsRef<[u8]>) -> io::Result<()> {
198 match &self.0 {
199 FsAccessInner::Direct(p) => std::fs::write(p, content),
200 #[cfg(feature = "sandbox")]
201 FsAccessInner::Capped { dir, relative } => dir.write(relative, content),
202 }
203 }
204
205 #[cfg(any(feature = "fs", test))]
206 pub(crate) fn exists(&self) -> bool {
207 match &self.0 {
208 FsAccessInner::Direct(p) => p.exists(),
209 #[cfg(feature = "sandbox")]
210 FsAccessInner::Capped { dir, relative } => dir.exists(relative),
211 }
212 }
213
214 #[cfg(any(feature = "fs", test))]
215 pub(crate) fn is_dir(&self) -> bool {
216 match &self.0 {
217 FsAccessInner::Direct(p) => p.is_dir(),
218 #[cfg(feature = "sandbox")]
219 FsAccessInner::Capped { dir, relative } => {
220 dir.metadata(relative).map(|m| m.is_dir()).unwrap_or(false)
221 }
222 }
223 }
224
225 /// Check if the path is a regular file.
226 ///
227 /// Available when the `fs` feature is enabled.
228 #[cfg(feature = "fs")]
229 pub(crate) fn is_file(&self) -> bool {
230 match &self.0 {
231 FsAccessInner::Direct(p) => p.is_file(),
232 #[cfg(feature = "sandbox")]
233 FsAccessInner::Capped { dir, relative } => {
234 dir.metadata(relative).map(|m| m.is_file()).unwrap_or(false)
235 }
236 }
237 }
238
239 /// Create the directory and all parent directories.
240 ///
241 /// Available when the `fs` feature is enabled.
242 #[cfg(feature = "fs")]
243 pub(crate) fn create_dir_all(&self) -> io::Result<()> {
244 match &self.0 {
245 FsAccessInner::Direct(p) => std::fs::create_dir_all(p),
246 #[cfg(feature = "sandbox")]
247 FsAccessInner::Capped { dir, relative } => dir.create_dir_all(relative),
248 }
249 }
250
251 #[cfg(any(feature = "fs", test))]
252 /// Remove a file or directory.
253 ///
254 /// Tries `remove_file` first. On failure, falls back to `remove_dir_all`
255 /// only when the error indicates the target is a directory:
256 ///
257 /// - Linux: `unlink()` returns `EISDIR` → `ErrorKind::IsADirectory`
258 /// - macOS/BSD: `unlink()` returns `EPERM` → `ErrorKind::PermissionDenied`
259 ///
260 /// On macOS, `PermissionDenied` is ambiguous (could be a genuine
261 /// permission error on a file). If `remove_dir_all` also fails
262 /// (e.g. target was a file, not a directory), the **original**
263 /// `remove_file` error is returned so diagnostics remain accurate.
264 /// All other error kinds are propagated immediately.
265 pub(crate) fn remove(&self) -> io::Result<()> {
266 match &self.0 {
267 FsAccessInner::Direct(p) => match std::fs::remove_file(p) {
268 Ok(()) => Ok(()),
269 Err(e) if is_unlink_dir_error(&e) => std::fs::remove_dir_all(p).map_err(|_| e),
270 Err(e) => Err(e),
271 },
272 #[cfg(feature = "sandbox")]
273 FsAccessInner::Capped { dir, relative } => match dir.remove_file(relative) {
274 Ok(()) => Ok(()),
275 Err(e) if is_unlink_dir_error(&e) => dir.remove_dir_all(relative).map_err(|_| e),
276 Err(e) => Err(e),
277 },
278 }
279 }
280
281 /// Open the file for buffered reading.
282 ///
283 /// Available when the `fs` or `hash` feature is enabled.
284 #[cfg(any(feature = "fs", feature = "hash"))]
285 pub(crate) fn open_read(&self) -> io::Result<Box<dyn Read>> {
286 match &self.0 {
287 FsAccessInner::Direct(p) => {
288 let f = std::fs::File::open(p)?;
289 Ok(Box::new(io::BufReader::new(f)))
290 }
291 #[cfg(feature = "sandbox")]
292 FsAccessInner::Capped { dir, relative } => {
293 let f = dir.open(relative)?;
294 Ok(Box::new(io::BufReader::new(f)))
295 }
296 }
297 }
298
299 pub(crate) fn canonicalize(&self) -> io::Result<PathBuf> {
300 match &self.0 {
301 FsAccessInner::Direct(p) => p.canonicalize(),
302 #[cfg(feature = "sandbox")]
303 FsAccessInner::Capped { .. } => Err(io::Error::new(
304 io::ErrorKind::Unsupported,
305 "canonicalize is not available in sandboxed mode",
306 )),
307 }
308 }
309
310 /// Walk this path recursively, collecting file paths that pass `filter`.
311 ///
312 /// `display_prefix` is the user-visible path prefix to prepend
313 /// (e.g. the original dir_path the user passed to `fs.walk`).
314 #[cfg(feature = "fs")]
315 pub(crate) fn walk_files_filtered(
316 &self,
317 display_prefix: &Path,
318 filter: &dyn Fn(&str) -> bool,
319 max_depth: usize,
320 max_entries: usize,
321 ) -> io::Result<Vec<String>> {
322 let mut results = Vec::new();
323 match &self.0 {
324 FsAccessInner::Direct(p) => {
325 for entry in walkdir::WalkDir::new(p).max_depth(max_depth) {
326 match entry {
327 Ok(e) if e.file_type().is_file() => {
328 let path_str = e.path().to_string_lossy();
329 if filter(&path_str) {
330 if results.len() >= max_entries {
331 return Err(io::Error::other(format!(
332 "entry limit exceeded ({max_entries})"
333 )));
334 }
335 results.push(path_str.into_owned());
336 }
337 }
338 Ok(_) => {}
339 Err(e) => return Err(e.into()),
340 }
341 }
342 }
343 #[cfg(feature = "sandbox")]
344 FsAccessInner::Capped { dir, relative } => {
345 let walk_root = dir.open_dir(relative)?;
346 sandbox::walk_capped_filtered(
347 &walk_root,
348 display_prefix,
349 filter,
350 0,
351 max_depth,
352 max_entries,
353 &mut results,
354 )?;
355 }
356 }
357 Ok(results)
358 }
359
360 /// Walk this path recursively, collecting all file paths.
361 #[cfg(feature = "fs")]
362 pub(crate) fn walk_files(
363 &self,
364 display_prefix: &Path,
365 max_depth: usize,
366 max_entries: usize,
367 ) -> io::Result<Vec<String>> {
368 self.walk_files_filtered(display_prefix, &|_| true, max_depth, max_entries)
369 }
370
371 /// Copy this file's contents to `dst`.
372 ///
373 /// Available when the `fs` feature is enabled.
374 #[cfg(feature = "fs")]
375 pub(crate) fn copy_to(&self, dst: &FsAccess) -> io::Result<u64> {
376 match (&self.0, &dst.0) {
377 (FsAccessInner::Direct(src), FsAccessInner::Direct(d)) => std::fs::copy(src, d),
378 #[cfg(feature = "sandbox")]
379 _ => {
380 let content = self.read_bytes()?;
381 // content.len() fits in u64 (Vec max is isize::MAX < u64::MAX).
382 let len = content.len() as u64;
383 dst.write(&content)?;
384 Ok(len)
385 }
386 }
387 }
388
389 #[cfg(test)]
390 pub(crate) fn display(&self) -> String {
391 match &self.0 {
392 FsAccessInner::Direct(p) => p.to_string_lossy().to_string(),
393 #[cfg(feature = "sandbox")]
394 FsAccessInner::Capped { relative, .. } => relative.to_string_lossy().to_string(),
395 }
396 }
397}
398
399/// Check if a `remove_file` error indicates the target *may* be a directory.
400///
401/// Platform behaviour of `unlink()` on a directory:
402/// - Linux: `EISDIR` → `ErrorKind::IsADirectory`
403/// - macOS / BSD: `EPERM` → `ErrorKind::PermissionDenied`
404/// (POSIX specifies `EPERM` for directory unlink)
405///
406/// On macOS, `PermissionDenied` is ambiguous: it could be a genuine
407/// permission error on a file. Callers handle this by attempting
408/// `remove_dir_all` and falling back to the original error on failure.
409#[cfg(any(feature = "fs", test))]
410fn is_unlink_dir_error(e: &io::Error) -> bool {
411 matches!(
412 e.kind(),
413 io::ErrorKind::IsADirectory | io::ErrorKind::PermissionDenied
414 )
415}
416
417// ─── PathPolicy trait ────────────────────────────
418
419/// Policy that decides whether a given path may be accessed.
420///
421/// Every filesystem-touching function in `mlua-batteries` calls
422/// [`PathPolicy::resolve`] before performing I/O.
423pub trait PathPolicy: Send + Sync + 'static {
424 /// Human-readable name for this policy, used in `Debug` output.
425 ///
426 /// The default implementation returns [`std::any::type_name`] of the
427 /// concrete type, which works correctly even through trait objects
428 /// because the vtable dispatches to the concrete implementation.
429 fn policy_name(&self) -> &'static str {
430 std::any::type_name::<Self>()
431 }
432
433 /// Validate `path` for `op` and return an [`FsAccess`] handle.
434 ///
435 /// Return `Ok(handle)` to allow, `Err(reason)` to deny.
436 fn resolve(&self, path: &Path, op: PathOp) -> Result<FsAccess, PolicyError>;
437}
438
439// ─── Unrestricted ────────────────────────────
440
441/// No restrictions — every path is allowed as-is.
442///
443/// This is the default policy used by [`crate::register_all`].
444///
445/// # Warning
446///
447/// With this policy, Lua scripts can read, write, and delete **any** file
448/// accessible to the process. Do **not** use this policy with untrusted
449/// scripts. Use [`Sandboxed`] instead.
450#[derive(Debug)]
451pub struct Unrestricted;
452
453impl PathPolicy for Unrestricted {
454 fn resolve(&self, path: &Path, _op: PathOp) -> Result<FsAccess, PolicyError> {
455 Ok(FsAccess::direct(path))
456 }
457}
458
459#[cfg(test)]
460mod tests;