parent
b39d79ef04
commit
154f0b97bf
@ -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`. |
||||
@ -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 |
||||
@ -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()); |
||||
} |
||||
@ -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"))); |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue