Skip to main content

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;