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 read_to_string(&self) -> io::Result<String> {
170        match &self.0 {
171            FsAccessInner::Direct(p) => std::fs::read_to_string(p),
172            #[cfg(feature = "sandbox")]
173            FsAccessInner::Capped { dir, relative } => dir.read_to_string(relative),
174        }
175    }
176
177    /// Read the file as raw bytes.
178    ///
179    /// Available when the `fs` feature is enabled.
180    #[cfg(feature = "fs")]
181    pub(crate) fn read_bytes(&self) -> io::Result<Vec<u8>> {
182        match &self.0 {
183            FsAccessInner::Direct(p) => std::fs::read(p),
184            #[cfg(feature = "sandbox")]
185            FsAccessInner::Capped { dir, relative } => dir.read(relative),
186        }
187    }
188
189    pub(crate) fn write(&self, content: impl AsRef<[u8]>) -> io::Result<()> {
190        match &self.0 {
191            FsAccessInner::Direct(p) => std::fs::write(p, content),
192            #[cfg(feature = "sandbox")]
193            FsAccessInner::Capped { dir, relative } => dir.write(relative, content),
194        }
195    }
196
197    #[cfg(any(feature = "fs", test))]
198    pub(crate) fn exists(&self) -> bool {
199        match &self.0 {
200            FsAccessInner::Direct(p) => p.exists(),
201            #[cfg(feature = "sandbox")]
202            FsAccessInner::Capped { dir, relative } => dir.exists(relative),
203        }
204    }
205
206    #[cfg(any(feature = "fs", test))]
207    pub(crate) fn is_dir(&self) -> bool {
208        match &self.0 {
209            FsAccessInner::Direct(p) => p.is_dir(),
210            #[cfg(feature = "sandbox")]
211            FsAccessInner::Capped { dir, relative } => {
212                dir.metadata(relative).map(|m| m.is_dir()).unwrap_or(false)
213            }
214        }
215    }
216
217    /// Check if the path is a regular file.
218    ///
219    /// Available when the `fs` feature is enabled.
220    #[cfg(feature = "fs")]
221    pub(crate) fn is_file(&self) -> bool {
222        match &self.0 {
223            FsAccessInner::Direct(p) => p.is_file(),
224            #[cfg(feature = "sandbox")]
225            FsAccessInner::Capped { dir, relative } => {
226                dir.metadata(relative).map(|m| m.is_file()).unwrap_or(false)
227            }
228        }
229    }
230
231    /// Create the directory and all parent directories.
232    ///
233    /// Available when the `fs` feature is enabled.
234    #[cfg(feature = "fs")]
235    pub(crate) fn create_dir_all(&self) -> io::Result<()> {
236        match &self.0 {
237            FsAccessInner::Direct(p) => std::fs::create_dir_all(p),
238            #[cfg(feature = "sandbox")]
239            FsAccessInner::Capped { dir, relative } => dir.create_dir_all(relative),
240        }
241    }
242
243    #[cfg(any(feature = "fs", test))]
244    /// Remove a file or directory.
245    ///
246    /// Tries `remove_file` first. On failure, falls back to `remove_dir_all`
247    /// only when the error indicates the target is a directory:
248    ///
249    /// - Linux: `unlink()` returns `EISDIR` → `ErrorKind::IsADirectory`
250    /// - macOS/BSD: `unlink()` returns `EPERM` → `ErrorKind::PermissionDenied`
251    ///
252    /// On macOS, `PermissionDenied` is ambiguous (could be a genuine
253    /// permission error on a file).  If `remove_dir_all` also fails
254    /// (e.g. target was a file, not a directory), the **original**
255    /// `remove_file` error is returned so diagnostics remain accurate.
256    /// All other error kinds are propagated immediately.
257    pub(crate) fn remove(&self) -> io::Result<()> {
258        match &self.0 {
259            FsAccessInner::Direct(p) => match std::fs::remove_file(p) {
260                Ok(()) => Ok(()),
261                Err(e) if is_unlink_dir_error(&e) => std::fs::remove_dir_all(p).map_err(|_| e),
262                Err(e) => Err(e),
263            },
264            #[cfg(feature = "sandbox")]
265            FsAccessInner::Capped { dir, relative } => match dir.remove_file(relative) {
266                Ok(()) => Ok(()),
267                Err(e) if is_unlink_dir_error(&e) => dir.remove_dir_all(relative).map_err(|_| e),
268                Err(e) => Err(e),
269            },
270        }
271    }
272
273    /// Open the file for buffered reading.
274    ///
275    /// Available when the `fs` or `hash` feature is enabled.
276    #[cfg(any(feature = "fs", feature = "hash"))]
277    pub(crate) fn open_read(&self) -> io::Result<Box<dyn Read>> {
278        match &self.0 {
279            FsAccessInner::Direct(p) => {
280                let f = std::fs::File::open(p)?;
281                Ok(Box::new(io::BufReader::new(f)))
282            }
283            #[cfg(feature = "sandbox")]
284            FsAccessInner::Capped { dir, relative } => {
285                let f = dir.open(relative)?;
286                Ok(Box::new(io::BufReader::new(f)))
287            }
288        }
289    }
290
291    pub(crate) fn canonicalize(&self) -> io::Result<PathBuf> {
292        match &self.0 {
293            FsAccessInner::Direct(p) => p.canonicalize(),
294            #[cfg(feature = "sandbox")]
295            FsAccessInner::Capped { .. } => Err(io::Error::new(
296                io::ErrorKind::Unsupported,
297                "canonicalize is not available in sandboxed mode",
298            )),
299        }
300    }
301
302    /// Walk this path recursively, collecting file paths that pass `filter`.
303    ///
304    /// `display_prefix` is the user-visible path prefix to prepend
305    /// (e.g. the original dir_path the user passed to `fs.walk`).
306    #[cfg(feature = "fs")]
307    pub(crate) fn walk_files_filtered(
308        &self,
309        display_prefix: &Path,
310        filter: &dyn Fn(&str) -> bool,
311        max_depth: usize,
312        max_entries: usize,
313    ) -> io::Result<Vec<String>> {
314        let mut results = Vec::new();
315        match &self.0 {
316            FsAccessInner::Direct(p) => {
317                for entry in walkdir::WalkDir::new(p).max_depth(max_depth) {
318                    match entry {
319                        Ok(e) if e.file_type().is_file() => {
320                            let path_str = e.path().to_string_lossy();
321                            if filter(&path_str) {
322                                if results.len() >= max_entries {
323                                    return Err(io::Error::other(format!(
324                                        "entry limit exceeded ({max_entries})"
325                                    )));
326                                }
327                                results.push(path_str.into_owned());
328                            }
329                        }
330                        Ok(_) => {}
331                        Err(e) => return Err(e.into()),
332                    }
333                }
334            }
335            #[cfg(feature = "sandbox")]
336            FsAccessInner::Capped { dir, relative } => {
337                let walk_root = dir.open_dir(relative)?;
338                sandbox::walk_capped_filtered(
339                    &walk_root,
340                    display_prefix,
341                    filter,
342                    0,
343                    max_depth,
344                    max_entries,
345                    &mut results,
346                )?;
347            }
348        }
349        Ok(results)
350    }
351
352    /// Walk this path recursively, collecting all file paths.
353    #[cfg(feature = "fs")]
354    pub(crate) fn walk_files(
355        &self,
356        display_prefix: &Path,
357        max_depth: usize,
358        max_entries: usize,
359    ) -> io::Result<Vec<String>> {
360        self.walk_files_filtered(display_prefix, &|_| true, max_depth, max_entries)
361    }
362
363    /// Copy this file's contents to `dst`.
364    ///
365    /// Available when the `fs` feature is enabled.
366    #[cfg(feature = "fs")]
367    pub(crate) fn copy_to(&self, dst: &FsAccess) -> io::Result<u64> {
368        match (&self.0, &dst.0) {
369            (FsAccessInner::Direct(src), FsAccessInner::Direct(d)) => std::fs::copy(src, d),
370            #[cfg(feature = "sandbox")]
371            _ => {
372                let content = self.read_bytes()?;
373                // content.len() fits in u64 (Vec max is isize::MAX < u64::MAX).
374                let len = content.len() as u64;
375                dst.write(&content)?;
376                Ok(len)
377            }
378        }
379    }
380
381    #[cfg(test)]
382    pub(crate) fn display(&self) -> String {
383        match &self.0 {
384            FsAccessInner::Direct(p) => p.to_string_lossy().to_string(),
385            #[cfg(feature = "sandbox")]
386            FsAccessInner::Capped { relative, .. } => relative.to_string_lossy().to_string(),
387        }
388    }
389}
390
391/// Check if a `remove_file` error indicates the target *may* be a directory.
392///
393/// Platform behaviour of `unlink()` on a directory:
394/// - Linux: `EISDIR` → `ErrorKind::IsADirectory`
395/// - macOS / BSD: `EPERM` → `ErrorKind::PermissionDenied`
396///   (POSIX specifies `EPERM` for directory unlink)
397///
398/// On macOS, `PermissionDenied` is ambiguous: it could be a genuine
399/// permission error on a file.  Callers handle this by attempting
400/// `remove_dir_all` and falling back to the original error on failure.
401#[cfg(any(feature = "fs", test))]
402fn is_unlink_dir_error(e: &io::Error) -> bool {
403    matches!(
404        e.kind(),
405        io::ErrorKind::IsADirectory | io::ErrorKind::PermissionDenied
406    )
407}
408
409// ─── PathPolicy trait ────────────────────────────
410
411/// Policy that decides whether a given path may be accessed.
412///
413/// Every filesystem-touching function in `mlua-batteries` calls
414/// [`PathPolicy::resolve`] before performing I/O.
415pub trait PathPolicy: Send + Sync + 'static {
416    /// Human-readable name for this policy, used in `Debug` output.
417    ///
418    /// The default implementation returns [`std::any::type_name`] of the
419    /// concrete type, which works correctly even through trait objects
420    /// because the vtable dispatches to the concrete implementation.
421    fn policy_name(&self) -> &'static str {
422        std::any::type_name::<Self>()
423    }
424
425    /// Validate `path` for `op` and return an [`FsAccess`] handle.
426    ///
427    /// Return `Ok(handle)` to allow, `Err(reason)` to deny.
428    fn resolve(&self, path: &Path, op: PathOp) -> Result<FsAccess, PolicyError>;
429}
430
431// ─── Unrestricted ────────────────────────────
432
433/// No restrictions — every path is allowed as-is.
434///
435/// This is the default policy used by [`crate::register_all`].
436///
437/// # Warning
438///
439/// With this policy, Lua scripts can read, write, and delete **any** file
440/// accessible to the process.  Do **not** use this policy with untrusted
441/// scripts.  Use [`Sandboxed`] instead.
442#[derive(Debug)]
443pub struct Unrestricted;
444
445impl PathPolicy for Unrestricted {
446    fn resolve(&self, path: &Path, _op: PathOp) -> Result<FsAccess, PolicyError> {
447        Ok(FsAccess::direct(path))
448    }
449}
450
451#[cfg(test)]
452mod tests;