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;