add prototype permission based fs access

master
Ondřej Hruška 2 weeks ago
parent b39d79ef04
commit 154f0b97bf
  1. 2
      Cargo.lock
  2. 6
      Cargo.toml
  3. 3
      README.md
  4. 1
      docs/README.md
  5. 157
      docs/fs.md
  6. 6
      docs/http.md
  7. 5
      docs/sqlite.md
  8. 57
      lua/stubs/fs.lua
  9. 3
      lua/stubs/sqlite.lua
  10. 412
      src/lua_tests/fs_tests.rs
  11. 1
      src/lua_tests/mod.rs
  12. 47
      src/main.rs
  13. 294
      src/stdlib/fs/mod.rs
  14. 519
      src/stdlib/fs/policy.rs
  15. 105
      src/stdlib/fs/prompt.rs
  16. 321
      src/stdlib/fs/store.rs
  17. 52
      src/stdlib/http.rs
  18. 4
      src/stdlib/mod.rs
  19. 1
      src/stdlib/require.rs
  20. 79
      src/stdlib/sqlite.rs
  21. 5
      src/stdlib/tui/mod.rs
  22. 5
      src/stdlib/tui/prompts.rs

2
Cargo.lock generated

@ -20,10 +20,12 @@ dependencies = [
"mlua", "mlua",
"reqwest", "reqwest",
"rusqlite", "rusqlite",
"rustix",
"rustls", "rustls",
"rustyline", "rustyline",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
"thiserror", "thiserror",
"tokio", "tokio",
] ]

@ -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"] } 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"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
rusqlite = { version = "0.32", features = ["bundled"] } 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" rustyline = "14"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150" serde_json = "1.0.150"
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["full"] } tokio = { version = "1.52.3", features = ["full"] }
digest_auth = "0.3" digest_auth = "0.3"
[dev-dependencies]
tempfile = "3"

@ -42,12 +42,13 @@ The standard library is the main purpose of `a`. Available today:
- **Networking** — HTTP client (WebSocket and MQTT are planned). - **Networking** — HTTP client (WebSocket and MQTT are planned).
- **SQLite** — access to embedded SQLite databases. - **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). - **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. - **Core helpers**`math`, `table`, and `utils` extensions, structured `log`, `os` time helpers, and `task.join` concurrency.
Planned: 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. - **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. The intent is for the common case to be a single call rather than a multi-step setup.

@ -15,6 +15,7 @@ also works and returns the same table).
| [`log`](log.md) | Level-tagged diagnostic logging (`trace`…`error`), gated by `RUST_LOG`. | | [`log`](log.md) | Level-tagged diagnostic logging (`trace`…`error`), gated by `RUST_LOG`. |
| [`sqlite`](sqlite.md) | Embedded SQLite: connect, query, parameters, transactions. | | [`sqlite`](sqlite.md) | Embedded SQLite: connect, query, parameters, transactions. |
| [`http`](http.md) | HTTP/HTTPS client: requests, JSON, auth, cookie-jar sessions. | | [`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. | | [`tui`](tui.md) | Interactive terminal prompts (confirm/text/select/…), ANSI-styled output, message blocks. |
## Concepts ## Concepts

@ -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`.

@ -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 Cookies live in memory for the session's lifetime. Save them to reuse a logged-in
session across script runs. 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)` #### `s:save(path)`
Write the jar to `path` as JSONL — one JSON object per line, one cookie per line: Write the jar to `path` as JSONL — one JSON object per line, one cookie per line:

@ -26,6 +26,11 @@ local con = sqlite.connect(path)
string `":memory:"` for a private in-memory database that vanishes when closed. The string `":memory:"` for a private in-memory database that vanishes when closed. The
returned connection is an object you call methods on with `:` syntax. 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 The connection closes automatically when it is garbage-collected. Call
[`con:close()`](#conclose) to release it eagerly. [`con:close()`](#conclose) to release it eagerly.

@ -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

@ -65,7 +65,8 @@ function Connection:rollback() end
function Connection:transaction(fn) end function Connection:transaction(fn) end
---Open a database file (created if missing), or ":memory:" for a private ---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 ---@param path string
---@return sqlite.Connection ---@return sqlite.Connection
function sqlite.connect(path) end function sqlite.connect(path) end

@ -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());
}

@ -3,6 +3,7 @@
mod async_tests; mod async_tests;
mod core_tests; mod core_tests;
mod fs_tests;
mod math_tests; mod math_tests;
mod os_tests; mod os_tests;
mod require_tests; mod require_tests;

@ -18,6 +18,43 @@ mod stdlib;
struct Args { struct Args {
/// Path to the Lua script to execute. Omit to start an interactive REPL. /// Path to the Lua script to execute. Omit to start an interactive REPL.
filepath: Option<PathBuf>, filepath: Option<PathBuf>,
/// 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<String>) -> 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] #[tokio::main]
@ -50,8 +87,11 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let lua = sandbox::create_sandboxed_lua()?; 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. // 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()?)?; setup_require(&lua, &std::env::current_dir()?)?;
return repl::run(&lua).await; return repl::run(&lua).await;
}; };
@ -65,6 +105,11 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let script_dir = realpath.parent().unwrap_or(Path::new("/")); let script_dir = realpath.parent().unwrap_or(Path::new("/"));
setup_require(&lua, script_dir)?; 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 // 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. // byte strings, and stock Lua runs files that aren't valid UTF-8.
let source = tokio::fs::read(&realpath) let source = tokio::fs::read(&realpath)

@ -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.<fn>: …`.
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<u8>> = 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<FsPolicy> {
lua.app_data_ref::<Arc<FsPolicy>>()
.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<bool> {
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<PathBuf> {
tokio::fs::canonicalize(path)
.await
.map_err(|e| ext_err!("{fname}: {}: {e}", path.display()))
}
async fn metadata(fname: &str, path: &Path, canon: &Path) -> LuaResult<std::fs::Metadata> {
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())),
}
}

@ -0,0 +1,519 @@
//! The permission policy — who may touch which directory.
//!
//! One [`FsPolicy`] lives in mlua app data per Lua state (as `Arc<FsPolicy>`).
//! `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<Item = AccessKind> {
[(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<String>,
/// This script's grants from access.json, snapshotted at startup.
persisted: Mutex<DirGrants>,
/// Session-only answers — y/n, plus A/N in the REPL. Shadows
/// `persisted`.
session: Mutex<DirGrants>,
/// Where A/N answers are persisted; `None` = no config dir found,
/// session-only operation.
store_path: Option<PathBuf>,
/// 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<std::collections::VecDeque<Choice>>,
},
}
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<String>,
persisted: DirGrants,
store_path: Option<PathBuf>,
) -> 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<String>,
persisted: DirGrants,
store_path: Option<PathBuf>,
answers: impl IntoIterator<Item = Choice>,
) -> 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<Self>, dir: PathBuf, wants: Wants) -> LuaResult<Outcome> {
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<bool> {
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<bool> {
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<bool> {
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<bool>, Option<bool>)]) -> 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"
);
}
}

@ -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<RawMode> {
crossterm::terminal::enable_raw_mode()?;
Ok(RawMode)
}
}
impl Drop for RawMode {
fn drop(&mut self) {
let _ = crossterm::terminal::disable_raw_mode();
}
}

@ -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<bool>,
#[serde(default)]
pub(crate) write: Option<bool>,
}
impl DirAccess {
pub(crate) fn get(&self, kind: super::policy::AccessKind) -> Option<bool> {
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<String, DirAccess>;
#[derive(Serialize, Deserialize)]
pub(crate) struct AccessStore {
#[serde(default = "version_default")]
pub(crate) version: u32,
#[serde(default)]
pub(crate) scripts: BTreeMap<String, DirGrants>,
}
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<PathBuf> {
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")));
}
}
}

