From 154f0b97bf643b6c5fd79c15868f3ba08023d9be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Mon, 6 Jul 2026 23:54:41 +0200 Subject: [PATCH] add prototype permission based fs access --- Cargo.lock | 2 + Cargo.toml | 6 + README.md | 3 +- docs/README.md | 1 + docs/fs.md | 157 ++++++++++++ docs/http.md | 6 + docs/sqlite.md | 5 + lua/stubs/fs.lua | 57 +++++ lua/stubs/sqlite.lua | 3 +- src/lua_tests/fs_tests.rs | 412 ++++++++++++++++++++++++++++++ src/lua_tests/mod.rs | 1 + src/main.rs | 47 +++- src/stdlib/fs/mod.rs | 294 +++++++++++++++++++++ src/stdlib/fs/policy.rs | 519 ++++++++++++++++++++++++++++++++++++++ src/stdlib/fs/prompt.rs | 105 ++++++++ src/stdlib/fs/store.rs | 321 +++++++++++++++++++++++ src/stdlib/http.rs | 52 +++- src/stdlib/mod.rs | 4 + src/stdlib/require.rs | 1 + src/stdlib/sqlite.rs | 79 +++++- src/stdlib/tui/mod.rs | 5 + src/stdlib/tui/prompts.rs | 5 +- 22 files changed, 2069 insertions(+), 16 deletions(-) create mode 100644 docs/fs.md create mode 100644 lua/stubs/fs.lua create mode 100644 src/lua_tests/fs_tests.rs create mode 100644 src/stdlib/fs/mod.rs create mode 100644 src/stdlib/fs/policy.rs create mode 100644 src/stdlib/fs/prompt.rs create mode 100644 src/stdlib/fs/store.rs diff --git a/Cargo.lock b/Cargo.lock index aee6592..9d3f993 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,10 +20,12 @@ dependencies = [ "mlua", "reqwest", "rusqlite", + "rustix", "rustls", "rustyline", "serde", "serde_json", + "tempfile", "thiserror", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 781c74b..29ccc24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,9 +32,15 @@ mlua = { version = "0.11.6", features = ["lua55", "vendored", "serde", "async", reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "charset", "http2", "system-proxy"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } rusqlite = { version = "0.32", features = ["bundled"] } +# flock() guarding the fs-permission store (access.json) against concurrently +# running scripts; already in the tree as a transitive dependency. +rustix = { version = "1", features = ["fs"] } rustyline = "14" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["full"] } digest_auth = "0.3" + +[dev-dependencies] +tempfile = "3" diff --git a/README.md b/README.md index 1fc786e..ba430e5 100644 --- a/README.md +++ b/README.md @@ -42,12 +42,13 @@ The standard library is the main purpose of `a`. Available today: - **Networking** — HTTP client (WebSocket and MQTT are planned). - **SQLite** — access to embedded SQLite databases. +- **Filesystem** — whole-file read/write and directory listing via [`fs`](docs/fs.md), guarded by a per-script folder permission system (interactive grants recorded in `access.json`; `--allow-fs`, `--no-fs` and `--sandbox` override it). - **Terminal UI** — interactive prompts (confirm, text, select, password, number, date), styled/colored output, and terminal control (cursor addressing, alternate screen, scroll regions) via [`tui`](docs/tui.md). - **Core helpers** — `math`, `table`, and `utils` extensions, structured `log`, `os` time helpers, and `task.join` concurrency. Planned: -- **Filesystem** — read/write/glob/walk without stock Lua's `io` boilerplate. +- **Filesystem ergonomics** — glob/walk on top of the existing `fs` core. - **TUI widgets & input** — progress bars, tables, and raw key/mouse events on top of the existing terminal control. The intent is for the common case to be a single call rather than a multi-step setup. diff --git a/docs/README.md b/docs/README.md index 6f5d1dc..db64582 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ also works and returns the same table). | [`log`](log.md) | Level-tagged diagnostic logging (`trace`…`error`), gated by `RUST_LOG`. | | [`sqlite`](sqlite.md) | Embedded SQLite: connect, query, parameters, transactions. | | [`http`](http.md) | HTTP/HTTPS client: requests, JSON, auth, cookie-jar sessions. | +| [`fs`](fs.md) | Whole-file read/write and directory listing, behind a per-script folder permission system. | | [`tui`](tui.md) | Interactive terminal prompts (confirm/text/select/…), ANSI-styled output, message blocks. | ## Concepts diff --git a/docs/fs.md b/docs/fs.md new file mode 100644 index 0000000..8d8691d --- /dev/null +++ b/docs/fs.md @@ -0,0 +1,157 @@ +# `fs` + +Whole-file filesystem access, guarded by a per-script folder permission +system. The sandbox loads no stock `io`; this module is the supported way for +a script to touch files, and every operation first passes a permission check +(see [Permissions](#permissions) below). + +The v1 surface is deliberately small — whole files in and out, directory +listing, two predicates, and an explicit permission probe: + +```lua +local text = fs.readAll("/home/me/notes.txt") +fs.writeAll("out/report.md", text .. "\n-- processed") + +for _, name in ipairs(fs.list(".")) do print(name) end + +if fs.isFile("config.json") then ... end +if fs.access("/var/data", "w") then ... end +``` + +## Functions + +### `fs.readAll(path) -> string` + +Reads the whole file into a string. Lua strings are byte strings, so binary +content round-trips exactly. Errors if `path` is not a regular file, the OS +refuses, or the permission system denies **read** on the file's directory. + +### `fs.writeAll(path, content)` + +Writes the whole file: created if missing, truncated if it exists. The +containing directory must already exist (there is no mkdir-p), and a dangling +symlink as the target is refused. Errors if the file can't be created or the +permission system denies **write** on the containing directory. + +### `fs.list(path) -> string[]` + +The names (not full paths) of a directory's entries, sorted. `.` and `..` are +not included. Errors if `path` is not a directory or **read** on it is +denied. + +### `fs.isDir(path) -> boolean`, `fs.isFile(path) -> boolean` + +`true` iff the path exists, has the right type, *and* is readable under the +permission system. These never error: a nonexistent path is `false` without +any permission prompt; an unknown permission prompts like any other read (a +denial makes the result `false`). + +### `fs.getWorkdir() -> string` + +The process's current working directory, as an absolute path. Relative paths +given to the other fs functions resolve against it, so it is the natural base +for building paths to probe with `fs.access`: + +```lua +local cwd = fs.getWorkdir() +if fs.access(cwd, "w") then + fs.writeAll("report.txt", text) -- relative = cwd .. "/report.txt" +end +``` + +Not permission-gated — it reports process state and touches no file content. + +### `fs.access(dir, mode) -> boolean` + +Runs the permission cycle for a directory explicitly — `mode` is `"r"` or +`"w"`: + +1. a recorded grant/denial answers immediately; +2. otherwise the interactive prompt asks; +3. with no terminal to ask on, returns `false` **without** recording a + denial (a persisted "no" is always an explicit user decision). + +Use it to ask for permission up front at a natural moment instead of +mid-operation. Errors if `dir` is not an existing directory. + +## Permissions + +Permissions attach to **directories**, not files, and are recorded per +script: + +- The permission unit for `readAll`/`writeAll`/`isFile` is the file's + directory; for `list`/`isDir`/`access` it is the directory itself. Paths + are canonicalized first (symlinks resolved, relative paths against the + CWD), so two spellings of the same place share one grant. +- Grants are **recursive**: an allow or deny on a directory covers + everything below it, and the *deepest* recorded ancestor wins — deny + `~/secrets` and it stays denied even though `~` is allowed. +- Read and write are tracked separately; each is approved, denied, or not + decided yet. + +When an operation needs a decision that isn't recorded, `a` asks on the +terminal (stderr, so it shows even with stdout piped) — one keypress, no +Enter: + +``` +a: permission request + script: /home/me/bin/deploy.lua + wants: read access to directory /home/me/project +[y] allow once [n] deny once [A] allow always [N] never allow: +``` + +- **`y` / `n`** apply for the rest of this run only. +- **`A` / `N`** are recorded in `access.json` and answer silently from then + on. (A lowercase `a` deliberately does nothing — a slip of the finger must + not persist anything.) +- **Esc / Ctrl-C** count as `n`. +- No terminal (piped stdin, cron): the operation is denied for this run and + *nothing* is recorded. + +### `access.json` + +Recorded answers live in `$XDG_CONFIG_HOME/a/access.json` (usually +`~/.config/a/access.json`), keyed by the script's canonical path — moving or +renaming a script starts it with a clean slate. The file is plain JSON and +safe to hand-edit; each flag is `true` (approved), `false` (denied), or +`null` (not decided yet): + +```json +{ + "version": 1, + "scripts": { + "/home/me/bin/deploy.lua": { + "/home/me/project": { "read": true, "write": true }, + "/etc": { "read": true, "write": false } + } + } +} +``` + +Concurrent `a` processes update it safely (the write cycle holds a file +lock and replaces the file atomically). + +The **REPL** prompts the same way, but its answers — including `A`/`N` — +last only for the session; an interactive session has no script identity to +record them under. + +### `sqlite` and `http` respect the same system + +- `sqlite.connect(path)` asks one combined **read/write** question for the + database's directory (SQLite keeps WAL/journal files next to it). + Memory-class databases — `":memory:"`, `""` and their `file:` URI + spellings — touch no file and are always allowed, even under `--no-fs` / + `--sandbox`. +- A session's cookie-jar files (`http.session(path)`, `session:load`, + `session:save`) are read/write checked like any other file. + +## CLI flags + +| Flag | Effect | +|------|--------| +| `--allow-fs` | Skip the permission system: everything granted, nothing asked or recorded. | +| `--no-fs` | Deny all filesystem access (fs module, on-disk sqlite databases, cookie-jar files). | +| `--sandbox` | `--no-fs`, plus networking is cut (`http` errors); overrides `--allow-fs`. | + +Denials from a flag read `fs.readAll: filesystem access disabled by --no-fs`, +interactive/recorded ones `fs.readAll: access to /some/dir denied`. diff --git a/docs/http.md b/docs/http.md index 47b0b09..9c6b75c 100644 --- a/docs/http.md +++ b/docs/http.md @@ -249,6 +249,12 @@ Empty the in-memory jar. Cookies live in memory for the session's lifetime. Save them to reuse a logged-in session across script runs. +Jar files hold live credentials, so both directions run the +[fs permission system](fs.md#permissions) — write for `s:save`, read for +`s:load` and `http.session(path)`. Note also that `--sandbox` disables +networking entirely: every request errors with +`http: networking disabled by --sandbox`. + #### `s:save(path)` Write the jar to `path` as JSONL — one JSON object per line, one cookie per line: diff --git a/docs/sqlite.md b/docs/sqlite.md index 7442ffb..eae4c48 100644 --- a/docs/sqlite.md +++ b/docs/sqlite.md @@ -26,6 +26,11 @@ local con = sqlite.connect(path) string `":memory:"` for a private in-memory database that vanishes when closed. The returned connection is an object you call methods on with `:` syntax. +Opening a file-backed database runs the [fs permission system](fs.md#permissions): +one combined read/write question for the database's directory (SQLite keeps its +WAL/journal files next to the file). In-memory databases touch no file and are +always allowed, even under `--no-fs` / `--sandbox`. + The connection closes automatically when it is garbage-collected. Call [`con:close()`](#conclose) to release it eagerly. diff --git a/lua/stubs/fs.lua b/lua/stubs/fs.lua new file mode 100644 index 0000000..00ea9c2 --- /dev/null +++ b/lua/stubs/fs.lua @@ -0,0 +1,57 @@ +---@meta +---@diagnostic disable: missing-return +-- Type stubs for the `fs` module (fully native). Reference: docs/fs.md +-- +-- Every operation runs under the folder permission system: recorded grants +-- (per script, per directory, recursive) answer silently; unknown ones ask on +-- the terminal — y/n for this run, A/N recorded in access.json. Denials and +-- OS-level failures raise Lua errors; trap with pcall/utils.try where needed. +-- `--allow-fs`, `--no-fs` and `--sandbox` override the whole system. + +fs = {} + +---Read a whole file into a string (byte-exact; no encoding assumed). +---Errors if the path is not a regular file or not accessible. +---@param path string +---@return string content +function fs.readAll(path) end + +---Write a whole file from a string: created if missing, truncated if not. +---The containing directory must already exist. Errors if the file can't be +---created or is not accessible. +---@param path string +---@param content string +function fs.writeAll(path, content) end + +---Names of a directory's entries (files and subdirectories), sorted. +---Errors if the path is not a directory or not accessible. +---@param path string +---@return string[] names +function fs.list(path) end + +---true iff the path exists, is a directory, and is readable under the +---permission system. Never errors; a nonexistent path is false without any +---permission prompt. +---@param path string +---@return boolean +function fs.isDir(path) end + +---true iff the path exists, is a regular file, and is readable under the +---permission system. Never errors; a nonexistent path is false without any +---permission prompt. +---@param path string +---@return boolean +function fs.isFile(path) end + +---The process's current working directory (absolute path) — what relative +---paths in the other fs functions resolve against. Not permission-gated. +---@return string dir +function fs.getWorkdir() end + +---Run the permission cycle for a directory explicitly: recorded answer, else +---interactive prompt, else false (nothing recorded when there is no terminal +---to ask on). Errors if the path is not an existing directory. +---@param dir string +---@param mode "r"|"w" +---@return boolean granted +function fs.access(dir, mode) end diff --git a/lua/stubs/sqlite.lua b/lua/stubs/sqlite.lua index 02b6da2..8e06830 100644 --- a/lua/stubs/sqlite.lua +++ b/lua/stubs/sqlite.lua @@ -65,7 +65,8 @@ function Connection:rollback() end function Connection:transaction(fn) end ---Open a database file (created if missing), or ":memory:" for a private ----in-memory database. +---in-memory database. A file-backed database runs the fs permission system +---(one combined read/write ask for its directory); ":memory:" never does. ---@param path string ---@return sqlite.Connection function sqlite.connect(path) end diff --git a/src/lua_tests/fs_tests.rs b/src/lua_tests/fs_tests.rs new file mode 100644 index 0000000..5b4ab1a --- /dev/null +++ b/src/lua_tests/fs_tests.rs @@ -0,0 +1,412 @@ +//! Tests for the `fs` module and its permission system, exercised through +//! Lua with policies injected via app data (the same channel `main` uses). +//! +//! No test here can ever reach a real terminal prompt or the real +//! access.json: in test builds the prompt path is compiled out (scripted +//! answers only) and the default policy carries no script identity. + +use std::sync::Arc; + +use super::lua; +use crate::stdlib::fs::{store, Choice, FsPolicy}; + +/// The sandboxed state with an --allow-fs policy. +fn lua_allow_all() -> mlua::Lua { + let lua = lua(); + lua.set_app_data(Arc::new(FsPolicy::allow_all())); + lua +} + +fn lua_with(policy: FsPolicy) -> mlua::Lua { + let lua = lua(); + lua.set_app_data(Arc::new(policy)); + lua +} + +async fn lua_err(lua: &mlua::Lua, src: &str) -> String { + lua.load(src).exec_async().await.unwrap_err().to_string() +} + +#[tokio::test] +async fn write_read_round_trip_and_truncation() { + let lua = lua_allow_all(); + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("data.bin"); + + // Non-UTF-8 bytes must survive; a rewrite must truncate, not append. + let got: mlua::String = lua + .load(format!( + r#" + fs.writeAll('{p}', "long original content \255\254\0tail") + fs.writeAll('{p}', "short\255") + return fs.readAll('{p}') + "#, + p = file.display() + )) + .eval_async() + .await + .unwrap(); + assert_eq!(&*got.as_bytes(), b"short\xff"); +} + +#[tokio::test] +async fn write_all_creates_missing_file_but_not_missing_parent() { + let lua = lua_allow_all(); + let dir = tempfile::tempdir().unwrap(); + + let fresh = dir.path().join("new.txt"); + lua.load(format!("fs.writeAll('{}', 'x')", fresh.display())) + .exec_async() + .await + .unwrap(); + assert_eq!(std::fs::read_to_string(&fresh).unwrap(), "x"); + + let orphan = dir.path().join("no/such/dir/f.txt"); + let err = lua_err(&lua, &format!("fs.writeAll('{}', 'x')", orphan.display())).await; + assert!(err.contains("fs.writeAll") && err.contains("parent directory"), "{err}"); +} + +#[tokio::test] +async fn type_mismatches_error() { + let lua = lua_allow_all(); + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("f.txt"); + std::fs::write(&file, "x").unwrap(); + + let err = lua_err(&lua, &format!("fs.readAll('{}')", dir.path().display())).await; + assert!(err.contains("fs.readAll") && err.contains("not a regular file"), "{err}"); + + let err = lua_err(&lua, &format!("fs.writeAll('{}', 'x')", dir.path().display())).await; + assert!(err.contains("fs.writeAll") && err.contains("is a directory"), "{err}"); + + let err = lua_err(&lua, &format!("fs.list('{}')", file.display())).await; + assert!(err.contains("fs.list") && err.contains("not a directory"), "{err}"); + + let err = lua_err(&lua, "fs.readAll('/no/such/file/anywhere')").await; + assert!(err.contains("fs.readAll"), "{err}"); +} + +#[tokio::test] +async fn list_returns_sorted_names() { + let lua = lua_allow_all(); + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("b.txt"), "").unwrap(); + std::fs::write(dir.path().join("a.txt"), "").unwrap(); + std::fs::create_dir(dir.path().join("sub")).unwrap(); + + let joined: String = lua + .load(format!( + "return table.concat(fs.list('{}'), ',')", + dir.path().display() + )) + .eval_async() + .await + .unwrap(); + assert_eq!(joined, "a.txt,b.txt,sub"); +} + +#[tokio::test] +async fn predicates_check_existence_type_and_access() { + let lua = lua_allow_all(); + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("f.txt"); + std::fs::write(&file, "x").unwrap(); + + let (dir_is_dir, dir_is_file, file_is_dir, file_is_file, ghost_dir, ghost_file): ( + bool, + bool, + bool, + bool, + bool, + bool, + ) = lua + .load(format!( + r#" + return fs.isDir('{d}'), fs.isFile('{d}'), + fs.isDir('{f}'), fs.isFile('{f}'), + fs.isDir('/no/such/path'), fs.isFile('/no/such/path') + "#, + d = dir.path().display(), + f = file.display() + )) + .eval_async() + .await + .unwrap(); + assert!(dir_is_dir && !dir_is_file && !file_is_dir && file_is_file); + assert!(!ghost_dir && !ghost_file); +} + +#[tokio::test] +async fn access_validates_arguments() { + let lua = lua_allow_all(); + let dir = tempfile::tempdir().unwrap(); + + let ok: bool = lua + .load(format!("return fs.access('{}', 'r')", dir.path().display())) + .eval_async() + .await + .unwrap(); + assert!(ok); + + let err = lua_err(&lua, &format!("fs.access('{}', 'rw')", dir.path().display())).await; + assert!(err.contains("fs.access") && err.contains("mode"), "{err}"); + + let err = lua_err(&lua, "fs.access('/no/such/dir', 'r')").await; + assert!(err.contains("fs.access"), "{err}"); +} + +#[tokio::test] +async fn get_workdir_returns_the_cwd_ungated() { + // Works even under the strictest policy: it reveals process state, not + // filesystem content. + let lua = lua_with(FsPolicy::deny_all("--sandbox", false)); + let cwd: String = lua.load("return fs.getWorkdir()").eval_async().await.unwrap(); + assert_eq!(std::path::PathBuf::from(&cwd), std::env::current_dir().unwrap()); + assert!(std::path::Path::new(&cwd).is_absolute()); +} + +#[tokio::test] +async fn deny_all_blocks_everything_without_prompting() { + let lua = lua_with(FsPolicy::deny_all("--no-fs", true)); + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("f.txt"); + std::fs::write(&file, "secret").unwrap(); + + for op in [ + format!("fs.readAll('{}')", file.display()), + format!("fs.writeAll('{}', 'x')", file.display()), + format!("fs.list('{}')", dir.path().display()), + ] { + let err = lua_err(&lua, &op).await; + assert!(err.contains("disabled by --no-fs"), "{op}: {err}"); + } + + let (acc, is_dir, is_file): (bool, bool, bool) = lua + .load(format!( + "return fs.access('{d}', 'r'), fs.isDir('{d}'), fs.isFile('{f}')", + d = dir.path().display(), + f = file.display() + )) + .eval_async() + .await + .unwrap(); + assert!(!acc && !is_dir && !is_file); + // the file was not touched + assert_eq!(std::fs::read_to_string(&file).unwrap(), "secret"); +} + +#[tokio::test] +async fn default_policy_denies_unknown_without_persisting() { + // No policy injected: the fs::install default (REPL semantics, no + // scripted answers = nobody to ask) must deny and record nothing. + let lua = lua(); + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("f.txt"); + std::fs::write(&file, "x").unwrap(); + + let err = lua_err(&lua, &format!("fs.readAll('{}')", file.display())).await; + assert!(err.contains("fs.readAll") && err.contains("denied"), "{err}"); + + let ok: bool = lua + .load(format!("return fs.access('{}', 'r')", dir.path().display())) + .eval_async() + .await + .unwrap(); + assert!(!ok); +} + +#[tokio::test] +async fn interactive_allow_once_grants_operations() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("f.txt"); + std::fs::write(&file, "content").unwrap(); + + // One AllowOnce answer covers the whole run for that directory: the + // second read and the fs.access probe consume no further answers. + let lua = lua_with(FsPolicy::interactive_scripted( + None, + Default::default(), + None, + [Choice::AllowOnce], + )); + let (a, b, acc): (String, String, bool) = lua + .load(format!( + r#"return fs.readAll('{f}'), fs.readAll('{f}'), fs.access('{d}', 'r')"#, + f = file.display(), + d = dir.path().display() + )) + .eval_async() + .await + .unwrap(); + assert_eq!((a.as_str(), b.as_str(), acc), ("content", "content", true)); +} + +#[tokio::test] +async fn interactive_deny_answer_blocks_and_read_grant_does_not_leak_to_write() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("f.txt"); + std::fs::write(&file, "content").unwrap(); + + // First answer grants the read; the write is a separate (write-kind) + // question answered with DenyOnce. + let lua = lua_with(FsPolicy::interactive_scripted( + None, + Default::default(), + None, + [Choice::AllowOnce, Choice::DenyOnce], + )); + let got: String = lua + .load(format!("return fs.readAll('{}')", file.display())) + .eval_async() + .await + .unwrap(); + assert_eq!(got, "content"); + + let err = lua_err(&lua, &format!("fs.writeAll('{}', 'nope')", file.display())).await; + assert!(err.contains("fs.writeAll") && err.contains("denied"), "{err}"); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "content"); +} + +#[tokio::test] +async fn fs_access_allow_always_persists_and_is_honored_next_run() { + let data_dir = tempfile::tempdir().unwrap(); + let cfg = tempfile::tempdir().unwrap(); + let store_path = cfg.path().join("access.json"); + let script_key = "/scripts/tool.lua"; + + // Run 1: fs.access asks, the user answers "A". + let lua = lua_with(FsPolicy::interactive_scripted( + Some(script_key.into()), + Default::default(), + Some(store_path.clone()), + [Choice::AllowAlways], + )); + let ok: bool = lua + .load(format!("return fs.access('{}', 'w')", data_dir.path().display())) + .eval_async() + .await + .unwrap(); + assert!(ok); + + // The grant is on disk, keyed by script and canonical dir… + let persisted = store::load_for_script(&store_path, script_key); + let dir_key = data_dir.path().canonicalize().unwrap(); + assert_eq!(persisted[&dir_key.to_string_lossy().into_owned()].write, Some(true)); + + // …and a fresh "run" that loads it never needs a prompt, including for a + // file inside a subdirectory. + std::fs::create_dir(data_dir.path().join("sub")).unwrap(); + let lua = lua_with(FsPolicy::interactive_scripted( + Some(script_key.into()), + store::load_for_script(&store_path, script_key), + Some(store_path.clone()), + [], // any prompt would deny + )); + lua.load(format!( + "fs.writeAll('{}', 'hello')", + data_dir.path().join("sub/out.txt").display() + )) + .exec_async() + .await + .unwrap(); +} + +#[tokio::test] +async fn sqlite_memory_db_works_under_sandbox_but_file_db_is_blocked() { + let lua = lua_with(FsPolicy::deny_all("--sandbox", false)); + + let n: i64 = lua + .load( + r#" + local db = sqlite.connect(":memory:") + db:execute("CREATE TABLE t (x)") + db:execute("INSERT INTO t VALUES (41)") + return db:queryOne("SELECT x + 1 AS y FROM t").y + "#, + ) + .eval_async() + .await + .unwrap(); + assert_eq!(n, 42); + + let dir = tempfile::tempdir().unwrap(); + let err = lua_err( + &lua, + &format!("sqlite.connect('{}')", dir.path().join("x.db").display()), + ) + .await; + assert!(err.contains("sqlite.connect") && err.contains("--sandbox"), "{err}"); +} + +#[tokio::test] +async fn sqlite_file_db_asks_one_combined_question() { + let dir = tempfile::tempdir().unwrap(); + // A single AllowOnce must cover the whole read/write open. + let lua = lua_with(FsPolicy::interactive_scripted( + None, + Default::default(), + None, + [Choice::AllowOnce], + )); + let n: i64 = lua + .load(format!( + r#" + local db = sqlite.connect('{}') + db:execute("CREATE TABLE t (x)") + db:execute("INSERT INTO t VALUES (1)") + return db:queryOne("SELECT COUNT(*) AS n FROM t").n + "#, + dir.path().join("x.db").display() + )) + .eval_async() + .await + .unwrap(); + assert_eq!(n, 1); +} + +#[tokio::test] +async fn http_requests_are_blocked_under_sandbox_before_any_io() { + let lua = lua_with(FsPolicy::deny_all("--sandbox", false)); + // Unroutable address: if the gate failed, this would time out / error + // differently; the sandbox message must come from our check. + let err = lua_err(&lua, "http.get('http://127.0.0.1:1/x')").await; + assert!(err.contains("networking disabled by --sandbox"), "{err}"); + + // --no-fs alone leaves networking on (the request fails at the socket, + // not at the gate). + let lua = lua_with(FsPolicy::deny_all("--no-fs", true)); + let err = lua_err(&lua, "http.get('http://127.0.0.1:1/x')").await; + assert!(!err.contains("networking disabled"), "{err}"); +} + +#[tokio::test] +async fn cookie_jar_files_go_through_the_permission_system() { + let dir = tempfile::tempdir().unwrap(); + let jar_path = dir.path().join("jar.jsonl"); + + // Denied: save must not create the file. + let lua = lua_with(FsPolicy::deny_all("--no-fs", true)); + let err = lua_err( + &lua, + &format!("local s = http.session() s:save('{}')", jar_path.display()), + ) + .await; + assert!(err.contains("session:save") && err.contains("--no-fs"), "{err}"); + assert!(!jar_path.exists()); + + // Allowed: save/load and the http.session(path) preload round-trip. + let lua = lua_allow_all(); + lua.load(format!( + r#" + local s = http.session() + s:save('{p}') + s:load('{p}') + local s2 = http.session('{p}') + "#, + p = jar_path.display() + )) + .exec_async() + .await + .unwrap(); + assert!(jar_path.exists()); +} diff --git a/src/lua_tests/mod.rs b/src/lua_tests/mod.rs index 87fa350..cada28b 100644 --- a/src/lua_tests/mod.rs +++ b/src/lua_tests/mod.rs @@ -3,6 +3,7 @@ mod async_tests; mod core_tests; +mod fs_tests; mod math_tests; mod os_tests; mod require_tests; diff --git a/src/main.rs b/src/main.rs index dabe096..5c4ff4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,43 @@ mod stdlib; struct Args { /// Path to the Lua script to execute. Omit to start an interactive REPL. filepath: Option, + + /// Grant all filesystem access without prompting or recording anything. + #[arg(long, conflicts_with = "no_fs")] + allow_fs: bool, + + /// Deny all filesystem access (fs module, sqlite databases on disk, + /// cookie-jar files). + #[arg(long)] + no_fs: bool, + + /// Deny filesystem access AND networking; overrides --allow-fs. + #[arg(long)] + sandbox: bool, +} + +impl Args { + /// The fs/network permission policy the flags call for. In the default + /// interactive mode, grants recorded for this script (keyed by its + /// canonical path) are loaded from access.json; the REPL passes `None` + /// and never persists anything. + fn fs_policy(&self, script_key: Option) -> stdlib::fs::FsPolicy { + use stdlib::fs::{store, FsPolicy}; + if self.sandbox { + FsPolicy::deny_all("--sandbox", false) + } else if self.no_fs { + FsPolicy::deny_all("--no-fs", true) + } else if self.allow_fs { + FsPolicy::allow_all() + } else { + let store_path = store::default_store_path(); + let persisted = match (&script_key, &store_path) { + (Some(key), Some(path)) => store::load_for_script(path, key), + _ => Default::default(), + }; + FsPolicy::interactive(script_key, persisted, store_path) + } + } } #[tokio::main] @@ -50,8 +87,11 @@ async fn run() -> Result<(), Box> { let lua = sandbox::create_sandboxed_lua()?; - let Some(filepath) = args.filepath else { + let Some(filepath) = args.filepath.clone() else { // In the REPL, `.env` and modules are looked up from the CWD. + // Permissions are prompted for but never persisted (no script + // identity to record them under). + lua.set_app_data(std::sync::Arc::new(args.fs_policy(None))); setup_require(&lua, &std::env::current_dir()?)?; return repl::run(&lua).await; }; @@ -65,6 +105,11 @@ async fn run() -> Result<(), Box> { let script_dir = realpath.parent().unwrap_or(Path::new("/")); setup_require(&lua, script_dir)?; + // The canonical path doubles as the script's identity in the permission + // store; this replaces the session-only default planted by fs::install. + let script_key = realpath.to_string_lossy().into_owned(); + lua.set_app_data(std::sync::Arc::new(args.fs_policy(Some(script_key)))); + // Read as bytes, not a UTF-8 String: Lua source (and its string literals) are // byte strings, and stock Lua runs files that aren't valid UTF-8. let source = tokio::fs::read(&realpath) diff --git a/src/stdlib/fs/mod.rs b/src/stdlib/fs/mod.rs new file mode 100644 index 0000000..1057768 --- /dev/null +++ b/src/stdlib/fs/mod.rs @@ -0,0 +1,294 @@ +//! `fs` — whole-file filesystem access behind a folder-based permission +//! system. +//! +//! v1 keeps the API deliberately small: `fs.readAll` / `fs.writeAll` move a +//! whole file to/from a Lua string, `fs.list` names a directory's entries, +//! `fs.isDir` / `fs.isFile` are existence-and-type predicates, and +//! `fs.access(dir, "r"|"w")` runs the permission cycle explicitly. +//! +//! Every operation is checked against a per-script, per-directory grant +//! ([`policy`]): recorded answers live in `access.json` ([`store`]), unknown +//! ones are asked on the terminal ([`prompt`]) — `y`/`n` for this run, +//! `A`/`N` recorded. Grants are recursive (a granted parent covers all +//! children) and keyed by the script's canonical path; the REPL prompts the +//! same way but never persists. `--allow-fs`, `--no-fs` and `--sandbox` +//! replace the interactive policy wholesale (see `main.rs`). +//! +//! `sqlite.connect` and http's cookie-jar files route through the same +//! checks via [`ensure_access`]; `--sandbox` additionally cuts networking +//! via [`ensure_net`]. + +pub(crate) mod policy; +// In test builds `policy::check` never reaches the real terminal prompt +// (scripted answers only), leaving these functions deliberately unused. +#[cfg_attr(test, allow(dead_code))] +mod prompt; +pub(crate) mod store; + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use mlua::prelude::{LuaResult, LuaString}; +use mlua::Lua; + +pub(crate) use policy::{FsPolicy, Wants}; +#[cfg(test)] +pub(crate) use prompt::Choice; + +/// `mlua::Error::external(format!(...))` — every Lua-facing error in this +/// module goes through this, prefixed `fs.: …`. +macro_rules! ext_err { + ($($arg:tt)*) => { mlua::Error::external(format!($($arg)*)) }; +} + +pub(super) fn install(lua: &Lua) -> LuaResult<()> { + // Default policy: REPL semantics — prompt if a terminal is there, never + // persist. `main` replaces it once the CLI flags and the script's + // canonical path are known; bare states (tests) can therefore never + // touch the real access.json. + lua.set_app_data(Arc::new(FsPolicy::interactive(None, Default::default(), None))); + + let fs = lua.create_table()?; + + fs.set( + "readAll", + lua.create_async_function(|lua, path: LuaString| async move { + const F: &str = "fs.readAll"; + let path = to_path(&path); + let (dir, target) = read_target(F, &path).await?; + ensure_access(&lua, F, dir, Wants::READ).await?; + let bytes = tokio::fs::read(&target) + .await + .map_err(|e| ext_err!("{F}: {}: {e}", path.display()))?; + lua.create_string(&bytes) + })?, + )?; + + fs.set( + "writeAll", + lua.create_async_function(|lua, (path, content): (LuaString, LuaString)| async move { + const F: &str = "fs.writeAll"; + let path = to_path(&path); + let (dir, target) = write_target(F, &path).await?; + ensure_access(&lua, F, dir, Wants::WRITE).await?; + tokio::fs::write(&target, &content.as_bytes()) + .await + .map_err(|e| ext_err!("{F}: {}: {e}", path.display())) + })?, + )?; + + fs.set( + "list", + lua.create_async_function(|lua, path: LuaString| async move { + const F: &str = "fs.list"; + let path = to_path(&path); + let canon = canonicalize(F, &path).await?; + let meta = metadata(F, &path, &canon).await?; + if !meta.is_dir() { + return Err(ext_err!("{F}: {}: not a directory", path.display())); + } + ensure_access(&lua, F, canon.clone(), Wants::READ).await?; + + let mut entries = tokio::fs::read_dir(&canon) + .await + .map_err(|e| ext_err!("{F}: {}: {e}", path.display()))?; + let mut names: Vec> = Vec::new(); + while let Some(entry) = entries + .next_entry() + .await + .map_err(|e| ext_err!("{F}: {}: {e}", path.display()))? + { + use std::os::unix::ffi::OsStrExt; + names.push(entry.file_name().as_bytes().to_vec()); + } + // read_dir order is arbitrary; sorted output is deterministic. + names.sort_unstable(); + let list = lua.create_table_with_capacity(names.len(), 0)?; + for name in &names { + list.raw_push(lua.create_string(name)?)?; + } + Ok(list) + })?, + )?; + + fs.set( + "isDir", + lua.create_async_function(|lua, path: LuaString| async move { + let path = to_path(&path); + let Ok(canon) = tokio::fs::canonicalize(&path).await else { + return Ok(false); // nonexistent: false without prompting + }; + match tokio::fs::metadata(&canon).await { + Ok(meta) if meta.is_dir() => check_access(&lua, canon, Wants::READ).await, + _ => Ok(false), + } + })?, + )?; + + fs.set( + "isFile", + lua.create_async_function(|lua, path: LuaString| async move { + let path = to_path(&path); + let Ok(canon) = tokio::fs::canonicalize(&path).await else { + return Ok(false); + }; + match tokio::fs::metadata(&canon).await { + Ok(meta) if meta.is_file() => { + check_access(&lua, parent_dir(&canon), Wants::READ).await + } + _ => Ok(false), + } + })?, + )?; + + fs.set( + "getWorkdir", + lua.create_function(|lua, ()| { + // Process state, not filesystem content — no permission check. + // This is what relative paths in the other fs functions (and in + // fs.access probes) resolve against. + use std::os::unix::ffi::OsStrExt; + let cwd = std::env::current_dir() + .map_err(|e| ext_err!("fs.getWorkdir: {e}"))?; + lua.create_string(cwd.as_os_str().as_bytes()) + })?, + )?; + + fs.set( + "access", + lua.create_async_function(|lua, (dir, mode): (LuaString, LuaString)| async move { + const F: &str = "fs.access"; + let wants = match &*mode.as_bytes() { + b"r" => Wants::READ, + b"w" => Wants::WRITE, + _ => return Err(ext_err!("{F}: mode must be \"r\" or \"w\"")), + }; + let dir = to_path(&dir); + let canon = canonicalize(F, &dir).await?; + let meta = metadata(F, &dir, &canon).await?; + if !meta.is_dir() { + return Err(ext_err!("{F}: {}: not a directory", dir.display())); + } + check_access(&lua, canon, wants).await + })?, + )?; + + lua.globals().raw_set("fs", fs)?; + Ok(()) +} + +/// The active policy; fails closed if none was installed (cannot happen — +/// `install` plants a default). +fn active_policy(lua: &Lua) -> Arc { + lua.app_data_ref::>() + .map(|p| Arc::clone(&p)) + .unwrap_or_else(|| Arc::new(FsPolicy::deny_all("(internal: no fs policy)", false))) +} + +/// Run the permission cycle for the canonical directory `dir`; a denial is a +/// Lua error prefixed with `fname`. Shared with sqlite (`Wants::READ_WRITE` +/// on a database's directory) and http (cookie-jar files). +pub(super) async fn ensure_access( + lua: &Lua, + fname: &'static str, + dir: PathBuf, + wants: Wants, +) -> LuaResult<()> { + match active_policy(lua).check(dir.clone(), wants).await? { + policy::Outcome::Granted => Ok(()), + policy::Outcome::DeniedByFlag(flag) => { + Err(ext_err!("{fname}: filesystem access disabled by {flag}")) + } + policy::Outcome::Denied => Err(ext_err!("{fname}: access to {} denied", dir.display())), + } +} + +/// Bool-returning permission cycle (`fs.access`, the predicates): denial is +/// `false`, never an error. +async fn check_access(lua: &Lua, dir: PathBuf, wants: Wants) -> LuaResult { + Ok(matches!(active_policy(lua).check(dir, wants).await?, policy::Outcome::Granted)) +} + +/// Networking gate for `--sandbox`; called by http before any request. +pub(super) fn ensure_net(lua: &Lua) -> LuaResult<()> { + if active_policy(lua).net_allowed { + Ok(()) + } else { + Err(ext_err!("http: networking disabled by --sandbox")) + } +} + +/// Lua strings are byte strings; on unix so are paths. +fn to_path(s: &LuaString) -> PathBuf { + use std::os::unix::ffi::OsStrExt; + PathBuf::from(std::ffi::OsStr::from_bytes(&s.as_bytes())) +} + +/// The permission unit for operations on a file: its containing directory. +fn parent_dir(canon: &Path) -> PathBuf { + canon.parent().unwrap_or(Path::new("/")).to_path_buf() +} + +async fn canonicalize(fname: &str, path: &Path) -> LuaResult { + tokio::fs::canonicalize(path) + .await + .map_err(|e| ext_err!("{fname}: {}: {e}", path.display())) +} + +async fn metadata(fname: &str, path: &Path, canon: &Path) -> LuaResult { + tokio::fs::metadata(canon) + .await + .map_err(|e| ext_err!("{fname}: {}: {e}", path.display())) +} + +/// Resolve an existing regular file into (canonical containing dir = the +/// permission unit, canonical file). Shared with http's cookie-jar load. +pub(super) async fn read_target(fname: &'static str, path: &Path) -> LuaResult<(PathBuf, PathBuf)> { + let canon = canonicalize(fname, path).await?; + let meta = metadata(fname, path, &canon).await?; + if !meta.is_file() { + return Err(ext_err!("{fname}: {}: not a regular file", path.display())); + } + Ok((parent_dir(&canon), canon)) +} + +/// Resolve a write destination that may not exist yet into (canonical +/// containing dir = the permission unit, write target). The containing +/// directory must exist — v1 has no mkdir-p. Shared with http's cookie-jar +/// save and sqlite's connect. +pub(super) async fn write_target( + fname: &'static str, + path: &Path, +) -> LuaResult<(PathBuf, PathBuf)> { + match tokio::fs::canonicalize(path).await { + Ok(canon) => { + let meta = metadata(fname, path, &canon).await?; + if meta.is_dir() { + return Err(ext_err!("{fname}: {}: is a directory", path.display())); + } + Ok((parent_dir(&canon), canon)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // A dangling symlink would make write() land at a location the + // permission check never saw; refuse it. + if let Ok(meta) = tokio::fs::symlink_metadata(path).await + && meta.file_type().is_symlink() + { + return Err(ext_err!("{fname}: {}: dangling symlink", path.display())); + } + let Some(name) = path.file_name() else { + return Err(ext_err!("{fname}: {}: not a file path", path.display())); + }; + let parent = match path.parent() { + Some(p) if !p.as_os_str().is_empty() => p, + _ => Path::new("."), + }; + let dir = tokio::fs::canonicalize(parent).await.map_err(|e| { + ext_err!("{fname}: {}: parent directory: {e}", path.display()) + })?; + let target = dir.join(name); + Ok((dir, target)) + } + Err(e) => Err(ext_err!("{fname}: {}: {e}", path.display())), + } +} diff --git a/src/stdlib/fs/policy.rs b/src/stdlib/fs/policy.rs new file mode 100644 index 0000000..8f6d3a2 --- /dev/null +++ b/src/stdlib/fs/policy.rs @@ -0,0 +1,519 @@ +//! The permission policy — who may touch which directory. +//! +//! One [`FsPolicy`] lives in mlua app data per Lua state (as `Arc`). +//! `fs::install` plants a session-only default; `main` replaces it once the +//! CLI flags and the script's canonical path are known. +//! +//! Grants are recorded per canonical directory and apply recursively: the +//! deepest recorded ancestor decides, so a deny on `~/secrets` beats an allow +//! on `~`. Session answers (y/n, and everything in the REPL) shadow the +//! persisted store at every depth. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use mlua::prelude::LuaResult; + +use super::prompt::Choice; +use super::store::{self, DirAccess, DirGrants}; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum AccessKind { + Read, + Write, +} + +/// Which directions an operation needs; `read && write` is sqlite's combined +/// "open this database" request, answered by a single prompt. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Wants { + pub(crate) read: bool, + pub(crate) write: bool, +} + +impl Wants { + pub(crate) const READ: Wants = Wants { read: true, write: false }; + pub(crate) const WRITE: Wants = Wants { read: false, write: true }; + pub(crate) const READ_WRITE: Wants = Wants { read: true, write: true }; + + fn kinds(self) -> impl Iterator { + [(self.read, AccessKind::Read), (self.write, AccessKind::Write)] + .into_iter() + .filter_map(|(wanted, kind)| wanted.then_some(kind)) + } + + fn describe(self) -> &'static str { + match (self.read, self.write) { + (true, true) => "read/write", + (true, false) => "read", + (false, true) => "write", + (false, false) => "no", + } + } +} + +pub(crate) enum FsMode { + /// `--allow-fs`: everything granted, no prompts, no persistence. + AllowAll, + /// `--no-fs` / `--sandbox`: everything denied; `flag` names the culprit + /// for error messages. + DenyAll { flag: &'static str }, + /// Default: consult the recorded grants, prompt on unknown. + Interactive { + /// Store key = the script's canonical path. `None` in the REPL (and + /// in bare test states): prompts work, nothing is ever persisted. + script_key: Option, + /// This script's grants from access.json, snapshotted at startup. + persisted: Mutex, + /// Session-only answers — y/n, plus A/N in the REPL. Shadows + /// `persisted`. + session: Mutex, + /// Where A/N answers are persisted; `None` = no config dir found, + /// session-only operation. + store_path: Option, + /// Scripted prompt answers for tests, consumed front to back; an + /// exhausted (or absent) queue behaves like "no terminal". In test + /// builds the real terminal prompt is never reached (see `check`). + #[cfg(test)] + scripted: Mutex>, + }, +} + +pub(crate) struct FsPolicy { + pub(crate) mode: FsMode, + /// `false` only under `--sandbox`: http refuses all requests. + pub(crate) net_allowed: bool, +} + +/// Outcome of a permission check, kept apart from granted/denied booleans so +/// error messages can name the CLI flag that caused a static denial. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Outcome { + Granted, + /// Denied by `--no-fs` / `--sandbox`. + DeniedByFlag(&'static str), + /// Denied by a recorded decision, an interactive answer, or the inability + /// to ask (no terminal). + Denied, +} + +impl FsPolicy { + pub(crate) fn allow_all() -> Self { + FsPolicy { mode: FsMode::AllowAll, net_allowed: true } + } + + pub(crate) fn deny_all(flag: &'static str, net_allowed: bool) -> Self { + FsPolicy { mode: FsMode::DenyAll { flag }, net_allowed } + } + + pub(crate) fn interactive( + script_key: Option, + persisted: DirGrants, + store_path: Option, + ) -> Self { + FsPolicy { + mode: FsMode::Interactive { + script_key, + persisted: Mutex::new(persisted), + session: Mutex::new(BTreeMap::new()), + store_path, + #[cfg(test)] + scripted: Mutex::new(std::collections::VecDeque::new()), + }, + net_allowed: true, + } + } + + /// An interactive policy with a queue of pre-recorded prompt answers. + #[cfg(test)] + pub(crate) fn interactive_scripted( + script_key: Option, + persisted: DirGrants, + store_path: Option, + answers: impl IntoIterator, + ) -> Self { + let policy = Self::interactive(script_key, persisted, store_path); + if let FsMode::Interactive { scripted, .. } = &policy.mode { + scripted.lock().unwrap().extend(answers); + } + policy + } + + /// The full permission cycle for the canonical directory `dir`: recorded + /// answer, else ask on the terminal, else deny (recording nothing — a + /// persisted denial must be an explicit user decision). + pub(crate) async fn check(self: Arc, dir: PathBuf, wants: Wants) -> LuaResult { + match &self.mode { + FsMode::AllowAll => return Ok(Outcome::Granted), + FsMode::DenyAll { flag } => return Ok(Outcome::DeniedByFlag(flag)), + FsMode::Interactive { .. } => {} + } + + // Fast path — already decided, no prompt needed. + match self.resolve_wants(&dir, wants) { + Some(true) => return Ok(Outcome::Granted), + Some(false) => return Ok(Outcome::Denied), + None => {} + } + + // Test builds never reach a real terminal prompt: scripted answers + // only. This is structural — an unwitting test cannot hang `cargo + // test` on a keypress. + #[cfg(test)] + { + let FsMode::Interactive { scripted, .. } = &self.mode else { unreachable!() }; + let choice = { + let mut answers = scripted.lock().unwrap_or_else(|e| e.into_inner()); + answers.pop_front().unwrap_or(Choice::NoTty) + }; + Ok(self.decide(&dir, wants, |_, _, _| choice)) + } + + #[cfg(not(test))] + { + if !super::prompt::can_prompt() { + return Ok(Outcome::Denied); + } + let this = Arc::clone(&self); + tokio::task::spawn_blocking(move || { + // Serialize with tui prompts (and each other) so concurrent + // coroutines can't fight over the terminal. + let _guard = crate::stdlib::tui::PROMPT_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + this.decide(&dir, wants, super::prompt::prompt_access) + }) + .await + .map_err(|e| mlua::Error::external(format!("fs: prompt task failed: {e}"))) + } + } + + /// The ask-and-record step, run with the prompt lock held. Re-resolves + /// first — another coroutine may have answered for this directory while + /// we waited for the lock — then asks only for what is still unknown. + fn decide(&self, dir: &Path, wants: Wants, ask: impl FnOnce(&str, &str, &str) -> Choice) -> Outcome { + let FsMode::Interactive { script_key, session, store_path, .. } = &self.mode else { + unreachable!("decide() is only called in interactive mode"); + }; + + match self.resolve_wants(dir, wants) { + Some(true) => return Outcome::Granted, + Some(false) => return Outcome::Denied, + None => {} + } + let missing = Wants { + read: wants.read && self.resolve_one(dir, AccessKind::Read).is_none(), + write: wants.write && self.resolve_one(dir, AccessKind::Write).is_none(), + }; + + let label = script_key.as_deref().unwrap_or("interactive session (REPL)"); + let dir_key = dir.to_string_lossy().into_owned(); + let choice = ask(label, &dir_key, missing.describe()); + let value = match choice { + Choice::AllowOnce | Choice::AllowAlways => true, + Choice::DenyOnce | Choice::DenyAlways => false, + Choice::NoTty => return Outcome::Denied, // record nothing + }; + + let update = DirAccess { + read: missing.read.then_some(value), + write: missing.write.then_some(value), + }; + { + let mut session = session.lock().unwrap_or_else(|e| e.into_inner()); + let entry = session.entry(dir_key.clone()).or_default(); + if update.read.is_some() { + entry.read = update.read; + } + if update.write.is_some() { + entry.write = update.write; + } + } + if matches!(choice, Choice::AllowAlways | Choice::DenyAlways) + && let (Some(key), Some(path)) = (script_key, store_path) + && let Err(e) = store::persist(path, key, &dir_key, update) + { + // The answer still holds for this run; failing to record it must + // not fail the script's operation. + eprintln!( + "{}: warning: cannot save permission to {}: {e}", + store::APP_NAME, + path.display() + ); + } + + // `missing` now carries the fresh answer; every other wanted kind + // already resolved to true (a false would have denied above). + if value { Outcome::Granted } else { Outcome::Denied } + } + + /// `Some(true)` = every wanted kind granted, `Some(false)` = at least one + /// denied, `None` = at least one undecided (and none denied). + fn resolve_wants(&self, dir: &Path, wants: Wants) -> Option { + let mut all_known = true; + for kind in wants.kinds() { + match self.resolve_one(dir, kind) { + Some(false) => return Some(false), + Some(true) => {} + None => all_known = false, + } + } + all_known.then_some(true) + } + + fn resolve_one(&self, dir: &Path, kind: AccessKind) -> Option { + let FsMode::Interactive { session, persisted, .. } = &self.mode else { + return None; + }; + let session = session.lock().unwrap_or_else(|e| e.into_inner()); + let persisted = persisted.lock().unwrap_or_else(|e| e.into_inner()); + resolve(&session, &persisted, dir, kind) + } +} + +/// Walk from `dir` up to and including `/`. At each depth the session layer +/// shadows the persisted one; the first decided value wins — the deepest +/// recorded ancestor decides. +fn resolve(session: &DirGrants, persisted: &DirGrants, dir: &Path, kind: AccessKind) -> Option { + let mut cur = Some(dir); + while let Some(d) = cur { + let key = d.to_string_lossy(); + for layer in [session, persisted] { + if let Some(v) = layer.get(key.as_ref()).and_then(|a| a.get(kind)) { + return Some(v); + } + } + cur = d.parent(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn grants(entries: &[(&str, Option, Option)]) -> DirGrants { + entries + .iter() + .map(|(dir, read, write)| { + (dir.to_string(), DirAccess { read: *read, write: *write }) + }) + .collect() + } + + #[test] + fn resolve_walks_ancestors() { + let persisted = grants(&[("/home/u/data", Some(true), None)]); + let session = DirGrants::new(); + let r = |dir: &str| resolve(&session, &persisted, Path::new(dir), AccessKind::Read); + assert_eq!(r("/home/u/data"), Some(true), "exact hit"); + assert_eq!(r("/home/u/data/sub/deep"), Some(true), "parent covers children"); + assert_eq!(r("/home/u"), None, "no upward leakage"); + assert_eq!(r("/elsewhere"), None, "unrelated dir unknown"); + } + + #[test] + fn resolve_deepest_ancestor_wins() { + let persisted = grants(&[ + ("/home/u", Some(true), None), + ("/home/u/secrets", Some(false), None), + ]); + let session = DirGrants::new(); + let r = |dir: &str| resolve(&session, &persisted, Path::new(dir), AccessKind::Read); + assert_eq!(r("/home/u/data"), Some(true)); + assert_eq!(r("/home/u/secrets"), Some(false), "deny beats shallower allow"); + assert_eq!(r("/home/u/secrets/inner"), Some(false)); + } + + #[test] + fn resolve_session_shadows_persisted() { + let persisted = grants(&[("/data", Some(false), None)]); + let session = grants(&[("/data", Some(true), None)]); + assert_eq!( + resolve(&session, &persisted, Path::new("/data/x"), AccessKind::Read), + Some(true) + ); + } + + #[test] + fn resolve_null_flags_are_skipped() { + // read is undecided at the deep entry; the shallow decided one wins. + let persisted = grants(&[("/a", Some(true), None), ("/a/b", None, Some(false))]); + let session = DirGrants::new(); + assert_eq!(resolve(&session, &persisted, Path::new("/a/b"), AccessKind::Read), Some(true)); + assert_eq!(resolve(&session, &persisted, Path::new("/a/b"), AccessKind::Write), Some(false)); + } + + #[test] + fn resolve_root_grant_covers_everything() { + let persisted = grants(&[("/", Some(true), Some(true))]); + let session = DirGrants::new(); + assert_eq!( + resolve(&session, &persisted, Path::new("/any/where"), AccessKind::Write), + Some(true) + ); + } + + #[tokio::test] + async fn static_modes() { + let allow = Arc::new(FsPolicy::allow_all()); + assert_eq!(allow.check("/x".into(), Wants::READ).await.unwrap(), Outcome::Granted); + let deny = Arc::new(FsPolicy::deny_all("--no-fs", true)); + assert_eq!( + Arc::clone(&deny).check("/x".into(), Wants::WRITE).await.unwrap(), + Outcome::DeniedByFlag("--no-fs") + ); + assert!(deny.net_allowed); + assert!(!FsPolicy::deny_all("--sandbox", false).net_allowed); + } + + #[tokio::test] + async fn allow_once_grants_for_the_whole_run() { + let policy = Arc::new(FsPolicy::interactive_scripted( + None, + DirGrants::new(), + None, + [Choice::AllowOnce], // exactly one answer available + )); + let p = Arc::clone(&policy); + assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted); + // second check hits the session cache — no second answer consumed + // (the queue is empty; consuming would mean NoTty => Denied) + let p = Arc::clone(&policy); + assert_eq!(p.check("/data/sub".into(), Wants::READ).await.unwrap(), Outcome::Granted); + } + + #[tokio::test] + async fn deny_once_is_cached_for_the_run() { + let policy = Arc::new(FsPolicy::interactive_scripted( + None, + DirGrants::new(), + None, + [Choice::DenyOnce], + )); + let p = Arc::clone(&policy); + assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Denied); + let p = Arc::clone(&policy); + assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Denied); + } + + #[tokio::test] + async fn allow_always_persists_and_covers_subdirs() { + let tmp = tempfile::tempdir().unwrap(); + let store_path = tmp.path().join("access.json"); + let policy = Arc::new(FsPolicy::interactive_scripted( + Some("/s/tool.lua".into()), + DirGrants::new(), + Some(store_path.clone()), + [Choice::AllowAlways], + )); + let p = Arc::clone(&policy); + assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted); + + let saved = crate::stdlib::fs::store::load_for_script(&store_path, "/s/tool.lua"); + assert_eq!(saved["/data"], DirAccess { read: Some(true), write: None }); + + let p = Arc::clone(&policy); + assert_eq!(p.check("/data/deep".into(), Wants::READ).await.unwrap(), Outcome::Granted); + } + + #[tokio::test] + async fn deny_always_persists_false() { + let tmp = tempfile::tempdir().unwrap(); + let store_path = tmp.path().join("access.json"); + let policy = Arc::new(FsPolicy::interactive_scripted( + Some("/s/tool.lua".into()), + DirGrants::new(), + Some(store_path.clone()), + [Choice::DenyAlways], + )); + let p = Arc::clone(&policy); + assert_eq!(p.check("/secrets".into(), Wants::WRITE).await.unwrap(), Outcome::Denied); + + let saved = crate::stdlib::fs::store::load_for_script(&store_path, "/s/tool.lua"); + assert_eq!(saved["/secrets"], DirAccess { read: None, write: Some(false) }); + } + + #[tokio::test] + async fn repl_always_answers_never_persist() { + let tmp = tempfile::tempdir().unwrap(); + let store_path = tmp.path().join("access.json"); + // script_key = None (REPL): store_path present but must not be used + let policy = Arc::new(FsPolicy::interactive_scripted( + None, + DirGrants::new(), + Some(store_path.clone()), + [Choice::AllowAlways], + )); + let p = Arc::clone(&policy); + assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted); + assert!(!store_path.exists(), "REPL grant must not create the store"); + // ...but it holds for the session + let p = Arc::clone(&policy); + assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted); + } + + #[tokio::test] + async fn unknown_without_terminal_denies_and_records_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let store_path = tmp.path().join("access.json"); + // empty answer queue = "no terminal" + let policy = Arc::new(FsPolicy::interactive_scripted( + Some("/s/tool.lua".into()), + DirGrants::new(), + Some(store_path.clone()), + [], + )); + let p = Arc::clone(&policy); + assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Denied); + assert!(!store_path.exists()); + // nothing was cached either: a persisted grant arriving later (other + // process) would be honored — verify session stayed empty + if let FsMode::Interactive { session, .. } = &policy.mode { + assert!(session.lock().unwrap().is_empty()); + } + } + + #[tokio::test] + async fn persisted_grants_answer_without_prompting() { + let persisted = grants(&[("/data", Some(true), Some(false))]); + let policy = Arc::new(FsPolicy::interactive_scripted( + Some("/s/tool.lua".into()), + persisted, + None, + [], // no answers available: any prompt would deny + )); + let p = Arc::clone(&policy); + assert_eq!(p.check("/data/x".into(), Wants::READ).await.unwrap(), Outcome::Granted); + let p = Arc::clone(&policy); + assert_eq!(p.check("/data/x".into(), Wants::WRITE).await.unwrap(), Outcome::Denied); + // combined read/write: the recorded write=false denies without asking + let p = Arc::clone(&policy); + assert_eq!(p.check("/data/x".into(), Wants::READ_WRITE).await.unwrap(), Outcome::Denied); + } + + #[tokio::test] + async fn combined_prompt_records_only_missing_kinds() { + let tmp = tempfile::tempdir().unwrap(); + let store_path = tmp.path().join("access.json"); + // read already granted; a READ_WRITE check must only ask (and record) + // the missing write flag + let persisted = grants(&[("/db", Some(true), None)]); + let policy = Arc::new(FsPolicy::interactive_scripted( + Some("/s/tool.lua".into()), + persisted, + Some(store_path.clone()), + [Choice::AllowAlways], + )); + let p = Arc::clone(&policy); + assert_eq!(p.check("/db".into(), Wants::READ_WRITE).await.unwrap(), Outcome::Granted); + + let saved = crate::stdlib::fs::store::load_for_script(&store_path, "/s/tool.lua"); + assert_eq!( + saved["/db"], + DirAccess { read: None, write: Some(true) }, + "read was already known; only write gets recorded" + ); + } +} diff --git a/src/stdlib/fs/prompt.rs b/src/stdlib/fs/prompt.rs new file mode 100644 index 0000000..030679c --- /dev/null +++ b/src/stdlib/fs/prompt.rs @@ -0,0 +1,105 @@ +//! The single-keypress permission prompt. +//! +//! Runs on the blocking pool with the tui prompt lock held (see +//! [`super::policy::FsPolicy::check`]), so it never fights an inquire prompt +//! or a sibling permission prompt for the terminal. One keypress, no Enter: +//! `y`/`n` answer for this run only, `A`/`N` are recorded in access.json. +//! Deliberately, a lowercase `a` does nothing — a slip of the finger must not +//! persist anything. + +use std::io::{IsTerminal, Write}; + +use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; + +use super::store::APP_NAME; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Choice { + AllowOnce, + DenyOnce, + AllowAlways, + DenyAlways, + /// No terminal to ask on (or raw mode unavailable): deny, record nothing. + NoTty, +} + +/// Whether there is a terminal to ask on. The prompt itself goes to stderr so +/// it shows even when the script's stdout is piped. +pub(super) fn can_prompt() -> bool { + std::io::stdin().is_terminal() && std::io::stderr().is_terminal() +} + +/// Ask the user; `what` is `"read"`, `"write"` or `"read/write"`. +pub(super) fn prompt_access(script: &str, dir: &str, what: &str) -> Choice { + if !can_prompt() { + return Choice::NoTty; + } + eprint!( + "{APP_NAME}: permission request\n \ + script: {script}\n \ + wants: {what} access to directory {dir}\n\ + [y] allow once [n] deny once [A] allow always [N] never allow: " + ); + let _ = std::io::stderr().flush(); + + let Ok(raw) = RawMode::enable() else { + eprintln!(); + return Choice::NoTty; + }; + let choice = loop { + match crossterm::event::read() { + Ok(Event::Key(k)) if k.kind == KeyEventKind::Press => { + // In raw mode Ctrl-C is a plain key event (no SIGINT): a + // cautious "deny once", same as Esc. + if k.modifiers.contains(KeyModifiers::CONTROL) { + if k.code == KeyCode::Char('c') { + break Choice::DenyOnce; + } + continue; + } + match k.code { + KeyCode::Char('y') => break Choice::AllowOnce, + KeyCode::Char('n') => break Choice::DenyOnce, + // Shifted letters arrive as the uppercase char (the SHIFT + // modifier bit varies by terminal, so don't match on it). + KeyCode::Char('A') => break Choice::AllowAlways, + KeyCode::Char('N') => break Choice::DenyAlways, + KeyCode::Esc => break Choice::DenyOnce, + _ => {} + } + } + Ok(_) => {} // resize, mouse, focus: ignore + Err(_) => break Choice::DenyOnce, // fail closed + } + }; + drop(raw); + // Echo the decision after raw mode is off (raw mode needs \r\n). + eprintln!( + "{}", + match choice { + Choice::AllowOnce => "allow once", + Choice::DenyOnce => "deny once", + Choice::AllowAlways => "allow always", + Choice::DenyAlways => "never allow", + Choice::NoTty => "", + } + ); + choice +} + +/// RAII raw-mode: a panic between enable and disable can't strand the +/// terminal in raw mode. +struct RawMode; + +impl RawMode { + fn enable() -> std::io::Result { + crossterm::terminal::enable_raw_mode()?; + Ok(RawMode) + } +} + +impl Drop for RawMode { + fn drop(&mut self) { + let _ = crossterm::terminal::disable_raw_mode(); + } +} diff --git a/src/stdlib/fs/store.rs b/src/stdlib/fs/store.rs new file mode 100644 index 0000000..aef8525 --- /dev/null +++ b/src/stdlib/fs/store.rs @@ -0,0 +1,321 @@ +//! The persistent grant store — `access.json` in the user's config directory. +//! +//! Grants are keyed by the script's canonical path, then by canonical +//! directory, each carrying a tri-state per direction: `null` (not decided), +//! `true` (approved), `false` (denied). The file is hand-editable; [`persist`] +//! keeps concurrent `a` processes from losing each other's updates by holding +//! an exclusive `flock` on a sidecar lock file across its read-merge-write +//! cycle, and replaces the store with `rename()` so readers never see a +//! half-written file. + +use std::collections::BTreeMap; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// Config directory name under `$XDG_CONFIG_HOME`. A literal, not +/// `CARGO_PKG_NAME`: renaming the binary silently abandoning every recorded +/// grant should be an explicit decision made here. +pub(crate) const APP_NAME: &str = "a"; + +/// Tri-state access recorded for one directory. `None` = not decided yet, +/// `Some(true)` = approved, `Some(false)` = denied — serialized as JSON +/// `null` / `true` / `false`. +#[derive(Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Debug)] +pub(crate) struct DirAccess { + #[serde(default)] + pub(crate) read: Option, + #[serde(default)] + pub(crate) write: Option, +} + +impl DirAccess { + pub(crate) fn get(&self, kind: super::policy::AccessKind) -> Option { + match kind { + super::policy::AccessKind::Read => self.read, + super::policy::AccessKind::Write => self.write, + } + } +} + +/// Grants recorded for one script: canonical directory → tri-state pair. +/// `BTreeMap` for deterministic serialization (stable diffs of access.json). +pub(crate) type DirGrants = BTreeMap; + +#[derive(Serialize, Deserialize)] +pub(crate) struct AccessStore { + #[serde(default = "version_default")] + pub(crate) version: u32, + #[serde(default)] + pub(crate) scripts: BTreeMap, +} + +fn version_default() -> u32 { + 1 +} + +impl Default for AccessStore { + fn default() -> Self { + AccessStore { version: 1, scripts: BTreeMap::new() } + } +} + +/// `$XDG_CONFIG_HOME/a/access.json`, falling back to `~/.config/a/access.json`. +/// Per the XDG spec a non-absolute `$XDG_CONFIG_HOME` is ignored. `None` when +/// no absolute base can be found — permissions then live for the session only. +pub(crate) fn default_store_path() -> Option { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| { + std::env::var_os("HOME") + .map(|h| PathBuf::from(h).join(".config")) + .filter(|p| p.is_absolute()) + })?; + Some(base.join(APP_NAME).join("access.json")) +} + +/// Read the whole store. Missing file → empty store; unreadable or corrupt → +/// warn and treat as empty (a later [`persist`] moves a corrupt file aside +/// before overwriting, preserving whatever the user had). +fn read_store(path: &Path) -> AccessStore { + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return AccessStore::default(), + Err(e) => { + log::warn!("cannot read {}: {e}", path.display()); + return AccessStore::default(); + } + }; + match serde_json::from_slice(&bytes) { + Ok(store) => store, + Err(e) => { + log::warn!("{} is corrupt ({e}); ignoring it", path.display()); + AccessStore::default() + } + } +} + +/// The grants recorded for one script (empty if none). Startup snapshot; takes +/// no lock — `persist`'s atomic rename guarantees a complete file. +pub(crate) fn load_for_script(store_path: &Path, script_key: &str) -> DirGrants { + read_store(store_path).scripts.remove(script_key).unwrap_or_default() +} + +/// Record a decision: merge `update`'s decided flags (only the `Some` ones) +/// into the store entry for (`script_key`, `dir_key`) and rewrite the file. +/// +/// Holds an exclusive `flock` on `access.json.lock` across the whole +/// read-merge-write so a concurrently running script can't lose this update. +/// The lock file is separate from the store on purpose: the final `rename` +/// replaces the store's inode, so a lock on the store itself would not exclude +/// a process that opened it just before the rename. +pub(crate) fn persist( + store_path: &Path, + script_key: &str, + dir_key: &str, + update: DirAccess, +) -> std::io::Result<()> { + let config_dir = store_path.parent().unwrap_or(Path::new("/")); + std::fs::create_dir_all(config_dir)?; + + let lock_file = std::fs::File::options() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(store_path.with_extension("json.lock"))?; + rustix::fs::flock(&lock_file, rustix::fs::FlockOperation::LockExclusive)?; + + // Re-read fresh under the lock: the startup snapshot (or a previous + // in-process copy) may be stale by now. + let mut store = match std::fs::read(store_path) { + Ok(bytes) => match serde_json::from_slice(&bytes) { + Ok(store) => store, + Err(e) => { + // Corrupt: move it aside instead of silently overwriting + // whatever the user had in there. + let aside = store_path.with_extension("json.corrupt"); + log::warn!( + "{} is corrupt ({e}); moving it to {}", + store_path.display(), + aside.display() + ); + std::fs::rename(store_path, &aside)?; + AccessStore::default() + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => AccessStore::default(), + Err(e) => return Err(e), + }; + + let grants = store.scripts.entry(script_key.to_string()).or_default(); + let entry = grants.entry(dir_key.to_string()).or_default(); + if update.read.is_some() { + entry.read = update.read; + } + if update.write.is_some() { + entry.write = update.write; + } + // Keep the file tidy: drop entries that no longer record anything. + grants.retain(|_, a| a.read.is_some() || a.write.is_some()); + store.scripts.retain(|_, g| !g.is_empty()); + + // Write-to-temp + rename: readers see the old or the new file, never a + // partial one, even across a crash mid-write. + let tmp = store_path.with_extension(format!("json.tmp.{}", std::process::id())); + let mut file = std::fs::File::create(&tmp)?; + file.write_all(&serde_json::to_vec_pretty(&store)?)?; + file.write_all(b"\n")?; + file.sync_all()?; + std::fs::rename(&tmp, store_path)?; + Ok(()) + // lock_file drops here, releasing the flock. +} + +#[cfg(test)] +mod tests { + use super::super::policy::AccessKind; + use super::*; + + fn store_path(dir: &tempfile::TempDir) -> PathBuf { + dir.path().join("cfg").join("access.json") + } + + #[test] + fn tri_state_round_trip() { + let json = r#"{ + "version": 1, + "scripts": { + "/s/deploy.lua": { + "/data": { "read": true, "write": true }, + "/etc": { "read": true, "write": false }, + "/secrets": { "read": null, "write": false } + } + } + }"#; + let store: AccessStore = serde_json::from_str(json).unwrap(); + let grants = &store.scripts["/s/deploy.lua"]; + assert_eq!(grants["/data"], DirAccess { read: Some(true), write: Some(true) }); + assert_eq!(grants["/etc"], DirAccess { read: Some(true), write: Some(false) }); + assert_eq!(grants["/secrets"], DirAccess { read: None, write: Some(false) }); + // null and absent both mean "unknown" + let sparse: DirAccess = serde_json::from_str(r#"{ "write": true }"#).unwrap(); + assert_eq!(sparse, DirAccess { read: None, write: Some(true) }); + // and null survives serialization (the file must distinguish it) + let out = serde_json::to_string(&grants["/secrets"]).unwrap(); + assert!(out.contains("\"read\":null"), "{out}"); + // round trip + let again: AccessStore = + serde_json::from_str(&serde_json::to_string(&store).unwrap()).unwrap(); + assert_eq!(again.scripts, store.scripts); + } + + #[test] + fn dir_access_get() { + let a = DirAccess { read: Some(true), write: Some(false) }; + assert_eq!(a.get(AccessKind::Read), Some(true)); + assert_eq!(a.get(AccessKind::Write), Some(false)); + } + + #[test] + fn persist_creates_dir_and_merges_without_clobbering() { + let tmp = tempfile::tempdir().unwrap(); + let path = store_path(&tmp); // parent "cfg" does not exist yet + + persist(&path, "/s/a.lua", "/data", DirAccess { read: Some(true), write: None }).unwrap(); + persist(&path, "/s/a.lua", "/data", DirAccess { read: None, write: Some(false) }).unwrap(); + + let grants = load_for_script(&path, "/s/a.lua"); + assert_eq!(grants["/data"], DirAccess { read: Some(true), write: Some(false) }); + // a second script's grants are separate + assert!(load_for_script(&path, "/s/b.lua").is_empty()); + } + + #[test] + fn persist_prunes_undecided_entries() { + let tmp = tempfile::tempdir().unwrap(); + let path = store_path(&tmp); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + // Hand-written file with a null-null entry (as a user might leave it). + std::fs::write( + &path, + r#"{ "version": 1, "scripts": { + "/s/a.lua": { "/dead": { "read": null, "write": null } }, + "/s/gone.lua": {} + } }"#, + ) + .unwrap(); + + persist(&path, "/s/a.lua", "/data", DirAccess { read: Some(true), write: None }).unwrap(); + + let store = read_store(&path); + assert!(!store.scripts["/s/a.lua"].contains_key("/dead")); + assert!(!store.scripts.contains_key("/s/gone.lua")); + assert_eq!(store.scripts["/s/a.lua"]["/data"].read, Some(true)); + } + + #[test] + fn persist_moves_corrupt_file_aside() { + let tmp = tempfile::tempdir().unwrap(); + let path = store_path(&tmp); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "{ not json").unwrap(); + + persist(&path, "/s/a.lua", "/data", DirAccess { read: Some(true), write: None }).unwrap(); + + assert_eq!( + std::fs::read_to_string(path.with_extension("json.corrupt")).unwrap(), + "{ not json" + ); + assert_eq!(load_for_script(&path, "/s/a.lua")["/data"].read, Some(true)); + } + + #[test] + fn missing_or_corrupt_reads_as_empty() { + let tmp = tempfile::tempdir().unwrap(); + let path = store_path(&tmp); + assert!(load_for_script(&path, "/s/a.lua").is_empty()); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "]]]").unwrap(); + assert!(load_for_script(&path, "/s/a.lua").is_empty()); + } + + #[test] + fn concurrent_persists_do_not_lose_updates() { + let tmp = tempfile::tempdir().unwrap(); + let path = store_path(&tmp); + let threads: Vec<_> = (0..8) + .map(|i| { + let path = path.clone(); + std::thread::spawn(move || { + for j in 0..10 { + persist( + &path, + "/s/a.lua", + &format!("/dir-{i}-{j}"), + DirAccess { read: Some(true), write: None }, + ) + .unwrap(); + } + }) + }) + .collect(); + for t in threads { + t.join().unwrap(); + } + // every one of the 80 grants survived the concurrent read-merge-writes + assert_eq!(load_for_script(&path, "/s/a.lua").len(), 80); + } + + #[test] + fn default_store_path_respects_xdg_rules() { + // Can't mutate the process environment safely in a threaded test + // runner; assert only on the shape of whatever the real env yields. + if let Some(p) = default_store_path() { + assert!(p.is_absolute()); + assert!(p.ends_with(format!("{APP_NAME}/access.json"))); + } + } +} diff --git a/src/stdlib/http.rs b/src/stdlib/http.rs index eb68065..0248341 100644 --- a/src/stdlib/http.rs +++ b/src/stdlib/http.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -173,6 +174,10 @@ async fn execute_request( url: String, opts: Option, ) -> LuaResult { + // Every request funnels through here (http.request, the shorthands, and + // all session methods) — the one choke point for --sandbox. + super::fs::ensure_net(&lua)?; + let mut method = reqwest::Method::from_bytes(method.to_ascii_uppercase().as_bytes()) .map_err(|e| mlua::Error::external(format!("http: invalid method '{method}': {e}")))?; @@ -471,22 +476,40 @@ fn make_session( })?, )?; + // Cookie-jar files hold live credentials; both directions go through the + // fs permission system (async only because the permission cycle is). tbl.raw_set("save", { let jar = jar.clone(); - lua.create_function(move |_, (_this, path): (LuaTable, String)| { - let jsonl = jar.lock().unwrap_or_else(|e| e.into_inner()).to_jsonl(); - std::fs::write(&path, jsonl) - .map_err(|e| mlua::Error::external(format!("session:save: {e}"))) + lua.create_async_function(move |lua, (_this, path): (LuaTable, String)| { + let jar = jar.clone(); + async move { + let (dir, target) = + super::fs::write_target("session:save", Path::new(&path)).await?; + super::fs::ensure_access(&lua, "session:save", dir, super::fs::Wants::WRITE) + .await?; + let jsonl = jar.lock().unwrap_or_else(|e| e.into_inner()).to_jsonl(); + tokio::fs::write(&target, jsonl) + .await + .map_err(|e| mlua::Error::external(format!("session:save: {e}"))) + } })? })?; tbl.raw_set("load", { let jar = jar.clone(); - lua.create_function(move |_, (_this, path): (LuaTable, String)| { - let src = std::fs::read_to_string(&path) - .map_err(|e| mlua::Error::external(format!("session:load: {e}")))?; - jar.lock().unwrap_or_else(|e| e.into_inner()).merge_jsonl(&src); - Ok(()) + lua.create_async_function(move |lua, (_this, path): (LuaTable, String)| { + let jar = jar.clone(); + async move { + let (dir, target) = + super::fs::read_target("session:load", Path::new(&path)).await?; + super::fs::ensure_access(&lua, "session:load", dir, super::fs::Wants::READ) + .await?; + let src = tokio::fs::read_to_string(&target) + .await + .map_err(|e| mlua::Error::external(format!("session:load: {e}")))?; + jar.lock().unwrap_or_else(|e| e.into_inner()).merge_jsonl(&src); + Ok(()) + } })? })?; @@ -564,7 +587,16 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { async move { let mut jar = CookieJar::default(); if let Some(p) = path { - let src = tokio::fs::read_to_string(&p) + let (dir, target) = + super::fs::read_target("http.session", Path::new(&p)).await?; + super::fs::ensure_access( + &lua, + "http.session", + dir, + super::fs::Wants::READ, + ) + .await?; + let src = tokio::fs::read_to_string(&target) .await .map_err(|e| mlua::Error::external(format!("http.session: {e}")))?; jar.merge_jsonl(&src); diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs index d53eef8..61d289d 100644 --- a/src/stdlib/mod.rs +++ b/src/stdlib/mod.rs @@ -1,3 +1,6 @@ +// pub(crate) for FsPolicy & co. — `main` builds the permission policy from +// the CLI flags and the script's canonical path. +pub(crate) mod fs; mod http; mod logging; mod lua_dump; @@ -22,6 +25,7 @@ pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> { logging::install(lua)?; sqlite::install(lua)?; http::install(lua)?; + fs::install(lua)?; task::install(lua)?; tui::install(lua)?; Ok(()) diff --git a/src/stdlib/require.rs b/src/stdlib/require.rs index 89654a2..eab6656 100644 --- a/src/stdlib/require.rs +++ b/src/stdlib/require.rs @@ -28,6 +28,7 @@ const BUILTINS: &[&str] = &[ "log", "sqlite", "http", + "fs", "task", "tui", "coroutine", diff --git a/src/stdlib/sqlite.rs b/src/stdlib/sqlite.rs index 050a4d1..33956a6 100644 --- a/src/stdlib/sqlite.rs +++ b/src/stdlib/sqlite.rs @@ -1,3 +1,4 @@ +use std::path::Path; use std::sync::{Arc, Mutex}; use mlua::prelude::LuaResult; @@ -404,6 +405,43 @@ impl UserData for SqliteConn { } } +// --------------------------------------------------------------------------- +// Path classification for the fs permission system +// --------------------------------------------------------------------------- + +/// Databases that live in memory (or in an anonymous temp file SQLite manages +/// itself): `""`, `":memory:"`, and their `file:` URI spellings. rusqlite's +/// default open flags include `SQLITE_OPEN_URI`, so URIs are live syntax. +fn is_memory_db(path: &str) -> bool { + if path.is_empty() || path == ":memory:" { + return true; + } + let Some(rest) = path.strip_prefix("file:") else { + return false; + }; + let (file, query) = rest.split_once('?').unwrap_or((rest, "")); + file == ":memory:" || query.split('&').any(|kv| kv == "mode=memory") +} + +/// The filesystem path of a file-backed database: strips the `file:` URI +/// scheme, authority and query, leaving what SQLite would open. +fn db_file_path(path: &str) -> &str { + let Some(rest) = path.strip_prefix("file:") else { + return path; + }; + let rest = rest.split_once('?').map_or(rest, |(file, _query)| file); + // "file://localhost/p" and "file:///p" carry an (empty) authority; + // "file:/p" and "file:p" do not. + if let Some(after) = rest.strip_prefix("//") { + let (authority, p) = after.split_once('/').unwrap_or((after, "")); + match authority { + "" | "localhost" => return &rest[2 + authority.len()..], + _ => return p, // nonsensical host: best effort + } + } + rest +} + // --------------------------------------------------------------------------- // Installation // --------------------------------------------------------------------------- @@ -413,7 +451,20 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { sqlite.set( "connect", - lua.create_async_function(|_, path: String| async move { + lua.create_async_function(|lua, path: String| async move { + // File-backed databases fall under the fs permission system. + // One combined read/write ask on the database's directory: SQLite + // opens read-write-create and drops WAL/journal files next to the + // file, so a read-only grant would be a lie. Memory-class + // databases touch no user-reachable file and stay exempt (they + // keep working even under --no-fs / --sandbox). + if !is_memory_db(&path) { + let (dir, _target) = + super::fs::write_target("sqlite.connect", Path::new(db_file_path(&path))) + .await?; + super::fs::ensure_access(&lua, "sqlite.connect", dir, super::fs::Wants::READ_WRITE) + .await?; + } // Opening a file-backed database touches disk, so do it on the // blocking pool rather than the tokio event loop. let conn = tokio::task::spawn_blocking(move || rusqlite::Connection::open(&path)) @@ -437,3 +488,29 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { let methods: LuaTable = sample.metatable()?.get("__index")?; init.call::<()>(methods) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn memory_db_detection() { + for mem in ["", ":memory:", "file::memory:", "file::memory:?cache=shared", "file:x.db?mode=memory", "file:x.db?cache=shared&mode=memory"] { + assert!(is_memory_db(mem), "{mem:?} should be memory-class"); + } + for disk in [":memory:x", "x.db", "/tmp/x.db", "file:x.db", "file:/tmp/x.db?cache=shared", "file:x.db?mode=memoryz"] { + assert!(!is_memory_db(disk), "{disk:?} should be file-backed"); + } + } + + #[test] + fn db_file_path_strips_uri_wrapping() { + assert_eq!(db_file_path("/tmp/x.db"), "/tmp/x.db"); + assert_eq!(db_file_path("x.db"), "x.db"); + assert_eq!(db_file_path("file:x.db"), "x.db"); + assert_eq!(db_file_path("file:/tmp/x.db"), "/tmp/x.db"); + assert_eq!(db_file_path("file:///tmp/x.db"), "/tmp/x.db"); + assert_eq!(db_file_path("file://localhost/tmp/x.db"), "/tmp/x.db"); + assert_eq!(db_file_path("file:/tmp/x.db?cache=shared&vfs=unix"), "/tmp/x.db"); + } +} diff --git a/src/stdlib/tui/mod.rs b/src/stdlib/tui/mod.rs index 5002652..96d9030 100644 --- a/src/stdlib/tui/mod.rs +++ b/src/stdlib/tui/mod.rs @@ -26,6 +26,11 @@ mod output; mod prompts; mod term; +// The fs module's permission prompt shares the terminal with tui's prompts +// and must take the same lock. (In test builds fs never opens a real prompt, +// leaving this re-export unused.) +#[cfg_attr(test, allow(unused_imports))] +pub(crate) use prompts::PROMPT_LOCK; pub(crate) use term::cleanup; use mlua::prelude::LuaResult; diff --git a/src/stdlib/tui/prompts.rs b/src/stdlib/tui/prompts.rs index 6f4dba0..b3a981d 100644 --- a/src/stdlib/tui/prompts.rs +++ b/src/stdlib/tui/prompts.rs @@ -36,8 +36,9 @@ use super::opts::{ /// Serializes terminal ownership across concurrent Lua coroutines. Locked /// inside the blocking closure, so a waiting prompt parks a blocking-pool /// thread, never the tokio event loop. Shared with `term`'s cursor-position -/// query, which also reads from the terminal. -pub(super) static PROMPT_LOCK: Mutex<()> = Mutex::new(()); +/// query and the fs module's permission prompt, which also read from the +/// terminal (re-exported as `stdlib::tui::PROMPT_LOCK`). +pub(crate) static PROMPT_LOCK: Mutex<()> = Mutex::new(()); /// `Send`-safe error carried out of the blocking prompt worker; the Lua-side /// wrapper prepends the `tui.:` prefix.