Skip to main content

mlua_batteries/
path.rs

1//! Path manipulation module.
2//!
3//! Most functions are pure computation with no I/O.
4//! Exception: `absolute` performs filesystem access (symlink resolution
5//! via `canonicalize`) and is subject to the active
6//! [`PathPolicy`](crate::policy::PathPolicy).
7//!
8//! # `absolute` and sandboxed mode
9//!
10//! `path.absolute(p)` internally calls [`std::fs::canonicalize`], which
11//! resolves symlinks and returns an **absolute** path.  This is **not
12//! available** in [`Sandboxed`](crate::policy::Sandboxed) mode.
13//!
14//! ## Why it doesn't work
15//!
16//! [`cap_std::fs::Dir`] *does* have a `canonicalize` method, but it
17//! returns a **relative** path (relative to the `Dir` handle) because
18//! absolute paths break the capability model.  `path.absolute` promises
19//! callers an absolute path, and [`FsAccess`](crate::policy::FsAccess)
20//! does not hold the sandbox root path, so there is no way to convert
21//! `cap_std`'s relative result back to an absolute path.
22//!
23//! Ref: <https://docs.rs/cap-std/4.0.2/cap_std/fs/struct.Dir.html#method.canonicalize>
24//!
25//! ## Workaround for sandboxed environments
26//!
27//! Use the pure-computation functions that require no filesystem access:
28//!
29//! ```lua
30//! -- Check if already absolute
31//! if not path.is_absolute(p) then
32//!     -- Build absolute path from a known base
33//!     p = path.join(base_dir, p)
34//! end
35//! ```
36//!
37//! ## Future: `std::path::absolute` (Rust 1.79+)
38//!
39//! [`std::path::absolute`] (stabilized in Rust 1.79.0) makes a path
40//! absolute **without** filesystem access — it does not resolve symlinks
41//! and works even if the path does not exist.  Migrating to this
42//! function would allow `path.absolute` to work in sandboxed mode,
43//! but changes the semantics (symlinks would no longer be resolved).
44//!
45//! Ref: <https://doc.rust-lang.org/std/path/fn.absolute.html>
46//! Ref: <https://github.com/rust-lang/rust/pull/124335>
47//!
48//! # Encoding — UTF-8 only (by design)
49//!
50//! All path arguments are received as Rust [`String`] (UTF-8).
51//! Non-UTF-8 Lua strings are rejected at the `FromLua` boundary
52//! with `FromLuaConversionError`.  Returned paths use
53//! [`to_string_lossy`](std::path::Path::to_string_lossy),
54//! replacing any non-UTF-8 bytes with U+FFFD.
55//!
56//! Raw byte (`OsStr`) round-tripping is intentionally unsupported.
57//! mlua's `FromLua for String` enforces UTF-8 validation before
58//! handler code runs, so supporting raw bytes would require every
59//! function to accept `mlua::String` + `as_bytes()` and return
60//! via `OsStr::as_bytes()`.  The added complexity is not justified
61//! given the rarity of non-UTF-8 filenames on modern systems.
62//!
63//! Ref: <https://docs.rs/mlua/latest/mlua/struct.String.html>
64//!
65//! ```lua
66//! local path = std.path
67//! local p = path.join("/usr", "local", "bin")
68//! local dir = path.parent("/usr/local/bin/foo")
69//! local name = path.filename("/usr/local/config.toml")
70//! local base = path.stem("/usr/local/config.toml")
71//! local ext = path.ext("/usr/local/config.toml")
72//! ```
73
74use mlua::prelude::*;
75use std::path::{Path, PathBuf};
76
77pub fn module(lua: &Lua) -> LuaResult<LuaTable> {
78    let t = lua.create_table()?;
79
80    t.set(
81        "join",
82        lua.create_function(|_, parts: LuaMultiValue| {
83            let mut path = PathBuf::new();
84            for (i, part) in parts.iter().enumerate() {
85                match part {
86                    LuaValue::String(s) => path.push(&*s.to_str()?),
87                    other => {
88                        return Err(LuaError::external(format!(
89                            "path.join: argument {} must be a string, got {}",
90                            i + 1,
91                            other.type_name()
92                        )));
93                    }
94                }
95            }
96            Ok(path.to_string_lossy().to_string())
97        })?,
98    )?;
99
100    t.set(
101        "parent",
102        lua.create_function(|_, p: String| {
103            Ok(Path::new(&p)
104                .parent()
105                .map(|p| p.to_string_lossy().to_string()))
106        })?,
107    )?;
108
109    t.set(
110        "filename",
111        lua.create_function(|_, p: String| {
112            Ok(Path::new(&p)
113                .file_name()
114                .map(|f| f.to_string_lossy().to_string()))
115        })?,
116    )?;
117
118    t.set(
119        "stem",
120        lua.create_function(|_, p: String| {
121            Ok(Path::new(&p)
122                .file_stem()
123                .map(|f| f.to_string_lossy().to_string()))
124        })?,
125    )?;
126
127    t.set(
128        "ext",
129        lua.create_function(|_, p: String| {
130            Ok(Path::new(&p)
131                .extension()
132                .map(|e| e.to_string_lossy().to_string()))
133        })?,
134    )?;
135
136    // path.absolute(p) — resolve a path to its absolute, canonical form.
137    //
138    // Uses `std::fs::canonicalize` (which resolves symlinks) rather than
139    // `std::path::absolute` (Rust 1.79+, purely lexical).  The symlink
140    // resolution is intentional: callers of `path.absolute` in scripting
141    // contexts typically expect the "real" path on disk (e.g. to compare
142    // two paths that may traverse symlinks).
143    //
144    // Trade-off: requires the path to exist and performs I/O.
145    // `std::path::absolute` would avoid both but changes semantics.
146    // See the module-level doc for a detailed discussion and the
147    // sandboxed-mode workaround.
148    t.set(
149        "absolute",
150        lua.create_function(|lua, p: String| {
151            let access = crate::util::check_path(lua, &p, crate::policy::PathOp::Read)?;
152            access
153                .canonicalize()
154                .map(|p| p.to_string_lossy().to_string())
155                .map_err(|e| {
156                    if e.kind() == std::io::ErrorKind::Unsupported {
157                        LuaError::external(
158                            "path.absolute is not supported in sandboxed mode: \
159                             cap_std canonicalize returns a relative path but \
160                             path.absolute must return an absolute path. \
161                             Use path.is_absolute() + path.join() instead.",
162                        )
163                    } else {
164                        LuaError::external(e)
165                    }
166                })
167        })?,
168    )?;
169
170    t.set(
171        "is_absolute",
172        lua.create_function(|_, p: String| Ok(Path::new(&p).is_absolute()))?,
173    )?;
174
175    Ok(t)
176}
177
178#[cfg(test)]
179mod tests {
180    use mlua::Lua;
181
182    use crate::util::test_eval as eval;
183
184    #[test]
185    fn join_parts() {
186        let s: String = eval(r#"return std.path.join("/usr", "local", "bin")"#);
187        assert_eq!(s, "/usr/local/bin");
188    }
189
190    #[test]
191    fn parent_of_file() {
192        let s: String = eval(r#"return std.path.parent("/usr/local/bin/foo")"#);
193        assert_eq!(s, "/usr/local/bin");
194    }
195
196    #[test]
197    fn filename_extraction() {
198        let s: String = eval(r#"return std.path.filename("/usr/local/config.toml")"#);
199        assert_eq!(s, "config.toml");
200    }
201
202    #[test]
203    fn stem_without_extension() {
204        let s: String = eval(r#"return std.path.stem("/usr/local/config.toml")"#);
205        assert_eq!(s, "config");
206    }
207
208    #[test]
209    fn ext_extraction() {
210        let s: String = eval(r#"return std.path.ext("/usr/local/config.toml")"#);
211        assert_eq!(s, "toml");
212    }
213
214    #[test]
215    fn is_absolute_true() {
216        let b: bool = eval(r#"return std.path.is_absolute("/usr/local")"#);
217        assert!(b);
218    }
219
220    #[test]
221    fn is_absolute_false() {
222        let b: bool = eval(r#"return std.path.is_absolute("relative/path")"#);
223        assert!(!b);
224    }
225
226    #[test]
227    fn parent_of_root_is_nil() {
228        let b: bool = eval(
229            r#"
230            return std.path.parent("/") == nil
231        "#,
232        );
233        assert!(b);
234    }
235
236    #[test]
237    fn absolute_resolves_existing_path() {
238        let s: String = eval(r#"return std.path.absolute("/tmp")"#);
239        assert!(std::path::Path::new(&s).is_absolute());
240    }
241
242    #[test]
243    fn absolute_nonexistent_returns_error() {
244        let lua = Lua::new();
245        crate::register_all(&lua, "std").unwrap();
246        let result: mlua::Result<mlua::Value> = lua
247            .load(r#"return std.path.absolute("/nonexistent_mlua_bat_xyz")"#)
248            .eval();
249        assert!(result.is_err());
250    }
251
252    #[cfg(feature = "sandbox")]
253    #[test]
254    fn absolute_sandboxed_returns_clear_error() {
255        let sandbox = std::env::temp_dir().join("mlua_bat_test_path_sandbox");
256        std::fs::create_dir_all(&sandbox).unwrap();
257        std::fs::write(sandbox.join("file.txt"), "").unwrap();
258
259        let lua = Lua::new();
260        let config = crate::config::Config::builder()
261            .path_policy(crate::policy::Sandboxed::new([&sandbox]).unwrap())
262            .build()
263            .unwrap();
264        crate::register_all_with(&lua, "std", config).unwrap();
265
266        let path_str = sandbox.join("file.txt").to_string_lossy().to_string();
267        let code = format!(r#"return std.path.absolute("{path_str}")"#);
268        let result: mlua::Result<mlua::Value> = lua.load(&code).eval();
269        assert!(result.is_err());
270        let err_msg = result.unwrap_err().to_string();
271        assert!(
272            err_msg.contains("path.absolute is not supported in sandboxed mode"),
273            "error message should mention path.absolute and sandboxed mode, got: {err_msg}"
274        );
275
276        let _ = std::fs::remove_dir_all(&sandbox);
277    }
278
279    #[test]
280    fn join_rejects_non_string_argument() {
281        let lua = Lua::new();
282        crate::register_all(&lua, "std").unwrap();
283        let result: mlua::Result<mlua::Value> = lua
284            .load(r#"return std.path.join("/usr", 42, "bin")"#)
285            .eval();
286        assert!(result.is_err());
287        let err_msg = result.unwrap_err().to_string();
288        assert!(err_msg.contains("must be a string"));
289    }
290
291    #[test]
292    fn join_with_empty_string() {
293        let s: String = eval(r#"return std.path.join("/usr", "", "bin")"#);
294        assert_eq!(s, "/usr/bin");
295    }
296
297    #[test]
298    fn join_single_argument() {
299        let s: String = eval(r#"return std.path.join("foo")"#);
300        assert_eq!(s, "foo");
301    }
302
303    #[test]
304    fn parent_of_single_component() {
305        // "foo" → parent is ""
306        let b: bool = eval(
307            r#"
308            local p = std.path.parent("foo")
309            return p == ""
310        "#,
311        );
312        assert!(b);
313    }
314
315    #[test]
316    fn ext_no_extension_returns_nil() {
317        let b: bool = eval(r#"return std.path.ext("Makefile") == nil"#);
318        assert!(b);
319    }
320
321    #[test]
322    fn filename_of_root_is_nil() {
323        let b: bool = eval(r#"return std.path.filename("/") == nil"#);
324        assert!(b);
325    }
326}