@ -1,4 +1,5 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
@ -173,6 +174,10 @@ async fn execute_request(
url: String, url: String,
opts: Option<LuaTable>, opts: Option<LuaTable>,
) -> LuaResult<LuaTable> { ) -> LuaResult<LuaTable> {
// 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()) 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}")))?; .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", { tbl.raw_set("save", {
let jar = jar.clone(); let jar = jar.clone();
lua.create_function(move |_, (_this, path): (LuaTable, String)| { lua.create_async_function(move |lua, (_this, path): (LuaTable, String)| {
let jsonl = jar.lock().unwrap_or_else(|e| e.into_inner()).to_jsonl(); let jar = jar.clone();
std::fs::write(&path, jsonl) async move {
.map_err(|e| mlua::Error::external(format!("session:save: {e}"))) 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", { tbl.raw_set("load", {
let jar = jar.clone(); let jar = jar.clone();
lua.create_function(move |_, (_this, path): (LuaTable, String)| { lua.create_async_function(move |lua, (_this, path): (LuaTable, String)| {
let src = std::fs::read_to_string(&path) let jar = jar.clone();
.map_err(|e| mlua::Error::external(format!("session:load: {e}")))?; async move {
jar.lock().unwrap_or_else(|e| e.into_inner()).merge_jsonl(&src); let (dir, target) =
Ok(()) 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 { async move {
let mut jar = CookieJar::default(); let mut jar = CookieJar::default();
if let Some(p) = path { 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 .await
.map_err(|e| mlua::Error::external(format!("http.session: {e}")))?; .map_err(|e| mlua::Error::external(format!("http.session: {e}")))?;
jar.merge_jsonl(&src); jar.merge_jsonl(&src);

@ -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 http;
mod logging; mod logging;
mod lua_dump; mod lua_dump;
@ -22,6 +25,7 @@ pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
logging::install(lua)?; logging::install(lua)?;
sqlite::install(lua)?; sqlite::install(lua)?;
http::install(lua)?; http::install(lua)?;
fs::install(lua)?;
task::install(lua)?; task::install(lua)?;
tui::install(lua)?; tui::install(lua)?;
Ok(()) Ok(())

@ -28,6 +28,7 @@ const BUILTINS: &[&str] = &[
"log", "log",
"sqlite", "sqlite",
"http", "http",
"fs",
"task", "task",
"tui", "tui",
"coroutine", "coroutine",

@ -1,3 +1,4 @@
use std::path::Path;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use mlua::prelude::LuaResult; 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 // Installation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -413,7 +451,20 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
sqlite.set( sqlite.set(
"connect", "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 // Opening a file-backed database touches disk, so do it on the
// blocking pool rather than the tokio event loop. // blocking pool rather than the tokio event loop.
let conn = tokio::task::spawn_blocking(move || rusqlite::Connection::open(&path)) 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")?; let methods: LuaTable = sample.metatable()?.get("__index")?;
init.call::<()>(methods) 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");
}
}

@ -26,6 +26,11 @@ mod output;
mod prompts; mod prompts;
mod term; 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; pub(crate) use term::cleanup;
use mlua::prelude::LuaResult; use mlua::prelude::LuaResult;

@ -36,8 +36,9 @@ use super::opts::{
/// Serializes terminal ownership across concurrent Lua coroutines. Locked /// Serializes terminal ownership across concurrent Lua coroutines. Locked
/// inside the blocking closure, so a waiting prompt parks a blocking-pool /// inside the blocking closure, so a waiting prompt parks a blocking-pool
/// thread, never the tokio event loop. Shared with `term`'s cursor-position /// thread, never the tokio event loop. Shared with `term`'s cursor-position
/// query, which also reads from the terminal. /// query and the fs module's permission prompt, which also read from the
pub(super) static PROMPT_LOCK: Mutex<()> = Mutex::new(()); /// 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 /// `Send`-safe error carried out of the blocking prompt worker; the Lua-side
/// wrapper prepends the `tui.<fn>:` prefix. /// wrapper prepends the `tui.<fn>:` prefix.

Loading…
Cancel
Save