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