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