diff --git a/README.md b/README.md index efd41e9..48c9f5b 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,9 @@ The result is a single binary that runs a `.lua` file. There is no package manag The standard library is the main purpose of Luna. Available today: -- **Networking** — HTTP client (WebSocket and MQTT are planned). +- **Networking** — HTTP client (WebSocket and MQTT are planned), guarded by a per-script, per-origin permission system (`--allow-net` and `--no-net` override it). - **SQLite** — access to embedded SQLite databases. -- **Filesystem** — whole-file read/write and directory listing via [`fs`](docs/fs.md), guarded by a per-script folder permission system (interactive grants recorded in `access.json`; `--allow-fs`, `--no-fs` and `--sandbox` override it). +- **Filesystem** — whole-file read/write and directory listing via [`fs`](docs/fs.md), guarded by the same permission system per folder (`--allow-fs` and `--no-fs` override it; interactive grants are recorded in `access.json`, and `--sandbox` denies both files and network). - **Terminal UI** — interactive prompts (confirm, text, select, password, number, date), styled/colored output, and terminal control (cursor addressing, alternate screen, scroll regions) via [`tui`](docs/tui.md). - **Core helpers** — `math`, `table`, and `utils` extensions, structured `log`, `os` time helpers, and `task.join` concurrency. diff --git a/docs/README.md b/docs/README.md index 8a5bde2..d1ed665 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ also works and returns the same table). | [`os`](os.md) | Sandbox additions: `microtime` (sub-second clock) and async `sleep`. | | [`log`](log.md) | Level-tagged diagnostic logging (`trace`…`error`), gated by `LUNA_LOG`. | | [`sqlite`](sqlite.md) | Embedded SQLite: connect, query, parameters, transactions. | -| [`http`](http.md) | HTTP/HTTPS client: requests, JSON, auth, cookie-jar sessions. | +| [`http`](http.md) | HTTP/HTTPS client: requests, JSON, auth, cookie-jar sessions, per-origin permissions. | | [`fs`](fs.md) | Whole-file read/write and directory listing, behind a per-script folder permission system. | | [`tui`](tui.md) | Interactive terminal prompts (confirm/text/select/…), ANSI-styled output, message blocks. | diff --git a/docs/fs.md b/docs/fs.md index 37b8868..82dd9bc 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -128,17 +128,24 @@ luna: permission request Recorded answers live in `$XDG_CONFIG_HOME/luna/access.json` (usually `~/.config/luna/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): +renaming a script starts it with a clean slate. Under each script, grants are +grouped by subsystem: directory grants sit under `"fs"`, network-origin grants +(see [`http`](http.md#permissions)) under `"net"`. 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 } + "fs": { + "/home/me/project": { "read": true, "write": true }, + "/etc": { "read": true, "write": false } + }, + "net": { + "https://api.example.com": { "access": true } + } } } } @@ -160,14 +167,17 @@ record them under. `--sandbox`. - A session's cookie-jar files (`http.session(path)`, `session:load`, `session:save`) are read/write checked like any other file. +- `http` requests run the same cycle per **network origin** instead of per + directory — see [Permissions in `http`](http.md#permissions). ## CLI flags | Flag | Effect | |------|--------| -| `--allow-fs` | Skip the permission system: everything granted, nothing asked or recorded. | +| `--allow-fs` | Skip the filesystem checks: 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`. | +| `--allow-net` / `--no-net` | The same two switches for network origins (see [`http`](http.md#permissions)). | +| `--sandbox` | `--no-fs` plus `--no-net`; overrides the `--allow-*` flags. | Denials from a flag read `fs.readAll: filesystem access disabled by --no-fs`, interactive/recorded ones `fs.readAll: access to /some/dir denied`. diff --git a/docs/http.md b/docs/http.md index e1c4548..c0f7799 100644 --- a/docs/http.md +++ b/docs/http.md @@ -44,6 +44,37 @@ filled in. local resp = http.request("DELETE", "https://api.example.com/items/42") ``` +## Permissions + +Every request first passes the per-script [permission system](fs.md#permissions) +— the same one that guards the filesystem. Recorded answers live under the +script's `"net"` section in `access.json`; unknown ones are asked on the +terminal: + +``` +luna: permission request + script: /home/me/bin/report.lua + wants: access to https://api.example.com +[y] allow once [n] deny once [A] allow always [N] never allow: +``` + +The permission unit is the URL's **origin** — scheme, host, and port, with +everything after the domain (and optional port) stripped: `https://example.com`, +`http://insecure.example.com:8090`. Credentials embedded in the URL +(`https://user:pass@host/…`) are never part of the origin; a scheme-default +port is omitted, any other port is kept. There is a single operation — +**access**, no read/write distinction — and grants are exact-match: approving +`https://example.com` says nothing about a subdomain, another port, or the +plain-`http` origin. + +A denial raises `http: access to denied`. Redirects within a request +are followed without a second check; sensitive headers are stripped on +cross-origin hops (see [Redirects](#redirects)). + +CLI flags override the interactive cycle: `--allow-net` grants every origin +silently, `--no-net` denies everything (`http: networking disabled by +--no-net`), and `--sandbox` implies `--no-net`. + ## The response Every request returns a table describing the response: @@ -264,11 +295,10 @@ Empty the in-memory jar. Cookies live in memory for the session's lifetime. Save them to reuse a logged-in session across script runs. -Jar files hold live credentials, so both directions run the -[fs permission system](fs.md#permissions) — write for `s:save`, read for -`s:load` and `http.session(path)`. Note also that `--sandbox` disables -networking entirely: every request errors with -`http: networking disabled by --sandbox`. +Jar files hold live credentials, so both directions run the **directory** +side of the [permission system](fs.md#permissions) — write for `s:save`, read +for `s:load` and `http.session(path)` — independently of the per-origin +checks the session's requests make. #### `s:save(path)` @@ -328,7 +358,7 @@ end - **HTTPS needs no setup.** The TLS stack (rustls + `ring`) is compiled in; trust roots come from the system certificate store. -- **A default `User-Agent` is sent** (`a/`) because some servers reject +- **A default `User-Agent` is sent** (`luna/`) because some servers reject requests without one. Override it with a `User-Agent` entry in `opts.headers`. - **Requests don't block the event loop.** Network I/O runs on the async core, so a slow request does not stall other async work (timers, `os.sleep`, SQLite) in diff --git a/lua/stubs/fs.lua b/lua/stubs/fs.lua index df07c0f..5830acb 100644 --- a/lua/stubs/fs.lua +++ b/lua/stubs/fs.lua @@ -6,7 +6,7 @@ -- (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. +-- `--allow-fs`, `--no-fs` and `--sandbox` override the filesystem checks. fs = {} diff --git a/lua/stubs/http.lua b/lua/stubs/http.lua index f64b77f..0bcccd8 100644 --- a/lua/stubs/http.lua +++ b/lua/stubs/http.lua @@ -8,6 +8,11 @@ -- -- A non-2xx status is NOT an error (see resp.ok/resp.status); only failing to -- get a response at all (DNS, connection, TLS, timeout) raises. +-- +-- Every request runs the per-origin permission system (docs/http.md): a +-- recorded grant for scheme://host[:port] answers silently, unknown origins +-- prompt on the terminal, denials raise. `--allow-net`, `--no-net` and +-- `--sandbox` override the network checks. http = {} diff --git a/src/lua_tests/fs_tests.rs b/src/lua_tests/fs_tests.rs index 9d2dd36..1708959 100644 --- a/src/lua_tests/fs_tests.rs +++ b/src/lua_tests/fs_tests.rs @@ -8,16 +8,22 @@ use std::sync::Arc; use super::lua; -use crate::stdlib::fs::{store, Choice, FsPolicy}; +use crate::stdlib::perm::{store, Choice, Override, Policy}; -/// The sandboxed state with an --allow-fs policy. +/// The sandboxed state with an allow-everything policy. fn lua_allow_all() -> mlua::Lua { let lua = lua(); - lua.set_app_data(Arc::new(FsPolicy::allow_all())); + lua.set_app_data(Arc::new(Policy::allow_all())); lua } -fn lua_with(policy: FsPolicy) -> mlua::Lua { +/// A policy with only the filesystem statically denied by `flag` (what +/// `--no-fs` builds); origins stay interactive. +fn deny_fs(flag: &'static str) -> Policy { + Policy::new(Some(Override::Deny { flag }), None, None, Default::default(), None) +} + +fn lua_with(policy: Policy) -> mlua::Lua { let lua = lua(); lua.set_app_data(Arc::new(policy)); lua @@ -159,7 +165,7 @@ async fn access_validates_arguments() { async fn get_work_dir_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 lua = lua_with(Policy::deny_all("--sandbox")); 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()); @@ -168,7 +174,7 @@ async fn get_work_dir_returns_the_cwd_ungated() { #[tokio::test] async fn get_script_dir_reports_planted_dir_ungated_and_nil_without_one() { // No ScriptDir in app data (REPL semantics): nil. - let lua = lua_with(FsPolicy::deny_all("--sandbox", false)); + let lua = lua_with(Policy::deny_all("--sandbox")); let noscript: Option = lua.load("return fs.getScriptDir()").eval_async().await.unwrap(); assert_eq!(noscript, None); @@ -182,7 +188,7 @@ async fn get_script_dir_reports_planted_dir_ungated_and_nil_without_one() { #[tokio::test] async fn deny_all_blocks_everything_without_prompting() { - let lua = lua_with(FsPolicy::deny_all("--no-fs", true)); + let lua = lua_with(deny_fs("--no-fs")); let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("f.txt"); std::fs::write(&file, "secret").unwrap(); @@ -238,7 +244,7 @@ async fn interactive_allow_once_grants_operations() { // 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( + let lua = lua_with(Policy::interactive_scripted( None, Default::default(), None, @@ -264,7 +270,7 @@ async fn interactive_deny_answer_blocks_and_read_grant_does_not_leak_to_write() // First answer grants the read; the write is a separate (write-kind) // question answered with DenyOnce. - let lua = lua_with(FsPolicy::interactive_scripted( + let lua = lua_with(Policy::interactive_scripted( None, Default::default(), None, @@ -290,7 +296,7 @@ async fn fs_access_allow_always_persists_and_is_honored_next_run() { let script_key = "/scripts/tool.lua"; // Run 1: fs.access asks, the user answers "A". - let lua = lua_with(FsPolicy::interactive_scripted( + let lua = lua_with(Policy::interactive_scripted( Some(script_key.into()), Default::default(), Some(store_path.clone()), @@ -306,12 +312,12 @@ async fn fs_access_allow_always_persists_and_is_honored_next_run() { // 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)); + assert_eq!(persisted.fs[&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( + let lua = lua_with(Policy::interactive_scripted( Some(script_key.into()), store::load_for_script(&store_path, script_key), Some(store_path.clone()), @@ -328,7 +334,7 @@ async fn fs_access_allow_always_persists_and_is_honored_next_run() { #[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 lua = lua_with(Policy::deny_all("--sandbox")); let n: i64 = lua .load( @@ -357,7 +363,7 @@ async fn sqlite_memory_db_works_under_sandbox_but_file_db_is_blocked() { 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( + let lua = lua_with(Policy::interactive_scripted( None, Default::default(), None, @@ -381,17 +387,20 @@ async fn sqlite_file_db_asks_one_combined_question() { #[tokio::test] async fn http_requests_are_blocked_under_sandbox_before_any_io() { - let lua = lua_with(FsPolicy::deny_all("--sandbox", false)); + let lua = lua_with(Policy::deny_all("--sandbox")); // 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)); + // --no-fs alone leaves networking interactive: the denial names the + // origin (nobody to ask here), not the flag. + let lua = lua_with(deny_fs("--no-fs")); let err = lua_err(&lua, "http.get('http://127.0.0.1:1/x')").await; - assert!(!err.contains("networking disabled"), "{err}"); + assert!( + err.contains("access to http://127.0.0.1:1 denied") && !err.contains("--no-fs"), + "{err}" + ); } #[tokio::test] @@ -400,7 +409,7 @@ async fn cookie_jar_files_go_through_the_permission_system() { 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 lua = lua_with(deny_fs("--no-fs")); let err = lua_err( &lua, &format!("local s = http.session() s:save('{}')", jar_path.display()), diff --git a/src/lua_tests/http_tests.rs b/src/lua_tests/http_tests.rs index 2816f39..174b961 100644 --- a/src/lua_tests/http_tests.rs +++ b/src/lua_tests/http_tests.rs @@ -12,7 +12,7 @@ use super::lua as sandboxed_lua; /// test policy is deny-all), plus a `BASE` global pointing at `server`. fn lua_net(server: &TestServer) -> mlua::Lua { let lua = sandboxed_lua(); - lua.set_app_data(Arc::new(crate::stdlib::fs::FsPolicy::allow_all())); + lua.set_app_data(Arc::new(crate::stdlib::perm::Policy::allow_all())); lua.globals().set("BASE", server.base.clone()).unwrap(); lua } @@ -541,3 +541,107 @@ async fn test_invalid_url_and_method() { assert!(server.requests().is_empty()); } + +// --------------------------------------------------------------------------- +// Origin permissions +// --------------------------------------------------------------------------- + +/// The stdlib Lua with an interactive policy fed by scripted prompt answers +/// (and a `BASE` global), for exercising the per-origin permission gate. +fn lua_scripted( + server: &TestServer, + script_key: Option<&str>, + store_path: Option, + answers: impl IntoIterator, +) -> mlua::Lua { + let lua = sandboxed_lua(); + lua.set_app_data(Arc::new(crate::stdlib::perm::Policy::interactive_scripted( + script_key.map(String::from), + Default::default(), + store_path, + answers, + ))); + lua.globals().set("BASE", server.base.clone()).unwrap(); + lua +} + +#[tokio::test] +async fn test_origin_denied_blocks_request_before_any_io() { + let server = serve(|_| resp(200, &[], "ok")); + // No scripted answers = nobody to ask: deny. + let lua = lua_scripted(&server, None, None, []); + + let err = lua.load("http.get(BASE .. '/x')").exec_async().await.unwrap_err().to_string(); + assert!( + err.contains(&format!("access to {} denied", server.base)), + "{err}" + ); + assert!(server.requests().is_empty(), "the request must never leave the process"); +} + +#[tokio::test] +async fn test_origin_allow_once_covers_the_whole_run() { + use crate::stdlib::perm::Choice; + let server = serve(|_| resp(200, &[], "ok")); + // Exactly one answer: the second request must be served from the session + // grant, not a second prompt. + let lua = lua_scripted(&server, None, None, [Choice::AllowOnce]); + + let (a, b): (String, String) = lua + .load("return http.get(BASE .. '/one').body, http.get(BASE .. '/two').body") + .eval_async() + .await + .unwrap(); + assert_eq!((a.as_str(), b.as_str()), ("ok", "ok")); + assert_eq!(server.requests().len(), 2); +} + +#[tokio::test] +async fn test_origin_allow_always_persists_under_net_key() { + use crate::stdlib::perm::{store, Choice}; + let server = serve(|_| resp(200, &[], "ok")); + let cfg = tempfile::tempdir().unwrap(); + let store_path = cfg.path().join("access.json"); + + // The URL carries credentials and a path; the recorded key must be the + // bare origin (BASE = scheme://host:port). + let lua = lua_scripted( + &server, + Some("/scripts/tool.lua"), + Some(store_path.clone()), + [Choice::AllowAlways], + ); + let with_creds = server.base.replace("http://", "http://user:secret@"); + lua.load(format!("http.get('{with_creds}/x?q=1')")).exec_async().await.unwrap(); + + let saved = store::load_for_script(&store_path, "/scripts/tool.lua"); + assert_eq!(saved.net[&server.base].access, Some(true)); + assert_eq!(saved.net.len(), 1, "exactly one origin recorded: {:?}", saved.net); + assert!(saved.fs.is_empty()); + // and the raw JSON nests it under the script's "net" section + let raw = std::fs::read_to_string(&store_path).unwrap(); + assert!(raw.contains("\"net\""), "{raw}"); + + // A fresh run loading the store needs no prompt (no answers scripted). + let lua = sandboxed_lua(); + lua.set_app_data(Arc::new(crate::stdlib::perm::Policy::interactive_scripted( + Some("/scripts/tool.lua".into()), + store::load_for_script(&store_path, "/scripts/tool.lua"), + Some(store_path), + [], + ))); + lua.globals().set("BASE", server.base.clone()).unwrap(); + let body: String = lua.load("return http.get(BASE .. '/y').body").eval_async().await.unwrap(); + assert_eq!(body, "ok"); +} + +#[tokio::test] +async fn test_origin_deny_answer_blocks_request() { + use crate::stdlib::perm::Choice; + let server = serve(|_| resp(200, &[], "ok")); + let lua = lua_scripted(&server, None, None, [Choice::DenyOnce]); + + let err = lua.load("http.get(BASE .. '/x')").exec_async().await.unwrap_err().to_string(); + assert!(err.contains("denied"), "{err}"); + assert!(server.requests().is_empty()); +} diff --git a/src/main.rs b/src/main.rs index 7d8104a..6441074 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,32 +28,53 @@ struct Args { #[arg(long)] no_fs: bool, - /// Deny filesystem access AND networking; overrides --allow-fs. + /// Grant network access to every origin without prompting or recording + /// anything. + #[arg(long, conflicts_with = "no_net")] + allow_net: bool, + + /// Deny all network access (http module). + #[arg(long)] + no_net: bool, + + /// Deny filesystem access AND networking; overrides the --allow-* flags. #[arg(long)] sandbox: bool, } impl Args { - /// The fs/network permission policy the flags call for. In the default - /// interactive mode, grants recorded for this script (keyed by its - /// canonical path) are loaded from access.json; the REPL passes `None` - /// and never persists anything. - fn fs_policy(&self, script_key: Option) -> stdlib::fs::FsPolicy { - use stdlib::fs::{store, FsPolicy}; - if self.sandbox { - FsPolicy::deny_all("--sandbox", false) - } else if self.no_fs { - FsPolicy::deny_all("--no-fs", true) - } else if self.allow_fs { - FsPolicy::allow_all() - } else { + /// The permission policy the flags call for. Filesystem and network are + /// overridden independently; whatever is left interactive consults the + /// grants recorded for this script (keyed by its canonical path) in + /// access.json — the REPL passes `None` and never persists anything. + fn policy(&self, script_key: Option) -> stdlib::perm::Policy { + use stdlib::perm::{store, Override, Policy}; + let choose = |allow: bool, deny: bool, deny_flag: &'static str| { + if self.sandbox { + Some(Override::Deny { flag: "--sandbox" }) + } else if deny { + Some(Override::Deny { flag: deny_flag }) + } else if allow { + Some(Override::Allow) + } else { + None + } + }; + let fs = choose(self.allow_fs, self.no_fs, "--no-fs"); + let net = choose(self.allow_net, self.no_net, "--no-net"); + + // The store is only relevant while something can still prompt. + let (persisted, store_path) = if fs.is_none() || net.is_none() { let store_path = store::default_store_path(); let persisted = match (&script_key, &store_path) { (Some(key), Some(path)) => store::load_for_script(path, key), _ => Default::default(), }; - FsPolicy::interactive(script_key, persisted, store_path) - } + (persisted, store_path) + } else { + (Default::default(), None) + }; + Policy::new(fs, net, script_key, persisted, store_path) } } @@ -96,7 +117,7 @@ async fn run() -> Result<(), Box> { // In the REPL, `.env` and modules are looked up from the CWD. // Permissions are prompted for but never persisted (no script // identity to record them under). - lua.set_app_data(std::sync::Arc::new(args.fs_policy(None))); + lua.set_app_data(std::sync::Arc::new(args.policy(None))); setup_require(&lua, &std::env::current_dir()?)?; return repl::run(&lua).await; }; @@ -113,9 +134,9 @@ async fn run() -> Result<(), Box> { lua.set_app_data(stdlib::fs::ScriptDir(script_dir.to_path_buf())); // The canonical path doubles as the script's identity in the permission - // store; this replaces the session-only default planted by fs::install. + // store; this replaces the session-only default planted by perm::install. let script_key = realpath.to_string_lossy().into_owned(); - lua.set_app_data(std::sync::Arc::new(args.fs_policy(Some(script_key)))); + lua.set_app_data(std::sync::Arc::new(args.policy(Some(script_key)))); // Read as bytes, not a UTF-8 String: Lua source (and its string literals) are // byte strings, and stock Lua runs files that aren't valid UTF-8. diff --git a/src/stdlib/fs/mod.rs b/src/stdlib/fs/mod.rs index 62ff299..7676a4c 100644 --- a/src/stdlib/fs/mod.rs +++ b/src/stdlib/fs/mod.rs @@ -1,39 +1,28 @@ -//! `fs` — whole-file filesystem access behind a folder-based permission -//! system. +//! `fs` — whole-file filesystem access behind the folder-based permission +//! system ([`super::perm`]). //! //! 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`). +//! Every operation is checked against a per-script, per-directory grant: +//! recorded answers live in `access.json`, unknown ones are asked on the +//! terminal — `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` statically override the +//! interactive checks (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; +//! checks via [`super::perm::ensure_dir_access`]. 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; +use super::perm::{self, Wants}; /// App-data slot for the main script's real (symlink-resolved) directory. /// `main` plants it before running a script; absent in the REPL and in bare @@ -47,12 +36,6 @@ macro_rules! ext_err { } 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( @@ -61,7 +44,7 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { 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?; + perm::ensure_dir_access(&lua, F, dir, Wants::READ).await?; let bytes = tokio::fs::read(&target) .await .map_err(|e| ext_err!("{F}: {}: {e}", path.display()))?; @@ -75,7 +58,7 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { 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?; + perm::ensure_dir_access(&lua, F, dir, Wants::WRITE).await?; tokio::fs::write(&target, &content.as_bytes()) .await .map_err(|e| ext_err!("{F}: {}: {e}", path.display())) @@ -92,7 +75,7 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { if !meta.is_dir() { return Err(ext_err!("{F}: {}: not a directory", path.display())); } - ensure_access(&lua, F, canon.clone(), Wants::READ).await?; + perm::ensure_dir_access(&lua, F, canon.clone(), Wants::READ).await?; let mut entries = tokio::fs::read_dir(&canon) .await @@ -124,7 +107,7 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { 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(meta) if meta.is_dir() => perm::check_dir_access(&lua, canon, Wants::READ).await, _ => Ok(false), } })?, @@ -139,7 +122,7 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { }; match tokio::fs::metadata(&canon).await { Ok(meta) if meta.is_file() => { - check_access(&lua, parent_dir(&canon), Wants::READ).await + perm::check_dir_access(&lua, parent_dir(&canon), Wants::READ).await } _ => Ok(false), } @@ -191,7 +174,7 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { if !meta.is_dir() { return Err(ext_err!("{F}: {}: not a directory", dir.display())); } - check_access(&lua, canon, wants).await + perm::check_dir_access(&lua, canon, wants).await })?, )?; @@ -199,47 +182,6 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { Ok(()) } -/// The active policy; fails closed if none was installed (cannot happen — -/// `install` plants a default). -fn active_policy(lua: &Lua) -> Arc { - lua.app_data_ref::>() - .map(|p| Arc::clone(&p)) - .unwrap_or_else(|| Arc::new(FsPolicy::deny_all("(internal: no fs policy)", false))) -} - -/// Run the permission cycle for the canonical directory `dir`; a denial is a -/// Lua error prefixed with `fname`. Shared with sqlite (`Wants::READ_WRITE` -/// on a database's directory) and http (cookie-jar files). -pub(super) async fn ensure_access( - lua: &Lua, - fname: &'static str, - dir: PathBuf, - wants: Wants, -) -> LuaResult<()> { - match active_policy(lua).check(dir.clone(), wants).await? { - policy::Outcome::Granted => Ok(()), - policy::Outcome::DeniedByFlag(flag) => { - Err(ext_err!("{fname}: filesystem access disabled by {flag}")) - } - policy::Outcome::Denied => Err(ext_err!("{fname}: access to {} denied", dir.display())), - } -} - -/// Bool-returning permission cycle (`fs.access`, the predicates): denial is -/// `false`, never an error. -async fn check_access(lua: &Lua, dir: PathBuf, wants: Wants) -> LuaResult { - Ok(matches!(active_policy(lua).check(dir, wants).await?, policy::Outcome::Granted)) -} - -/// Networking gate for `--sandbox`; called by http before any request. -pub(super) fn ensure_net(lua: &Lua) -> LuaResult<()> { - if active_policy(lua).net_allowed { - Ok(()) - } else { - Err(ext_err!("http: networking disabled by --sandbox")) - } -} - /// Lua strings are byte strings; on unix so are paths. fn to_path(s: &LuaString) -> PathBuf { use std::os::unix::ffi::OsStrExt; diff --git a/src/stdlib/fs/policy.rs b/src/stdlib/fs/policy.rs deleted file mode 100644 index 8f6d3a2..0000000 --- a/src/stdlib/fs/policy.rs +++ /dev/null @@ -1,519 +0,0 @@ -//! The permission policy — who may touch which directory. -//! -//! One [`FsPolicy`] lives in mlua app data per Lua state (as `Arc`). -//! `fs::install` plants a session-only default; `main` replaces it once the -//! CLI flags and the script's canonical path are known. -//! -//! Grants are recorded per canonical directory and apply recursively: the -//! deepest recorded ancestor decides, so a deny on `~/secrets` beats an allow -//! on `~`. Session answers (y/n, and everything in the REPL) shadow the -//! persisted store at every depth. - -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; - -use mlua::prelude::LuaResult; - -use super::prompt::Choice; -use super::store::{self, DirAccess, DirGrants}; - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(crate) enum AccessKind { - Read, - Write, -} - -/// Which directions an operation needs; `read && write` is sqlite's combined -/// "open this database" request, answered by a single prompt. -#[derive(Clone, Copy, Debug)] -pub(crate) struct Wants { - pub(crate) read: bool, - pub(crate) write: bool, -} - -impl Wants { - pub(crate) const READ: Wants = Wants { read: true, write: false }; - pub(crate) const WRITE: Wants = Wants { read: false, write: true }; - pub(crate) const READ_WRITE: Wants = Wants { read: true, write: true }; - - fn kinds(self) -> impl Iterator { - [(self.read, AccessKind::Read), (self.write, AccessKind::Write)] - .into_iter() - .filter_map(|(wanted, kind)| wanted.then_some(kind)) - } - - fn describe(self) -> &'static str { - match (self.read, self.write) { - (true, true) => "read/write", - (true, false) => "read", - (false, true) => "write", - (false, false) => "no", - } - } -} - -pub(crate) enum FsMode { - /// `--allow-fs`: everything granted, no prompts, no persistence. - AllowAll, - /// `--no-fs` / `--sandbox`: everything denied; `flag` names the culprit - /// for error messages. - DenyAll { flag: &'static str }, - /// Default: consult the recorded grants, prompt on unknown. - Interactive { - /// Store key = the script's canonical path. `None` in the REPL (and - /// in bare test states): prompts work, nothing is ever persisted. - script_key: Option, - /// This script's grants from access.json, snapshotted at startup. - persisted: Mutex, - /// Session-only answers — y/n, plus A/N in the REPL. Shadows - /// `persisted`. - session: Mutex, - /// Where A/N answers are persisted; `None` = no config dir found, - /// session-only operation. - store_path: Option, - /// Scripted prompt answers for tests, consumed front to back; an - /// exhausted (or absent) queue behaves like "no terminal". In test - /// builds the real terminal prompt is never reached (see `check`). - #[cfg(test)] - scripted: Mutex>, - }, -} - -pub(crate) struct FsPolicy { - pub(crate) mode: FsMode, - /// `false` only under `--sandbox`: http refuses all requests. - pub(crate) net_allowed: bool, -} - -/// Outcome of a permission check, kept apart from granted/denied booleans so -/// error messages can name the CLI flag that caused a static denial. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Outcome { - Granted, - /// Denied by `--no-fs` / `--sandbox`. - DeniedByFlag(&'static str), - /// Denied by a recorded decision, an interactive answer, or the inability - /// to ask (no terminal). - Denied, -} - -impl FsPolicy { - pub(crate) fn allow_all() -> Self { - FsPolicy { mode: FsMode::AllowAll, net_allowed: true } - } - - pub(crate) fn deny_all(flag: &'static str, net_allowed: bool) -> Self { - FsPolicy { mode: FsMode::DenyAll { flag }, net_allowed } - } - - pub(crate) fn interactive( - script_key: Option, - persisted: DirGrants, - store_path: Option, - ) -> Self { - FsPolicy { - mode: FsMode::Interactive { - script_key, - persisted: Mutex::new(persisted), - session: Mutex::new(BTreeMap::new()), - store_path, - #[cfg(test)] - scripted: Mutex::new(std::collections::VecDeque::new()), - }, - net_allowed: true, - } - } - - /// An interactive policy with a queue of pre-recorded prompt answers. - #[cfg(test)] - pub(crate) fn interactive_scripted( - script_key: Option, - persisted: DirGrants, - store_path: Option, - answers: impl IntoIterator, - ) -> Self { - let policy = Self::interactive(script_key, persisted, store_path); - if let FsMode::Interactive { scripted, .. } = &policy.mode { - scripted.lock().unwrap().extend(answers); - } - policy - } - - /// The full permission cycle for the canonical directory `dir`: recorded - /// answer, else ask on the terminal, else deny (recording nothing — a - /// persisted denial must be an explicit user decision). - pub(crate) async fn check(self: Arc, dir: PathBuf, wants: Wants) -> LuaResult { - match &self.mode { - FsMode::AllowAll => return Ok(Outcome::Granted), - FsMode::DenyAll { flag } => return Ok(Outcome::DeniedByFlag(flag)), - FsMode::Interactive { .. } => {} - } - - // Fast path — already decided, no prompt needed. - match self.resolve_wants(&dir, wants) { - Some(true) => return Ok(Outcome::Granted), - Some(false) => return Ok(Outcome::Denied), - None => {} - } - - // Test builds never reach a real terminal prompt: scripted answers - // only. This is structural — an unwitting test cannot hang `cargo - // test` on a keypress. - #[cfg(test)] - { - let FsMode::Interactive { scripted, .. } = &self.mode else { unreachable!() }; - let choice = { - let mut answers = scripted.lock().unwrap_or_else(|e| e.into_inner()); - answers.pop_front().unwrap_or(Choice::NoTty) - }; - Ok(self.decide(&dir, wants, |_, _, _| choice)) - } - - #[cfg(not(test))] - { - if !super::prompt::can_prompt() { - return Ok(Outcome::Denied); - } - let this = Arc::clone(&self); - tokio::task::spawn_blocking(move || { - // Serialize with tui prompts (and each other) so concurrent - // coroutines can't fight over the terminal. - let _guard = crate::stdlib::tui::PROMPT_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - this.decide(&dir, wants, super::prompt::prompt_access) - }) - .await - .map_err(|e| mlua::Error::external(format!("fs: prompt task failed: {e}"))) - } - } - - /// The ask-and-record step, run with the prompt lock held. Re-resolves - /// first — another coroutine may have answered for this directory while - /// we waited for the lock — then asks only for what is still unknown. - fn decide(&self, dir: &Path, wants: Wants, ask: impl FnOnce(&str, &str, &str) -> Choice) -> Outcome { - let FsMode::Interactive { script_key, session, store_path, .. } = &self.mode else { - unreachable!("decide() is only called in interactive mode"); - }; - - match self.resolve_wants(dir, wants) { - Some(true) => return Outcome::Granted, - Some(false) => return Outcome::Denied, - None => {} - } - let missing = Wants { - read: wants.read && self.resolve_one(dir, AccessKind::Read).is_none(), - write: wants.write && self.resolve_one(dir, AccessKind::Write).is_none(), - }; - - let label = script_key.as_deref().unwrap_or("interactive session (REPL)"); - let dir_key = dir.to_string_lossy().into_owned(); - let choice = ask(label, &dir_key, missing.describe()); - let value = match choice { - Choice::AllowOnce | Choice::AllowAlways => true, - Choice::DenyOnce | Choice::DenyAlways => false, - Choice::NoTty => return Outcome::Denied, // record nothing - }; - - let update = DirAccess { - read: missing.read.then_some(value), - write: missing.write.then_some(value), - }; - { - let mut session = session.lock().unwrap_or_else(|e| e.into_inner()); - let entry = session.entry(dir_key.clone()).or_default(); - if update.read.is_some() { - entry.read = update.read; - } - if update.write.is_some() { - entry.write = update.write; - } - } - if matches!(choice, Choice::AllowAlways | Choice::DenyAlways) - && let (Some(key), Some(path)) = (script_key, store_path) - && let Err(e) = store::persist(path, key, &dir_key, update) - { - // The answer still holds for this run; failing to record it must - // not fail the script's operation. - eprintln!( - "{}: warning: cannot save permission to {}: {e}", - store::APP_NAME, - path.display() - ); - } - - // `missing` now carries the fresh answer; every other wanted kind - // already resolved to true (a false would have denied above). - if value { Outcome::Granted } else { Outcome::Denied } - } - - /// `Some(true)` = every wanted kind granted, `Some(false)` = at least one - /// denied, `None` = at least one undecided (and none denied). - fn resolve_wants(&self, dir: &Path, wants: Wants) -> Option { - let mut all_known = true; - for kind in wants.kinds() { - match self.resolve_one(dir, kind) { - Some(false) => return Some(false), - Some(true) => {} - None => all_known = false, - } - } - all_known.then_some(true) - } - - fn resolve_one(&self, dir: &Path, kind: AccessKind) -> Option { - let FsMode::Interactive { session, persisted, .. } = &self.mode else { - return None; - }; - let session = session.lock().unwrap_or_else(|e| e.into_inner()); - let persisted = persisted.lock().unwrap_or_else(|e| e.into_inner()); - resolve(&session, &persisted, dir, kind) - } -} - -/// Walk from `dir` up to and including `/`. At each depth the session layer -/// shadows the persisted one; the first decided value wins — the deepest -/// recorded ancestor decides. -fn resolve(session: &DirGrants, persisted: &DirGrants, dir: &Path, kind: AccessKind) -> Option { - let mut cur = Some(dir); - while let Some(d) = cur { - let key = d.to_string_lossy(); - for layer in [session, persisted] { - if let Some(v) = layer.get(key.as_ref()).and_then(|a| a.get(kind)) { - return Some(v); - } - } - cur = d.parent(); - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - - fn grants(entries: &[(&str, Option, Option)]) -> DirGrants { - entries - .iter() - .map(|(dir, read, write)| { - (dir.to_string(), DirAccess { read: *read, write: *write }) - }) - .collect() - } - - #[test] - fn resolve_walks_ancestors() { - let persisted = grants(&[("/home/u/data", Some(true), None)]); - let session = DirGrants::new(); - let r = |dir: &str| resolve(&session, &persisted, Path::new(dir), AccessKind::Read); - assert_eq!(r("/home/u/data"), Some(true), "exact hit"); - assert_eq!(r("/home/u/data/sub/deep"), Some(true), "parent covers children"); - assert_eq!(r("/home/u"), None, "no upward leakage"); - assert_eq!(r("/elsewhere"), None, "unrelated dir unknown"); - } - - #[test] - fn resolve_deepest_ancestor_wins() { - let persisted = grants(&[ - ("/home/u", Some(true), None), - ("/home/u/secrets", Some(false), None), - ]); - let session = DirGrants::new(); - let r = |dir: &str| resolve(&session, &persisted, Path::new(dir), AccessKind::Read); - assert_eq!(r("/home/u/data"), Some(true)); - assert_eq!(r("/home/u/secrets"), Some(false), "deny beats shallower allow"); - assert_eq!(r("/home/u/secrets/inner"), Some(false)); - } - - #[test] - fn resolve_session_shadows_persisted() { - let persisted = grants(&[("/data", Some(false), None)]); - let session = grants(&[("/data", Some(true), None)]); - assert_eq!( - resolve(&session, &persisted, Path::new("/data/x"), AccessKind::Read), - Some(true) - ); - } - - #[test] - fn resolve_null_flags_are_skipped() { - // read is undecided at the deep entry; the shallow decided one wins. - let persisted = grants(&[("/a", Some(true), None), ("/a/b", None, Some(false))]); - let session = DirGrants::new(); - assert_eq!(resolve(&session, &persisted, Path::new("/a/b"), AccessKind::Read), Some(true)); - assert_eq!(resolve(&session, &persisted, Path::new("/a/b"), AccessKind::Write), Some(false)); - } - - #[test] - fn resolve_root_grant_covers_everything() { - let persisted = grants(&[("/", Some(true), Some(true))]); - let session = DirGrants::new(); - assert_eq!( - resolve(&session, &persisted, Path::new("/any/where"), AccessKind::Write), - Some(true) - ); - } - - #[tokio::test] - async fn static_modes() { - let allow = Arc::new(FsPolicy::allow_all()); - assert_eq!(allow.check("/x".into(), Wants::READ).await.unwrap(), Outcome::Granted); - let deny = Arc::new(FsPolicy::deny_all("--no-fs", true)); - assert_eq!( - Arc::clone(&deny).check("/x".into(), Wants::WRITE).await.unwrap(), - Outcome::DeniedByFlag("--no-fs") - ); - assert!(deny.net_allowed); - assert!(!FsPolicy::deny_all("--sandbox", false).net_allowed); - } - - #[tokio::test] - async fn allow_once_grants_for_the_whole_run() { - let policy = Arc::new(FsPolicy::interactive_scripted( - None, - DirGrants::new(), - None, - [Choice::AllowOnce], // exactly one answer available - )); - let p = Arc::clone(&policy); - assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted); - // second check hits the session cache — no second answer consumed - // (the queue is empty; consuming would mean NoTty => Denied) - let p = Arc::clone(&policy); - assert_eq!(p.check("/data/sub".into(), Wants::READ).await.unwrap(), Outcome::Granted); - } - - #[tokio::test] - async fn deny_once_is_cached_for_the_run() { - let policy = Arc::new(FsPolicy::interactive_scripted( - None, - DirGrants::new(), - None, - [Choice::DenyOnce], - )); - let p = Arc::clone(&policy); - assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Denied); - let p = Arc::clone(&policy); - assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Denied); - } - - #[tokio::test] - async fn allow_always_persists_and_covers_subdirs() { - let tmp = tempfile::tempdir().unwrap(); - let store_path = tmp.path().join("access.json"); - let policy = Arc::new(FsPolicy::interactive_scripted( - Some("/s/tool.lua".into()), - DirGrants::new(), - Some(store_path.clone()), - [Choice::AllowAlways], - )); - let p = Arc::clone(&policy); - assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted); - - let saved = crate::stdlib::fs::store::load_for_script(&store_path, "/s/tool.lua"); - assert_eq!(saved["/data"], DirAccess { read: Some(true), write: None }); - - let p = Arc::clone(&policy); - assert_eq!(p.check("/data/deep".into(), Wants::READ).await.unwrap(), Outcome::Granted); - } - - #[tokio::test] - async fn deny_always_persists_false() { - let tmp = tempfile::tempdir().unwrap(); - let store_path = tmp.path().join("access.json"); - let policy = Arc::new(FsPolicy::interactive_scripted( - Some("/s/tool.lua".into()), - DirGrants::new(), - Some(store_path.clone()), - [Choice::DenyAlways], - )); - let p = Arc::clone(&policy); - assert_eq!(p.check("/secrets".into(), Wants::WRITE).await.unwrap(), Outcome::Denied); - - let saved = crate::stdlib::fs::store::load_for_script(&store_path, "/s/tool.lua"); - assert_eq!(saved["/secrets"], DirAccess { read: None, write: Some(false) }); - } - - #[tokio::test] - async fn repl_always_answers_never_persist() { - let tmp = tempfile::tempdir().unwrap(); - let store_path = tmp.path().join("access.json"); - // script_key = None (REPL): store_path present but must not be used - let policy = Arc::new(FsPolicy::interactive_scripted( - None, - DirGrants::new(), - Some(store_path.clone()), - [Choice::AllowAlways], - )); - let p = Arc::clone(&policy); - assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted); - assert!(!store_path.exists(), "REPL grant must not create the store"); - // ...but it holds for the session - let p = Arc::clone(&policy); - assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted); - } - - #[tokio::test] - async fn unknown_without_terminal_denies_and_records_nothing() { - let tmp = tempfile::tempdir().unwrap(); - let store_path = tmp.path().join("access.json"); - // empty answer queue = "no terminal" - let policy = Arc::new(FsPolicy::interactive_scripted( - Some("/s/tool.lua".into()), - DirGrants::new(), - Some(store_path.clone()), - [], - )); - let p = Arc::clone(&policy); - assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Denied); - assert!(!store_path.exists()); - // nothing was cached either: a persisted grant arriving later (other - // process) would be honored — verify session stayed empty - if let FsMode::Interactive { session, .. } = &policy.mode { - assert!(session.lock().unwrap().is_empty()); - } - } - - #[tokio::test] - async fn persisted_grants_answer_without_prompting() { - let persisted = grants(&[("/data", Some(true), Some(false))]); - let policy = Arc::new(FsPolicy::interactive_scripted( - Some("/s/tool.lua".into()), - persisted, - None, - [], // no answers available: any prompt would deny - )); - let p = Arc::clone(&policy); - assert_eq!(p.check("/data/x".into(), Wants::READ).await.unwrap(), Outcome::Granted); - let p = Arc::clone(&policy); - assert_eq!(p.check("/data/x".into(), Wants::WRITE).await.unwrap(), Outcome::Denied); - // combined read/write: the recorded write=false denies without asking - let p = Arc::clone(&policy); - assert_eq!(p.check("/data/x".into(), Wants::READ_WRITE).await.unwrap(), Outcome::Denied); - } - - #[tokio::test] - async fn combined_prompt_records_only_missing_kinds() { - let tmp = tempfile::tempdir().unwrap(); - let store_path = tmp.path().join("access.json"); - // read already granted; a READ_WRITE check must only ask (and record) - // the missing write flag - let persisted = grants(&[("/db", Some(true), None)]); - let policy = Arc::new(FsPolicy::interactive_scripted( - Some("/s/tool.lua".into()), - persisted, - Some(store_path.clone()), - [Choice::AllowAlways], - )); - let p = Arc::clone(&policy); - assert_eq!(p.check("/db".into(), Wants::READ_WRITE).await.unwrap(), Outcome::Granted); - - let saved = crate::stdlib::fs::store::load_for_script(&store_path, "/s/tool.lua"); - assert_eq!( - saved["/db"], - DirAccess { read: None, write: Some(true) }, - "read was already known; only write gets recorded" - ); - } -} diff --git a/src/stdlib/http.rs b/src/stdlib/http.rs index 9131fe7..2934836 100644 --- a/src/stdlib/http.rs +++ b/src/stdlib/http.rs @@ -258,14 +258,17 @@ async fn execute_request( url: String, opts: Option, ) -> LuaResult { - // Every request funnels through here (http.request, the shorthands, and - // all session methods) — the one choke point for --sandbox. - super::fs::ensure_net(&lua)?; - let method = reqwest::Method::from_bytes(method.to_ascii_uppercase().as_bytes()) .map_err(|e| mlua::Error::external(format!("http: invalid method '{method}': {e}")))?; let url = reqwest::Url::parse(&url) .map_err(|e| mlua::Error::external(format!("http: invalid URL '{url}': {e}")))?; + + // Every request funnels through here (http.request, the shorthands, and + // all session methods) — the one choke point for the permission system. + // The unit is the URL's origin; a redirect that stays within reqwest's + // auto-follow is not re-checked (the client strips sensitive headers on + // cross-origin hops). + super::perm::ensure_origin_access(&lua, &origin_key(&url)?).await?; let o = parse_opts(opts.as_ref())?; let resp = build_request(&client, jar.as_deref(), &method, &url, &o, None) @@ -331,6 +334,21 @@ async fn execute_request( Ok(out) } +/// The permission unit for a request: the URL's origin, `scheme://host` with +/// a `:port` suffix only when it isn't the scheme's default — e.g. +/// `https://example.com`, `http://insecure.example.com:8090`. Credentials +/// (basic auth in the URL), path, query and fragment are stripped; the host +/// comes back lowercased and punycoded. +fn origin_key(url: &reqwest::Url) -> LuaResult { + if !matches!(url.scheme(), "http" | "https") { + return Err(mlua::Error::external(format!( + "http: unsupported URL scheme '{}'", + url.scheme() + ))); + } + Ok(url.origin().ascii_serialization()) +} + // --------------------------------------------------------------------------- // Client construction // --------------------------------------------------------------------------- @@ -387,7 +405,7 @@ fn make_session( async move { let (dir, target) = super::fs::write_target("session:save", Path::new(&path)).await?; - super::fs::ensure_access(&lua, "session:save", dir, super::fs::Wants::WRITE) + super::perm::ensure_dir_access(&lua, "session:save", dir, super::perm::Wants::WRITE) .await?; // Session cookies (no Expires/Max-Age — most login tokens) are // exactly what the caller wants to persist, so include them. @@ -411,7 +429,7 @@ fn make_session( async move { let (dir, target) = super::fs::read_target("session:load", Path::new(&path)).await?; - super::fs::ensure_access(&lua, "session:load", dir, super::fs::Wants::READ) + super::perm::ensure_dir_access(&lua, "session:load", dir, super::perm::Wants::READ) .await?; let src = tokio::fs::read_to_string(&target) .await @@ -500,7 +518,7 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { if let Some(p) = path { let (dir, target) = super::fs::read_target("http.session", Path::new(&p)).await?; - super::fs::ensure_access(&lua, "http.session", dir, super::fs::Wants::READ) + super::perm::ensure_dir_access(&lua, "http.session", dir, super::perm::Wants::READ) .await?; let src = tokio::fs::read_to_string(&target) .await @@ -540,6 +558,24 @@ mod tests { v } + #[test] + fn origin_key_is_scheme_host_and_nondefault_port() { + // Credentials, path, query and fragment are stripped; the host is + // lowercased; a default port is omitted, a non-default one kept. + for (input, expected) in [ + ("https://example.com/a/b?q=1#frag", "https://example.com"), + ("https://user:secret@Example.COM:443/x", "https://example.com"), + ("http://user@example.com/", "http://example.com"), + ("http://insecure.example.com:8090/api", "http://insecure.example.com:8090"), + ("https://example.com:8443", "https://example.com:8443"), + ] { + assert_eq!(origin_key(&url(input)).unwrap(), expected, "{input}"); + } + + let err = origin_key(&url("ftp://example.com/f")).unwrap_err().to_string(); + assert!(err.contains("unsupported URL scheme"), "{err}"); + } + #[test] fn rejects_public_suffix_domain() { let mut store = new_cookie_store(); diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs index 61d289d..30e33af 100644 --- a/src/stdlib/mod.rs +++ b/src/stdlib/mod.rs @@ -1,11 +1,14 @@ -// pub(crate) for FsPolicy & co. — `main` builds the permission policy from -// the CLI flags and the script's canonical path. +// pub(crate) for ScriptDir — `main` plants the script's real directory for +// fs.getScriptDir(). pub(crate) mod fs; mod http; mod logging; mod lua_dump; mod math; mod os_ext; +// pub(crate) for Policy & co. — `main` builds the permission policy from +// the CLI flags and the script's canonical path. +pub(crate) mod perm; // Installed separately from `install`, since it needs the module search roots // which are only known once the script path (or REPL CWD) is. pub(crate) mod require; @@ -18,6 +21,7 @@ pub(crate) mod tui; pub(crate) mod utils; pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> { + perm::install(lua); math::install(lua)?; table::install(lua)?; utils::install(lua)?; diff --git a/src/stdlib/perm/mod.rs b/src/stdlib/perm/mod.rs new file mode 100644 index 0000000..32d7f27 --- /dev/null +++ b/src/stdlib/perm/mod.rs @@ -0,0 +1,92 @@ +//! The per-script permission system, shared by every subsystem that reaches +//! outside the sandbox. +//! +//! Two kinds of resources are guarded: +//! +//! - **Directories** — the `fs` module, sqlite databases, http cookie-jar +//! files. Grants are per canonical directory with separate read/write +//! flags, and apply recursively (the deepest recorded ancestor decides). +//! - **Network origins** — the `http` module. Grants are per exact origin +//! (`scheme://host[:port]`) with a single access flag. +//! +//! 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 keyed by the script's canonical path; the REPL prompts the same +//! way but never persists. CLI flags (`--allow-fs`, `--no-fs`, `--allow-net`, +//! `--no-net`, `--sandbox`) statically override one or both resource kinds +//! (see `main.rs`). + +pub(crate) mod policy; +// In test builds `Policy::check_*` never reach 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::PathBuf; +use std::sync::Arc; + +use mlua::prelude::LuaResult; +use mlua::Lua; + +pub(crate) use policy::{Override, Policy, Wants}; +#[cfg(test)] +pub(crate) use prompt::Choice; + +/// `mlua::Error::external(format!(...))` — every Lua-facing error here goes +/// through this, prefixed with the name of the operation being denied. +macro_rules! ext_err { + ($($arg:tt)*) => { mlua::Error::external(format!($($arg)*)) }; +} + +/// Plant the 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. +pub(super) fn install(lua: &Lua) { + lua.set_app_data(Arc::new(Policy::interactive(None, Default::default(), None))); +} + +/// The active policy; fails closed if none was installed (cannot happen — +/// `install` plants a default). +fn active_policy(lua: &Lua) -> Arc { + lua.app_data_ref::>() + .map(|p| Arc::clone(&p)) + .unwrap_or_else(|| Arc::new(Policy::deny_all("(internal: no permission policy)"))) +} + +/// Run the permission cycle for the canonical directory `dir`; a denial is a +/// Lua error prefixed with `fname`. Shared by fs, sqlite (`Wants::READ_WRITE` +/// on a database's directory) and http (cookie-jar files). +pub(super) async fn ensure_dir_access( + lua: &Lua, + fname: &'static str, + dir: PathBuf, + wants: Wants, +) -> LuaResult<()> { + match active_policy(lua).check_dir(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 directory permission cycle (`fs.access`, the predicates): +/// denial is `false`, never an error. +pub(super) async fn check_dir_access(lua: &Lua, dir: PathBuf, wants: Wants) -> LuaResult { + Ok(matches!(active_policy(lua).check_dir(dir, wants).await?, policy::Outcome::Granted)) +} + +/// Run the permission cycle for a network origin (`scheme://host[:port]`); +/// a denial is a Lua error. Called by http before every request. +pub(super) async fn ensure_origin_access(lua: &Lua, origin: &str) -> LuaResult<()> { + match active_policy(lua).check_origin(origin.to_string()).await? { + policy::Outcome::Granted => Ok(()), + policy::Outcome::DeniedByFlag(flag) => { + Err(ext_err!("http: networking disabled by {flag}")) + } + policy::Outcome::Denied => Err(ext_err!("http: access to {origin} denied")), + } +} diff --git a/src/stdlib/perm/policy.rs b/src/stdlib/perm/policy.rs new file mode 100644 index 0000000..5066fa8 --- /dev/null +++ b/src/stdlib/perm/policy.rs @@ -0,0 +1,775 @@ +//! The permission policy — who may touch which directory or network origin. +//! +//! One [`Policy`] lives in mlua app data per Lua state (as `Arc`). +//! `perm::install` plants a session-only default; `main` replaces it once the +//! CLI flags and the script's canonical path are known. +//! +//! Directory grants are recorded per canonical directory and apply +//! recursively: the deepest recorded ancestor decides, so a deny on +//! `~/secrets` beats an allow on `~`. Origin grants are exact-match — a grant +//! for `https://example.com` says nothing about other hosts, subdomains, +//! schemes or ports. Session answers (y/n, and everything in the REPL) shadow +//! the persisted store at every depth. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use mlua::prelude::LuaResult; + +use super::prompt::Choice; +use super::store::{self, DirAccess, DirGrants, OriginAccess, ScriptGrants}; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum AccessKind { + Read, + Write, +} + +/// Which directions a directory operation needs; `read && write` is sqlite's +/// combined "open this database" request, answered by a single prompt. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Wants { + pub(crate) read: bool, + pub(crate) write: bool, +} + +impl Wants { + pub(crate) const READ: Wants = Wants { read: true, write: false }; + pub(crate) const WRITE: Wants = Wants { read: false, write: true }; + pub(crate) const READ_WRITE: Wants = Wants { read: true, write: true }; + + fn kinds(self) -> impl Iterator { + [(self.read, AccessKind::Read), (self.write, AccessKind::Write)] + .into_iter() + .filter_map(|(wanted, kind)| wanted.then_some(kind)) + } + + fn describe(self) -> &'static str { + match (self.read, self.write) { + (true, true) => "read/write", + (true, false) => "read", + (false, true) => "write", + (false, false) => "no", + } + } +} + +/// A CLI flag's static answer for one resource kind; interactive when absent. +#[derive(Clone, Copy, Debug)] +pub(crate) enum Override { + /// `--allow-fs` / `--allow-net`: granted, no prompts, no persistence. + Allow, + /// `--no-fs` / `--no-net` / `--sandbox`: denied; `flag` names the culprit + /// for error messages. + Deny { flag: &'static str }, +} + +pub(crate) struct Policy { + /// Static answer for directory checks; `None` = interactive. + fs_override: Option, + /// Static answer for origin checks; `None` = interactive. + net_override: Option, + /// Store key = the script's canonical path. `None` in the REPL (and in + /// bare test states): prompts work, nothing is ever persisted. + script_key: Option, + /// This script's grants from access.json, snapshotted at startup. + persisted: Mutex, + /// Session-only answers — y/n, plus A/N in the REPL. Shadows `persisted`. + session: Mutex, + /// Where A/N answers are persisted; `None` = no config dir found, + /// session-only operation. + store_path: Option, + /// Scripted prompt answers for tests, consumed front to back; an + /// exhausted (or absent) queue behaves like "no terminal". In test + /// builds the real terminal prompt is never reached (see `prompted`). + #[cfg(test)] + scripted: Mutex>, +} + +/// 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` / `--no-net` / `--sandbox`. + DeniedByFlag(&'static str), + /// Denied by a recorded decision, an interactive answer, or the inability + /// to ask (no terminal). + Denied, +} + +impl Outcome { + fn from_decided(granted: bool) -> Outcome { + if granted { Outcome::Granted } else { Outcome::Denied } + } +} + +impl Policy { + pub(crate) fn new( + fs_override: Option, + net_override: Option, + script_key: Option, + persisted: ScriptGrants, + store_path: Option, + ) -> Self { + Policy { + fs_override, + net_override, + script_key, + persisted: Mutex::new(persisted), + session: Mutex::new(ScriptGrants::default()), + store_path, + #[cfg(test)] + scripted: Mutex::new(std::collections::VecDeque::new()), + } + } + + /// Everything granted, no prompts, no persistence. Tests only — the CLI + /// composes the equivalent from two `Override::Allow`s. + #[cfg(test)] + pub(crate) fn allow_all() -> Self { + Self::new(Some(Override::Allow), Some(Override::Allow), None, Default::default(), None) + } + + /// Everything denied (`--sandbox`). + pub(crate) fn deny_all(flag: &'static str) -> Self { + Self::new( + Some(Override::Deny { flag }), + Some(Override::Deny { flag }), + None, + Default::default(), + None, + ) + } + + /// Fully interactive: consult the recorded grants, prompt on unknown. + pub(crate) fn interactive( + script_key: Option, + persisted: ScriptGrants, + store_path: Option, + ) -> Self { + Self::new(None, None, script_key, persisted, store_path) + } + + /// An interactive policy with a queue of pre-recorded prompt answers. + #[cfg(test)] + pub(crate) fn interactive_scripted( + script_key: Option, + persisted: ScriptGrants, + store_path: Option, + answers: impl IntoIterator, + ) -> Self { + let policy = Self::interactive(script_key, persisted, store_path); + policy.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_dir( + self: Arc, + dir: PathBuf, + wants: Wants, + ) -> LuaResult { + match self.fs_override { + Some(Override::Allow) => return Ok(Outcome::Granted), + Some(Override::Deny { flag }) => return Ok(Outcome::DeniedByFlag(flag)), + None => {} + } + + // Fast path — already decided, no prompt needed. + if let Some(decided) = self.resolve_dir_wants(&dir, wants) { + return Ok(Outcome::from_decided(decided)); + } + self.prompted(move |policy, ask| policy.decide_dir(&dir, wants, ask)).await + } + + /// The full permission cycle for a network origin + /// (`scheme://host[:port]`), same shape as [`check_dir`](Self::check_dir). + pub(crate) async fn check_origin(self: Arc, origin: String) -> LuaResult { + match self.net_override { + Some(Override::Allow) => return Ok(Outcome::Granted), + Some(Override::Deny { flag }) => return Ok(Outcome::DeniedByFlag(flag)), + None => {} + } + + if let Some(decided) = self.resolve_origin(&origin) { + return Ok(Outcome::from_decided(decided)); + } + self.prompted(move |policy, ask| policy.decide_origin(&origin, ask)).await + } + + /// Run an ask-and-record step. In test builds the scripted answer queue + /// stands in for the terminal — structurally, an unwitting test cannot + /// hang `cargo test` on a keypress. In real builds the step runs on the + /// blocking pool under the tui prompt lock, so concurrent coroutines + /// can't fight over the terminal. + async fn prompted( + self: Arc, + decide: impl FnOnce(&Policy, &dyn Fn(&str, &str) -> Choice) -> Outcome + Send + 'static, + ) -> LuaResult { + #[cfg(test)] + { + let choice = { + let mut answers = self.scripted.lock().unwrap_or_else(|e| e.into_inner()); + answers.pop_front().unwrap_or(Choice::NoTty) + }; + Ok(decide(&self, &move |_: &str, _: &str| choice)) + } + + #[cfg(not(test))] + { + if !super::prompt::can_prompt() { + return Ok(Outcome::Denied); + } + let this = Arc::clone(&self); + tokio::task::spawn_blocking(move || { + let _guard = crate::stdlib::tui::PROMPT_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + decide(&this, &super::prompt::prompt_access) + }) + .await + .map_err(|e| mlua::Error::external(format!("permission prompt task failed: {e}"))) + } + } + + /// The directory 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_dir(&self, dir: &Path, wants: Wants, ask: &dyn Fn(&str, &str) -> Choice) -> Outcome { + if let Some(decided) = self.resolve_dir_wants(dir, wants) { + return Outcome::from_decided(decided); + } + let missing = Wants { + read: wants.read && self.resolve_dir_one(dir, AccessKind::Read).is_none(), + write: wants.write && self.resolve_dir_one(dir, AccessKind::Write).is_none(), + }; + + let dir_key = dir.to_string_lossy().into_owned(); + let choice = ask( + self.script_label(), + &format!("{} access to directory {dir_key}", missing.describe()), + ); + let Some(value) = choice.value() else { + return Outcome::Denied; // no terminal: record nothing + }; + + let update = DirAccess { + read: missing.read.then_some(value), + write: missing.write.then_some(value), + }; + { + let mut session = self.session.lock().unwrap_or_else(|e| e.into_inner()); + let entry = session.fs.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 choice.is_always() + && let (Some(key), Some(path)) = (&self.script_key, &self.store_path) + && let Err(e) = store::persist_fs(path, key, &dir_key, update) + { + // The answer still holds for this run; failing to record it must + // not fail the script's operation. + warn_cannot_save(path, e); + } + + // `missing` now carries the fresh answer; every other wanted kind + // already resolved to true (a false would have denied above). + Outcome::from_decided(value) + } + + /// The origin ask-and-record step, run with the prompt lock held. + fn decide_origin(&self, origin: &str, ask: &dyn Fn(&str, &str) -> Choice) -> Outcome { + if let Some(decided) = self.resolve_origin(origin) { + return Outcome::from_decided(decided); + } + + let choice = ask(self.script_label(), &format!("access to {origin}")); + let Some(value) = choice.value() else { + return Outcome::Denied; // no terminal: record nothing + }; + + self.session + .lock() + .unwrap_or_else(|e| e.into_inner()) + .net + .insert(origin.to_string(), OriginAccess { access: Some(value) }); + if choice.is_always() + && let (Some(key), Some(path)) = (&self.script_key, &self.store_path) + && let Err(e) = store::persist_net(path, key, origin, value) + { + warn_cannot_save(path, e); + } + + Outcome::from_decided(value) + } + + fn script_label(&self) -> &str { + self.script_key.as_deref().unwrap_or("interactive session (REPL)") + } + + /// `Some(true)` = every wanted kind granted, `Some(false)` = at least one + /// denied, `None` = at least one undecided (and none denied). + fn resolve_dir_wants(&self, dir: &Path, wants: Wants) -> Option { + let mut all_known = true; + for kind in wants.kinds() { + match self.resolve_dir_one(dir, kind) { + Some(false) => return Some(false), + Some(true) => {} + None => all_known = false, + } + } + all_known.then_some(true) + } + + fn resolve_dir_one(&self, dir: &Path, kind: AccessKind) -> Option { + let session = self.session.lock().unwrap_or_else(|e| e.into_inner()); + let persisted = self.persisted.lock().unwrap_or_else(|e| e.into_inner()); + resolve_dir(&session.fs, &persisted.fs, dir, kind) + } + + /// Exact-match origin lookup, session layer first. + fn resolve_origin(&self, origin: &str) -> Option { + let session = self.session.lock().unwrap_or_else(|e| e.into_inner()); + let persisted = self.persisted.lock().unwrap_or_else(|e| e.into_inner()); + [&session.net, &persisted.net] + .into_iter() + .find_map(|layer| layer.get(origin).and_then(|a| a.access)) + } +} + +impl Choice { + /// The yes/no the choice stands for; `None` when there was nobody to ask. + fn value(self) -> Option { + match self { + Choice::AllowOnce | Choice::AllowAlways => Some(true), + Choice::DenyOnce | Choice::DenyAlways => Some(false), + Choice::NoTty => None, + } + } + + fn is_always(self) -> bool { + matches!(self, Choice::AllowAlways | Choice::DenyAlways) + } +} + +fn warn_cannot_save(path: &Path, e: std::io::Error) { + eprintln!( + "{}: warning: cannot save permission to {}: {e}", + store::APP_NAME, + path.display() + ); +} + +/// 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_dir( + session: &DirGrants, + persisted: &DirGrants, + dir: &Path, + kind: AccessKind, +) -> Option { + let mut cur = Some(dir); + while let Some(d) = cur { + let key = d.to_string_lossy(); + for layer in [session, persisted] { + if let Some(v) = layer.get(key.as_ref()).and_then(|a| a.get(kind)) { + return Some(v); + } + } + cur = d.parent(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn grants(entries: &[(&str, Option, Option)]) -> ScriptGrants { + ScriptGrants { + fs: entries + .iter() + .map(|(dir, read, write)| { + (dir.to_string(), DirAccess { read: *read, write: *write }) + }) + .collect(), + ..Default::default() + } + } + + fn origin_grants(entries: &[(&str, bool)]) -> ScriptGrants { + ScriptGrants { + net: entries + .iter() + .map(|(origin, allowed)| { + (origin.to_string(), OriginAccess { access: Some(*allowed) }) + }) + .collect(), + ..Default::default() + } + } + + #[test] + fn resolve_dir_walks_ancestors() { + let persisted = grants(&[("/home/u/data", Some(true), None)]).fs; + let session = DirGrants::new(); + let r = |dir: &str| resolve_dir(&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_dir_deepest_ancestor_wins() { + let persisted = grants(&[ + ("/home/u", Some(true), None), + ("/home/u/secrets", Some(false), None), + ]) + .fs; + let session = DirGrants::new(); + let r = |dir: &str| resolve_dir(&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_dir_session_shadows_persisted() { + let persisted = grants(&[("/data", Some(false), None)]).fs; + let session = grants(&[("/data", Some(true), None)]).fs; + assert_eq!( + resolve_dir(&session, &persisted, Path::new("/data/x"), AccessKind::Read), + Some(true) + ); + } + + #[test] + fn resolve_dir_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))]).fs; + let session = DirGrants::new(); + assert_eq!( + resolve_dir(&session, &persisted, Path::new("/a/b"), AccessKind::Read), + Some(true) + ); + assert_eq!( + resolve_dir(&session, &persisted, Path::new("/a/b"), AccessKind::Write), + Some(false) + ); + } + + #[test] + fn resolve_dir_root_grant_covers_everything() { + let persisted = grants(&[("/", Some(true), Some(true))]).fs; + let session = DirGrants::new(); + assert_eq!( + resolve_dir(&session, &persisted, Path::new("/any/where"), AccessKind::Write), + Some(true) + ); + } + + #[tokio::test] + async fn static_overrides() { + let allow = Arc::new(Policy::allow_all()); + assert_eq!( + Arc::clone(&allow).check_dir("/x".into(), Wants::READ).await.unwrap(), + Outcome::Granted + ); + assert_eq!( + allow.check_origin("https://example.com".into()).await.unwrap(), + Outcome::Granted + ); + + let deny = Arc::new(Policy::deny_all("--sandbox")); + assert_eq!( + Arc::clone(&deny).check_dir("/x".into(), Wants::WRITE).await.unwrap(), + Outcome::DeniedByFlag("--sandbox") + ); + assert_eq!( + deny.check_origin("https://example.com".into()).await.unwrap(), + Outcome::DeniedByFlag("--sandbox") + ); + + // Mixed: --no-fs leaves origins interactive (here: no answers = deny, + // but not by flag). + let mixed = Arc::new(Policy::new( + Some(Override::Deny { flag: "--no-fs" }), + None, + None, + Default::default(), + None, + )); + assert_eq!( + Arc::clone(&mixed).check_dir("/x".into(), Wants::READ).await.unwrap(), + Outcome::DeniedByFlag("--no-fs") + ); + assert_eq!( + mixed.check_origin("https://example.com".into()).await.unwrap(), + Outcome::Denied + ); + } + + #[tokio::test] + async fn allow_once_grants_for_the_whole_run() { + let policy = Arc::new(Policy::interactive_scripted( + None, + Default::default(), + None, + [Choice::AllowOnce], // exactly one answer available + )); + let p = Arc::clone(&policy); + assert_eq!(p.check_dir("/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_dir("/data/sub".into(), Wants::READ).await.unwrap(), Outcome::Granted); + } + + #[tokio::test] + async fn deny_once_is_cached_for_the_run() { + let policy = Arc::new(Policy::interactive_scripted( + None, + Default::default(), + None, + [Choice::DenyOnce], + )); + let p = Arc::clone(&policy); + assert_eq!(p.check_dir("/data".into(), Wants::READ).await.unwrap(), Outcome::Denied); + let p = Arc::clone(&policy); + assert_eq!(p.check_dir("/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(Policy::interactive_scripted( + Some("/s/tool.lua".into()), + Default::default(), + Some(store_path.clone()), + [Choice::AllowAlways], + )); + let p = Arc::clone(&policy); + assert_eq!(p.check_dir("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted); + + let saved = store::load_for_script(&store_path, "/s/tool.lua"); + assert_eq!(saved.fs["/data"], DirAccess { read: Some(true), write: None }); + + let p = Arc::clone(&policy); + assert_eq!(p.check_dir("/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(Policy::interactive_scripted( + Some("/s/tool.lua".into()), + Default::default(), + Some(store_path.clone()), + [Choice::DenyAlways], + )); + let p = Arc::clone(&policy); + assert_eq!(p.check_dir("/secrets".into(), Wants::WRITE).await.unwrap(), Outcome::Denied); + + let saved = store::load_for_script(&store_path, "/s/tool.lua"); + assert_eq!(saved.fs["/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(Policy::interactive_scripted( + None, + Default::default(), + Some(store_path.clone()), + [Choice::AllowAlways], + )); + let p = Arc::clone(&policy); + assert_eq!(p.check_dir("/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_dir("/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(Policy::interactive_scripted( + Some("/s/tool.lua".into()), + Default::default(), + Some(store_path.clone()), + [], + )); + let p = Arc::clone(&policy); + assert_eq!(p.check_dir("/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 + assert!(policy.session.lock().unwrap().fs.is_empty()); + } + + #[tokio::test] + async fn persisted_grants_answer_without_prompting() { + let persisted = grants(&[("/data", Some(true), Some(false))]); + let policy = Arc::new(Policy::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_dir("/data/x".into(), Wants::READ).await.unwrap(), Outcome::Granted); + let p = Arc::clone(&policy); + assert_eq!(p.check_dir("/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_dir("/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(Policy::interactive_scripted( + Some("/s/tool.lua".into()), + persisted, + Some(store_path.clone()), + [Choice::AllowAlways], + )); + let p = Arc::clone(&policy); + assert_eq!(p.check_dir("/db".into(), Wants::READ_WRITE).await.unwrap(), Outcome::Granted); + + let saved = store::load_for_script(&store_path, "/s/tool.lua"); + assert_eq!( + saved.fs["/db"], + DirAccess { read: None, write: Some(true) }, + "read was already known; only write gets recorded" + ); + } + + #[tokio::test] + async fn origin_allow_once_is_cached_for_the_run() { + let policy = Arc::new(Policy::interactive_scripted( + None, + Default::default(), + None, + [Choice::AllowOnce], // exactly one answer available + )); + let p = Arc::clone(&policy); + assert_eq!(p.check_origin("https://example.com".into()).await.unwrap(), Outcome::Granted); + // same origin again: session cache, no answer consumed + let p = Arc::clone(&policy); + assert_eq!(p.check_origin("https://example.com".into()).await.unwrap(), Outcome::Granted); + } + + #[tokio::test] + async fn origin_grants_are_exact_match_only() { + // A grant covers exactly its origin: not another scheme, port, + // subdomain, or a host that merely starts with it. + let persisted = origin_grants(&[("https://example.com", true)]); + let policy = Arc::new(Policy::interactive_scripted( + Some("/s/tool.lua".into()), + persisted, + None, + [], // any prompt denies + )); + for (origin, expected) in [ + ("https://example.com", Outcome::Granted), + ("http://example.com", Outcome::Denied), + ("https://example.com:8443", Outcome::Denied), + ("https://sub.example.com", Outcome::Denied), + ("https://example.com.evil.io", Outcome::Denied), + ] { + let p = Arc::clone(&policy); + assert_eq!(p.check_origin(origin.into()).await.unwrap(), expected, "{origin}"); + } + } + + #[tokio::test] + async fn origin_always_answers_persist_under_net() { + let tmp = tempfile::tempdir().unwrap(); + let store_path = tmp.path().join("access.json"); + let policy = Arc::new(Policy::interactive_scripted( + Some("/s/tool.lua".into()), + Default::default(), + Some(store_path.clone()), + [Choice::AllowAlways, Choice::DenyAlways], + )); + let p = Arc::clone(&policy); + assert_eq!( + p.check_origin("https://api.example.com".into()).await.unwrap(), + Outcome::Granted + ); + let p = Arc::clone(&policy); + assert_eq!( + p.check_origin("http://tracker.example.com".into()).await.unwrap(), + Outcome::Denied + ); + + let saved = store::load_for_script(&store_path, "/s/tool.lua"); + assert_eq!(saved.net["https://api.example.com"].access, Some(true)); + assert_eq!(saved.net["http://tracker.example.com"].access, Some(false)); + assert!(saved.fs.is_empty(), "no directory grants were involved"); + + // a fresh policy loading the store answers without prompting + let policy = Arc::new(Policy::interactive_scripted( + Some("/s/tool.lua".into()), + store::load_for_script(&store_path, "/s/tool.lua"), + Some(store_path.clone()), + [], + )); + let p = Arc::clone(&policy); + assert_eq!( + p.check_origin("https://api.example.com".into()).await.unwrap(), + Outcome::Granted + ); + let p = Arc::clone(&policy); + assert_eq!( + p.check_origin("http://tracker.example.com".into()).await.unwrap(), + Outcome::Denied + ); + } + + #[tokio::test] + async fn origin_and_dir_grants_do_not_cross_talk() { + // An fs grant whose key happens to look origin-ish must not answer an + // origin check, and vice versa. + let mut persisted = grants(&[("/data", Some(true), Some(true))]); + persisted.net.insert("https://example.com".into(), OriginAccess { access: Some(true) }); + let policy = Arc::new(Policy::interactive_scripted( + Some("/s/tool.lua".into()), + persisted, + None, + [], + )); + let p = Arc::clone(&policy); + assert_eq!(p.check_origin("/data".into()).await.unwrap(), Outcome::Denied); + let p = Arc::clone(&policy); + assert_eq!( + p.check_dir("https://example.com".into(), Wants::READ).await.unwrap(), + Outcome::Denied + ); + } +} diff --git a/src/stdlib/fs/prompt.rs b/src/stdlib/perm/prompt.rs similarity index 91% rename from src/stdlib/fs/prompt.rs rename to src/stdlib/perm/prompt.rs index 030679c..8d84250 100644 --- a/src/stdlib/fs/prompt.rs +++ b/src/stdlib/perm/prompt.rs @@ -1,7 +1,7 @@ //! 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 +//! [`super::policy::Policy`]), 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 @@ -29,15 +29,16 @@ 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 { +/// Ask the user; `wants` describes the request, e.g. `"read access to +/// directory /data"` or `"access to https://example.com"`. +pub(super) fn prompt_access(script: &str, wants: &str) -> Choice { if !can_prompt() { return Choice::NoTty; } eprint!( "{APP_NAME}: permission request\n \ script: {script}\n \ - wants: {what} access to directory {dir}\n\ + wants: {wants}\n\ [y] allow once [n] deny once [A] allow always [N] never allow: " ); let _ = std::io::stderr().flush(); diff --git a/src/stdlib/fs/store.rs b/src/stdlib/perm/store.rs similarity index 57% rename from src/stdlib/fs/store.rs rename to src/stdlib/perm/store.rs index ef6886c..a7e486e 100644 --- a/src/stdlib/fs/store.rs +++ b/src/stdlib/perm/store.rs @@ -1,12 +1,13 @@ //! 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 Luna 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. +//! Grants are keyed by the script's canonical path, then by subsystem section: +//! `fs` maps canonical directories to a tri-state per direction, `net` maps +//! network origins to a single tri-state `access` flag — `null` (not +//! decided), `true` (approved), `false` (denied). The file is hand-editable; +//! the persist functions keep concurrent Luna processes from losing each +//! other's updates by holding an exclusive `flock` on a sidecar lock file +//! across their read-merge-write cycle, and replace the store with `rename()` +//! so readers never see a half-written file. use std::collections::BTreeMap; use std::io::Write; @@ -39,16 +40,52 @@ impl DirAccess { } } -/// Grants recorded for one script: canonical directory → tri-state pair. -/// `BTreeMap` for deterministic serialization (stable diffs of access.json). +/// Filesystem grants recorded for one script: canonical directory → tri-state +/// pair. `BTreeMap` for deterministic serialization (stable diffs of +/// access.json). pub(crate) type DirGrants = BTreeMap; +/// Tri-state access recorded for one network origin, serialized as JSON +/// `null` / `true` / `false`. +#[derive(Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Debug)] +pub(crate) struct OriginAccess { + #[serde(default)] + pub(crate) access: Option, +} + +/// Network grants recorded for one script: origin (`scheme://host[:port]`) +/// → access flag. +pub(crate) type OriginGrants = BTreeMap; + +/// Everything recorded for one script, one section per subsystem. Adding a +/// subsystem is a new field here, not a new file shape. +#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Debug)] +pub(crate) struct ScriptGrants { + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) fs: DirGrants, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) net: OriginGrants, +} + +impl ScriptGrants { + /// True when no section records anything — the whole script entry can go. + fn is_empty(&self) -> bool { + self.fs.is_empty() && self.net.is_empty() + } + + /// Keep the file tidy: drop entries that no longer record anything. + fn prune(&mut self) { + self.fs.retain(|_, a| a.read.is_some() || a.write.is_some()); + self.net.retain(|_, a| a.access.is_some()); + } +} + #[derive(Serialize, Deserialize)] pub(crate) struct AccessStore { #[serde(default = "version_default")] pub(crate) version: u32, #[serde(default)] - pub(crate) scripts: BTreeMap, + pub(crate) scripts: BTreeMap, } fn version_default() -> u32 { @@ -98,26 +135,66 @@ fn read_store(path: &Path) -> AccessStore { } } -/// 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 { +/// The grants recorded for one script (empty if none). Startup snapshot; +/// takes no lock — the persist functions' atomic rename guarantees a complete +/// file. +pub(crate) fn load_for_script(store_path: &Path, script_key: &str) -> ScriptGrants { 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. +/// Record a directory decision: merge `update`'s decided flags (only the +/// `Some` ones) into the `fs` entry for (`script_key`, `dir_key`) and rewrite +/// the file. +pub(crate) fn persist_fs( + store_path: &Path, + script_key: &str, + dir_key: &str, + update: DirAccess, +) -> std::io::Result<()> { + with_store(store_path, |store| { + let entry = store + .scripts + .entry(script_key.to_string()) + .or_default() + .fs + .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; + } + }) +} + +/// Record an origin decision: set the `net` access flag for +/// (`script_key`, `origin`) and rewrite the file. +pub(crate) fn persist_net( + store_path: &Path, + script_key: &str, + origin: &str, + allowed: bool, +) -> std::io::Result<()> { + with_store(store_path, |store| { + store + .scripts + .entry(script_key.to_string()) + .or_default() + .net + .insert(origin.to_string(), OriginAccess { access: Some(allowed) }); + }) +} + +/// The shared read-merge-write cycle behind the persist functions: run +/// `update` on the freshly-read store, prune, 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<()> { +fn with_store(store_path: &Path, update: impl FnOnce(&mut AccessStore)) -> std::io::Result<()> { let config_dir = store_path.parent().unwrap_or(Path::new("/")); std::fs::create_dir_all(config_dir)?; @@ -151,17 +228,11 @@ pub(crate) fn persist( 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()); + update(&mut store); + store.scripts.retain(|_, g| { + g.prune(); + !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. @@ -190,22 +261,32 @@ mod tests { "version": 1, "scripts": { "/s/deploy.lua": { - "/data": { "read": true, "write": true }, - "/etc": { "read": true, "write": false }, - "/secrets": { "read": null, "write": false } + "fs": { + "/data": { "read": true, "write": true }, + "/etc": { "read": true, "write": false }, + "/secrets": { "read": null, "write": false } + }, + "net": { + "https://api.example.com": { "access": true }, + "http://insecure.example.com:8090": { "access": false }, + "https://undecided.example.com": { "access": null } + } } } }"#; 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) }); + assert_eq!(grants.fs["/data"], DirAccess { read: Some(true), write: Some(true) }); + assert_eq!(grants.fs["/etc"], DirAccess { read: Some(true), write: Some(false) }); + assert_eq!(grants.fs["/secrets"], DirAccess { read: None, write: Some(false) }); + assert_eq!(grants.net["https://api.example.com"].access, Some(true)); + assert_eq!(grants.net["http://insecure.example.com:8090"].access, Some(false)); + assert_eq!(grants.net["https://undecided.example.com"].access, None); // 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(); + let out = serde_json::to_string(&grants.fs["/secrets"]).unwrap(); assert!(out.contains("\"read\":null"), "{out}"); // round trip let again: AccessStore = @@ -225,15 +306,33 @@ mod tests { 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(); + persist_fs(&path, "/s/a.lua", "/data", DirAccess { read: Some(true), write: None }) + .unwrap(); + persist_fs(&path, "/s/a.lua", "/data", DirAccess { read: None, write: Some(false) }) + .unwrap(); + // the net section merges alongside, not instead + persist_net(&path, "/s/a.lua", "https://api.example.com", true).unwrap(); + persist_net(&path, "/s/a.lua", "http://other.example.com:8090", false).unwrap(); let grants = load_for_script(&path, "/s/a.lua"); - assert_eq!(grants["/data"], DirAccess { read: Some(true), write: Some(false) }); + assert_eq!(grants.fs["/data"], DirAccess { read: Some(true), write: Some(false) }); + assert_eq!(grants.net["https://api.example.com"].access, Some(true)); + assert_eq!(grants.net["http://other.example.com:8090"].access, Some(false)); // a second script's grants are separate assert!(load_for_script(&path, "/s/b.lua").is_empty()); } + #[test] + fn persist_net_overwrites_a_previous_answer() { + let tmp = tempfile::tempdir().unwrap(); + let path = store_path(&tmp); + persist_net(&path, "/s/a.lua", "https://api.example.com", false).unwrap(); + persist_net(&path, "/s/a.lua", "https://api.example.com", true).unwrap(); + let grants = load_for_script(&path, "/s/a.lua"); + assert_eq!(grants.net["https://api.example.com"].access, Some(true)); + assert_eq!(grants.net.len(), 1); + } + #[test] fn persist_prunes_undecided_entries() { let tmp = tempfile::tempdir().unwrap(); @@ -243,18 +342,23 @@ mod tests { std::fs::write( &path, r#"{ "version": 1, "scripts": { - "/s/a.lua": { "/dead": { "read": null, "write": null } }, - "/s/gone.lua": {} + "/s/a.lua": { + "fs": { "/dead": { "read": null, "write": null } }, + "net": { "https://dead.example.com": { "access": null } } + }, + "/s/gone.lua": { "fs": {} } } }"#, ) .unwrap(); - persist(&path, "/s/a.lua", "/data", DirAccess { read: Some(true), write: None }).unwrap(); + persist_fs(&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["/s/a.lua"].fs.contains_key("/dead")); + assert!(store.scripts["/s/a.lua"].net.is_empty()); assert!(!store.scripts.contains_key("/s/gone.lua")); - assert_eq!(store.scripts["/s/a.lua"]["/data"].read, Some(true)); + assert_eq!(store.scripts["/s/a.lua"].fs["/data"].read, Some(true)); } #[test] @@ -264,13 +368,14 @@ mod tests { 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(); + persist_fs(&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)); + assert_eq!(load_for_script(&path, "/s/a.lua").fs["/data"].read, Some(true)); } #[test] @@ -292,13 +397,15 @@ mod tests { let path = path.clone(); std::thread::spawn(move || { for j in 0..10 { - persist( + persist_fs( &path, "/s/a.lua", &format!("/dir-{i}-{j}"), DirAccess { read: Some(true), write: None }, ) .unwrap(); + persist_net(&path, "/s/a.lua", &format!("https://h{i}-{j}.example"), true) + .unwrap(); } }) }) @@ -306,8 +413,11 @@ mod tests { 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); + // every one of the 80+80 grants survived the concurrent + // read-merge-writes, across both sections + let grants = load_for_script(&path, "/s/a.lua"); + assert_eq!(grants.fs.len(), 80); + assert_eq!(grants.net.len(), 80); } #[test] diff --git a/src/stdlib/sqlite.rs b/src/stdlib/sqlite.rs index 33956a6..369f48c 100644 --- a/src/stdlib/sqlite.rs +++ b/src/stdlib/sqlite.rs @@ -462,7 +462,7 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { let (dir, _target) = super::fs::write_target("sqlite.connect", Path::new(db_file_path(&path))) .await?; - super::fs::ensure_access(&lua, "sqlite.connect", dir, super::fs::Wants::READ_WRITE) + super::perm::ensure_dir_access(&lua, "sqlite.connect", dir, super::perm::Wants::READ_WRITE) .await?; } // Opening a file-backed database touches disk, so do it on the