adjust the permission system to also protect network access

master
Ondřej Hruška 1 day ago
parent c2857f52e8
commit de5f536e7d
  1. 4
      README.md
  2. 2
      docs/README.md
  3. 24
      docs/fs.md
  4. 42
      docs/http.md
  5. 2
      lua/stubs/fs.lua
  6. 5
      lua/stubs/http.lua
  7. 49
      src/lua_tests/fs_tests.rs
  8. 106
      src/lua_tests/http_tests.rs
  9. 59
      src/main.rs
  10. 92
      src/stdlib/fs/mod.rs
  11. 519
      src/stdlib/fs/policy.rs
  12. 50
      src/stdlib/http.rs
  13. 8
      src/stdlib/mod.rs
  14. 92
      src/stdlib/perm/mod.rs
  15. 775
      src/stdlib/perm/policy.rs
  16. 9
      src/stdlib/perm/prompt.rs
  17. 214
      src/stdlib/perm/store.rs
  18. 2
      src/stdlib/sqlite.rs

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

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

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

@ -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 <origin> 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/<version>`) because some servers reject
- **A default `User-Agent` is sent** (`luna/<version>`) 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

@ -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 = {}

@ -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 = {}

@ -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<String> =
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()),

@ -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<std::path::PathBuf>,
answers: impl IntoIterator<Item = crate::stdlib::perm::Choice>,
) -> 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());
}

@ -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<String>) -> stdlib::fs::FsPolicy {
use stdlib::fs::{store, FsPolicy};
if self.sandbox {
FsPolicy::deny_all("--sandbox", false)
} else if self.no_fs {
FsPolicy::deny_all("--no-fs", true)
} else if self.allow_fs {
FsPolicy::allow_all()
} else {
/// 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<String>) -> 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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
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.

@ -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<FsPolicy> {
lua.app_data_ref::<Arc<FsPolicy>>()
.map(|p| Arc::clone(&p))
.unwrap_or_else(|| Arc::new(FsPolicy::deny_all("(internal: no fs policy)", false)))
}
/// Run the permission cycle for the canonical directory `dir`; a denial is a
/// Lua error prefixed with `fname`. Shared with sqlite (`Wants::READ_WRITE`
/// on a database's directory) and http (cookie-jar files).
pub(super) async fn ensure_access(
lua: &Lua,
fname: &'static str,
dir: PathBuf,
wants: Wants,
) -> LuaResult<()> {
match active_policy(lua).check(dir.clone(), wants).await? {
policy::Outcome::Granted => Ok(()),
policy::Outcome::DeniedByFlag(flag) => {
Err(ext_err!("{fname}: filesystem access disabled by {flag}"))
}
policy::Outcome::Denied => Err(ext_err!("{fname}: access to {} denied", dir.display())),
}
}
/// Bool-returning permission cycle (`fs.access`, the predicates): denial is
/// `false`, never an error.
async fn check_access(lua: &Lua, dir: PathBuf, wants: Wants) -> LuaResult<bool> {
Ok(matches!(active_policy(lua).check(dir, wants).await?, policy::Outcome::Granted))
}
/// Networking gate for `--sandbox`; called by http before any request.
pub(super) fn ensure_net(lua: &Lua) -> LuaResult<()> {
if active_policy(lua).net_allowed {
Ok(())
} else {
Err(ext_err!("http: networking disabled by --sandbox"))
}
}
/// Lua strings are byte strings; on unix so are paths.
fn to_path(s: &LuaString) -> PathBuf {
use std::os::unix::ffi::OsStrExt;

@ -1,519 +0,0 @@
//! The permission policy — who may touch which directory.
//!
//! One [`FsPolicy`] lives in mlua app data per Lua state (as `Arc<FsPolicy>`).
//! `fs::install` plants a session-only default; `main` replaces it once the
//! CLI flags and the script's canonical path are known.
//!
//! Grants are recorded per canonical directory and apply recursively: the
//! deepest recorded ancestor decides, so a deny on `~/secrets` beats an allow
//! on `~`. Session answers (y/n, and everything in the REPL) shadow the
//! persisted store at every depth.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use mlua::prelude::LuaResult;
use super::prompt::Choice;
use super::store::{self, DirAccess, DirGrants};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum AccessKind {
Read,
Write,
}
/// Which directions an operation needs; `read && write` is sqlite's combined
/// "open this database" request, answered by a single prompt.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Wants {
pub(crate) read: bool,
pub(crate) write: bool,
}
impl Wants {
pub(crate) const READ: Wants = Wants { read: true, write: false };
pub(crate) const WRITE: Wants = Wants { read: false, write: true };
pub(crate) const READ_WRITE: Wants = Wants { read: true, write: true };
fn kinds(self) -> impl Iterator<Item = AccessKind> {
[(self.read, AccessKind::Read), (self.write, AccessKind::Write)]
.into_iter()
.filter_map(|(wanted, kind)| wanted.then_some(kind))
}
fn describe(self) -> &'static str {
match (self.read, self.write) {
(true, true) => "read/write",
(true, false) => "read",
(false, true) => "write",
(false, false) => "no",
}
}
}
pub(crate) enum FsMode {
/// `--allow-fs`: everything granted, no prompts, no persistence.
AllowAll,
/// `--no-fs` / `--sandbox`: everything denied; `flag` names the culprit
/// for error messages.
DenyAll { flag: &'static str },
/// Default: consult the recorded grants, prompt on unknown.
Interactive {
/// Store key = the script's canonical path. `None` in the REPL (and
/// in bare test states): prompts work, nothing is ever persisted.
script_key: Option<String>,
/// This script's grants from access.json, snapshotted at startup.
persisted: Mutex<DirGrants>,
/// Session-only answers — y/n, plus A/N in the REPL. Shadows
/// `persisted`.
session: Mutex<DirGrants>,
/// Where A/N answers are persisted; `None` = no config dir found,
/// session-only operation.
store_path: Option<PathBuf>,
/// Scripted prompt answers for tests, consumed front to back; an
/// exhausted (or absent) queue behaves like "no terminal". In test
/// builds the real terminal prompt is never reached (see `check`).
#[cfg(test)]
scripted: Mutex<std::collections::VecDeque<Choice>>,
},
}
pub(crate) struct FsPolicy {
pub(crate) mode: FsMode,
/// `false` only under `--sandbox`: http refuses all requests.
pub(crate) net_allowed: bool,
}
/// Outcome of a permission check, kept apart from granted/denied booleans so
/// error messages can name the CLI flag that caused a static denial.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Outcome {
Granted,
/// Denied by `--no-fs` / `--sandbox`.
DeniedByFlag(&'static str),
/// Denied by a recorded decision, an interactive answer, or the inability
/// to ask (no terminal).
Denied,
}
impl FsPolicy {
pub(crate) fn allow_all() -> Self {
FsPolicy { mode: FsMode::AllowAll, net_allowed: true }
}
pub(crate) fn deny_all(flag: &'static str, net_allowed: bool) -> Self {
FsPolicy { mode: FsMode::DenyAll { flag }, net_allowed }
}
pub(crate) fn interactive(
script_key: Option<String>,
persisted: DirGrants,
store_path: Option<PathBuf>,
) -> Self {
FsPolicy {
mode: FsMode::Interactive {
script_key,
persisted: Mutex::new(persisted),
session: Mutex::new(BTreeMap::new()),
store_path,
#[cfg(test)]
scripted: Mutex::new(std::collections::VecDeque::new()),
},
net_allowed: true,
}
}
/// An interactive policy with a queue of pre-recorded prompt answers.
#[cfg(test)]
pub(crate) fn interactive_scripted(
script_key: Option<String>,
persisted: DirGrants,
store_path: Option<PathBuf>,
answers: impl IntoIterator<Item = Choice>,
) -> Self {
let policy = Self::interactive(script_key, persisted, store_path);
if let FsMode::Interactive { scripted, .. } = &policy.mode {
scripted.lock().unwrap().extend(answers);
}
policy
}
/// The full permission cycle for the canonical directory `dir`: recorded
/// answer, else ask on the terminal, else deny (recording nothing — a
/// persisted denial must be an explicit user decision).
pub(crate) async fn check(self: Arc<Self>, dir: PathBuf, wants: Wants) -> LuaResult<Outcome> {
match &self.mode {
FsMode::AllowAll => return Ok(Outcome::Granted),
FsMode::DenyAll { flag } => return Ok(Outcome::DeniedByFlag(flag)),
FsMode::Interactive { .. } => {}
}
// Fast path — already decided, no prompt needed.
match self.resolve_wants(&dir, wants) {
Some(true) => return Ok(Outcome::Granted),
Some(false) => return Ok(Outcome::Denied),
None => {}
}
// Test builds never reach a real terminal prompt: scripted answers
// only. This is structural — an unwitting test cannot hang `cargo
// test` on a keypress.
#[cfg(test)]
{
let FsMode::Interactive { scripted, .. } = &self.mode else { unreachable!() };
let choice = {
let mut answers = scripted.lock().unwrap_or_else(|e| e.into_inner());
answers.pop_front().unwrap_or(Choice::NoTty)
};
Ok(self.decide(&dir, wants, |_, _, _| choice))
}
#[cfg(not(test))]
{
if !super::prompt::can_prompt() {
return Ok(Outcome::Denied);
}
let this = Arc::clone(&self);
tokio::task::spawn_blocking(move || {
// Serialize with tui prompts (and each other) so concurrent
// coroutines can't fight over the terminal.
let _guard = crate::stdlib::tui::PROMPT_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
this.decide(&dir, wants, super::prompt::prompt_access)
})
.await
.map_err(|e| mlua::Error::external(format!("fs: prompt task failed: {e}")))
}
}
/// The ask-and-record step, run with the prompt lock held. Re-resolves
/// first — another coroutine may have answered for this directory while
/// we waited for the lock — then asks only for what is still unknown.
fn decide(&self, dir: &Path, wants: Wants, ask: impl FnOnce(&str, &str, &str) -> Choice) -> Outcome {
let FsMode::Interactive { script_key, session, store_path, .. } = &self.mode else {
unreachable!("decide() is only called in interactive mode");
};
match self.resolve_wants(dir, wants) {
Some(true) => return Outcome::Granted,
Some(false) => return Outcome::Denied,
None => {}
}
let missing = Wants {
read: wants.read && self.resolve_one(dir, AccessKind::Read).is_none(),
write: wants.write && self.resolve_one(dir, AccessKind::Write).is_none(),
};
let label = script_key.as_deref().unwrap_or("interactive session (REPL)");
let dir_key = dir.to_string_lossy().into_owned();
let choice = ask(label, &dir_key, missing.describe());
let value = match choice {
Choice::AllowOnce | Choice::AllowAlways => true,
Choice::DenyOnce | Choice::DenyAlways => false,
Choice::NoTty => return Outcome::Denied, // record nothing
};
let update = DirAccess {
read: missing.read.then_some(value),
write: missing.write.then_some(value),
};
{
let mut session = session.lock().unwrap_or_else(|e| e.into_inner());
let entry = session.entry(dir_key.clone()).or_default();
if update.read.is_some() {
entry.read = update.read;
}
if update.write.is_some() {
entry.write = update.write;
}
}
if matches!(choice, Choice::AllowAlways | Choice::DenyAlways)
&& let (Some(key), Some(path)) = (script_key, store_path)
&& let Err(e) = store::persist(path, key, &dir_key, update)
{
// The answer still holds for this run; failing to record it must
// not fail the script's operation.
eprintln!(
"{}: warning: cannot save permission to {}: {e}",
store::APP_NAME,
path.display()
);
}
// `missing` now carries the fresh answer; every other wanted kind
// already resolved to true (a false would have denied above).
if value { Outcome::Granted } else { Outcome::Denied }
}
/// `Some(true)` = every wanted kind granted, `Some(false)` = at least one
/// denied, `None` = at least one undecided (and none denied).
fn resolve_wants(&self, dir: &Path, wants: Wants) -> Option<bool> {
let mut all_known = true;
for kind in wants.kinds() {
match self.resolve_one(dir, kind) {
Some(false) => return Some(false),
Some(true) => {}
None => all_known = false,
}
}
all_known.then_some(true)
}
fn resolve_one(&self, dir: &Path, kind: AccessKind) -> Option<bool> {
let FsMode::Interactive { session, persisted, .. } = &self.mode else {
return None;
};
let session = session.lock().unwrap_or_else(|e| e.into_inner());
let persisted = persisted.lock().unwrap_or_else(|e| e.into_inner());
resolve(&session, &persisted, dir, kind)
}
}
/// Walk from `dir` up to and including `/`. At each depth the session layer
/// shadows the persisted one; the first decided value wins — the deepest
/// recorded ancestor decides.
fn resolve(session: &DirGrants, persisted: &DirGrants, dir: &Path, kind: AccessKind) -> Option<bool> {
let mut cur = Some(dir);
while let Some(d) = cur {
let key = d.to_string_lossy();
for layer in [session, persisted] {
if let Some(v) = layer.get(key.as_ref()).and_then(|a| a.get(kind)) {
return Some(v);
}
}
cur = d.parent();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn grants(entries: &[(&str, Option<bool>, Option<bool>)]) -> DirGrants {
entries
.iter()
.map(|(dir, read, write)| {
(dir.to_string(), DirAccess { read: *read, write: *write })
})
.collect()
}
#[test]
fn resolve_walks_ancestors() {
let persisted = grants(&[("/home/u/data", Some(true), None)]);
let session = DirGrants::new();
let r = |dir: &str| resolve(&session, &persisted, Path::new(dir), AccessKind::Read);
assert_eq!(r("/home/u/data"), Some(true), "exact hit");
assert_eq!(r("/home/u/data/sub/deep"), Some(true), "parent covers children");
assert_eq!(r("/home/u"), None, "no upward leakage");
assert_eq!(r("/elsewhere"), None, "unrelated dir unknown");
}
#[test]
fn resolve_deepest_ancestor_wins() {
let persisted = grants(&[
("/home/u", Some(true), None),
("/home/u/secrets", Some(false), None),
]);
let session = DirGrants::new();
let r = |dir: &str| resolve(&session, &persisted, Path::new(dir), AccessKind::Read);
assert_eq!(r("/home/u/data"), Some(true));
assert_eq!(r("/home/u/secrets"), Some(false), "deny beats shallower allow");
assert_eq!(r("/home/u/secrets/inner"), Some(false));
}
#[test]
fn resolve_session_shadows_persisted() {
let persisted = grants(&[("/data", Some(false), None)]);
let session = grants(&[("/data", Some(true), None)]);
assert_eq!(
resolve(&session, &persisted, Path::new("/data/x"), AccessKind::Read),
Some(true)
);
}
#[test]
fn resolve_null_flags_are_skipped() {
// read is undecided at the deep entry; the shallow decided one wins.
let persisted = grants(&[("/a", Some(true), None), ("/a/b", None, Some(false))]);
let session = DirGrants::new();
assert_eq!(resolve(&session, &persisted, Path::new("/a/b"), AccessKind::Read), Some(true));
assert_eq!(resolve(&session, &persisted, Path::new("/a/b"), AccessKind::Write), Some(false));
}
#[test]
fn resolve_root_grant_covers_everything() {
let persisted = grants(&[("/", Some(true), Some(true))]);
let session = DirGrants::new();
assert_eq!(
resolve(&session, &persisted, Path::new("/any/where"), AccessKind::Write),
Some(true)
);
}
#[tokio::test]
async fn static_modes() {
let allow = Arc::new(FsPolicy::allow_all());
assert_eq!(allow.check("/x".into(), Wants::READ).await.unwrap(), Outcome::Granted);
let deny = Arc::new(FsPolicy::deny_all("--no-fs", true));
assert_eq!(
Arc::clone(&deny).check("/x".into(), Wants::WRITE).await.unwrap(),
Outcome::DeniedByFlag("--no-fs")
);
assert!(deny.net_allowed);
assert!(!FsPolicy::deny_all("--sandbox", false).net_allowed);
}
#[tokio::test]
async fn allow_once_grants_for_the_whole_run() {
let policy = Arc::new(FsPolicy::interactive_scripted(
None,
DirGrants::new(),
None,
[Choice::AllowOnce], // exactly one answer available
));
let p = Arc::clone(&policy);
assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted);
// second check hits the session cache — no second answer consumed
// (the queue is empty; consuming would mean NoTty => Denied)
let p = Arc::clone(&policy);
assert_eq!(p.check("/data/sub".into(), Wants::READ).await.unwrap(), Outcome::Granted);
}
#[tokio::test]
async fn deny_once_is_cached_for_the_run() {
let policy = Arc::new(FsPolicy::interactive_scripted(
None,
DirGrants::new(),
None,
[Choice::DenyOnce],
));
let p = Arc::clone(&policy);
assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Denied);
let p = Arc::clone(&policy);
assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Denied);
}
#[tokio::test]
async fn allow_always_persists_and_covers_subdirs() {
let tmp = tempfile::tempdir().unwrap();
let store_path = tmp.path().join("access.json");
let policy = Arc::new(FsPolicy::interactive_scripted(
Some("/s/tool.lua".into()),
DirGrants::new(),
Some(store_path.clone()),
[Choice::AllowAlways],
));
let p = Arc::clone(&policy);
assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted);
let saved = crate::stdlib::fs::store::load_for_script(&store_path, "/s/tool.lua");
assert_eq!(saved["/data"], DirAccess { read: Some(true), write: None });
let p = Arc::clone(&policy);
assert_eq!(p.check("/data/deep".into(), Wants::READ).await.unwrap(), Outcome::Granted);
}
#[tokio::test]
async fn deny_always_persists_false() {
let tmp = tempfile::tempdir().unwrap();
let store_path = tmp.path().join("access.json");
let policy = Arc::new(FsPolicy::interactive_scripted(
Some("/s/tool.lua".into()),
DirGrants::new(),
Some(store_path.clone()),
[Choice::DenyAlways],
));
let p = Arc::clone(&policy);
assert_eq!(p.check("/secrets".into(), Wants::WRITE).await.unwrap(), Outcome::Denied);
let saved = crate::stdlib::fs::store::load_for_script(&store_path, "/s/tool.lua");
assert_eq!(saved["/secrets"], DirAccess { read: None, write: Some(false) });
}
#[tokio::test]
async fn repl_always_answers_never_persist() {
let tmp = tempfile::tempdir().unwrap();
let store_path = tmp.path().join("access.json");
// script_key = None (REPL): store_path present but must not be used
let policy = Arc::new(FsPolicy::interactive_scripted(
None,
DirGrants::new(),
Some(store_path.clone()),
[Choice::AllowAlways],
));
let p = Arc::clone(&policy);
assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted);
assert!(!store_path.exists(), "REPL grant must not create the store");
// ...but it holds for the session
let p = Arc::clone(&policy);
assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted);
}
#[tokio::test]
async fn unknown_without_terminal_denies_and_records_nothing() {
let tmp = tempfile::tempdir().unwrap();
let store_path = tmp.path().join("access.json");
// empty answer queue = "no terminal"
let policy = Arc::new(FsPolicy::interactive_scripted(
Some("/s/tool.lua".into()),
DirGrants::new(),
Some(store_path.clone()),
[],
));
let p = Arc::clone(&policy);
assert_eq!(p.check("/data".into(), Wants::READ).await.unwrap(), Outcome::Denied);
assert!(!store_path.exists());
// nothing was cached either: a persisted grant arriving later (other
// process) would be honored — verify session stayed empty
if let FsMode::Interactive { session, .. } = &policy.mode {
assert!(session.lock().unwrap().is_empty());
}
}
#[tokio::test]
async fn persisted_grants_answer_without_prompting() {
let persisted = grants(&[("/data", Some(true), Some(false))]);
let policy = Arc::new(FsPolicy::interactive_scripted(
Some("/s/tool.lua".into()),
persisted,
None,
[], // no answers available: any prompt would deny
));
let p = Arc::clone(&policy);
assert_eq!(p.check("/data/x".into(), Wants::READ).await.unwrap(), Outcome::Granted);
let p = Arc::clone(&policy);
assert_eq!(p.check("/data/x".into(), Wants::WRITE).await.unwrap(), Outcome::Denied);
// combined read/write: the recorded write=false denies without asking
let p = Arc::clone(&policy);
assert_eq!(p.check("/data/x".into(), Wants::READ_WRITE).await.unwrap(), Outcome::Denied);
}
#[tokio::test]
async fn combined_prompt_records_only_missing_kinds() {
let tmp = tempfile::tempdir().unwrap();
let store_path = tmp.path().join("access.json");
// read already granted; a READ_WRITE check must only ask (and record)
// the missing write flag
let persisted = grants(&[("/db", Some(true), None)]);
let policy = Arc::new(FsPolicy::interactive_scripted(
Some("/s/tool.lua".into()),
persisted,
Some(store_path.clone()),
[Choice::AllowAlways],
));
let p = Arc::clone(&policy);
assert_eq!(p.check("/db".into(), Wants::READ_WRITE).await.unwrap(), Outcome::Granted);
let saved = crate::stdlib::fs::store::load_for_script(&store_path, "/s/tool.lua");
assert_eq!(
saved["/db"],
DirAccess { read: None, write: Some(true) },
"read was already known; only write gets recorded"
);
}
}

@ -258,14 +258,17 @@ async fn execute_request(
url: String,
opts: Option<LuaTable>,
) -> LuaResult<LuaTable> {
// Every request funnels through here (http.request, the shorthands, and
// all session methods) — the one choke point for --sandbox.
super::fs::ensure_net(&lua)?;
let 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<String> {
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();

@ -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)?;

@ -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<Policy> {
lua.app_data_ref::<Arc<Policy>>()
.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<bool> {
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")),
}
}

@ -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<Policy>`).
//! `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<Item = AccessKind> {
[(self.read, AccessKind::Read), (self.write, AccessKind::Write)]
.into_iter()
.filter_map(|(wanted, kind)| wanted.then_some(kind))
}
fn describe(self) -> &'static str {
match (self.read, self.write) {
(true, true) => "read/write",
(true, false) => "read",
(false, true) => "write",
(false, false) => "no",
}
}
}
/// 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<Override>,
/// Static answer for origin checks; `None` = interactive.
net_override: Option<Override>,
/// Store key = the script's canonical path. `None` in the REPL (and in
/// bare test states): prompts work, nothing is ever persisted.
script_key: Option<String>,
/// This script's grants from access.json, snapshotted at startup.
persisted: Mutex<ScriptGrants>,
/// Session-only answers — y/n, plus A/N in the REPL. Shadows `persisted`.
session: Mutex<ScriptGrants>,
/// Where A/N answers are persisted; `None` = no config dir found,
/// session-only operation.
store_path: Option<PathBuf>,
/// Scripted prompt answers for tests, consumed front to back; an
/// exhausted (or absent) queue behaves like "no terminal". In test
/// builds the real terminal prompt is never reached (see `prompted`).
#[cfg(test)]
scripted: Mutex<std::collections::VecDeque<Choice>>,
}
/// 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<Override>,
net_override: Option<Override>,
script_key: Option<String>,
persisted: ScriptGrants,
store_path: Option<PathBuf>,
) -> 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<String>,
persisted: ScriptGrants,
store_path: Option<PathBuf>,
) -> 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<String>,
persisted: ScriptGrants,
store_path: Option<PathBuf>,
answers: impl IntoIterator<Item = Choice>,
) -> 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<Self>,
dir: PathBuf,
wants: Wants,
) -> LuaResult<Outcome> {
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<Self>, origin: String) -> LuaResult<Outcome> {
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<Self>,
decide: impl FnOnce(&Policy, &dyn Fn(&str, &str) -> Choice) -> Outcome + Send + 'static,
) -> LuaResult<Outcome> {
#[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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
let mut cur = Some(dir);
while let Some(d) = cur {
let key = d.to_string_lossy();
for layer in [session, persisted] {
if let Some(v) = layer.get(key.as_ref()).and_then(|a| a.get(kind)) {
return Some(v);
}
}
cur = d.parent();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn grants(entries: &[(&str, Option<bool>, Option<bool>)]) -> 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
);
}
}

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

@ -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<String, DirAccess>;
/// 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<bool>,
}
/// Network grants recorded for one script: origin (`scheme://host[:port]`)
/// → access flag.
pub(crate) type OriginGrants = BTreeMap<String, OriginAccess>;
/// 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<String, DirGrants>,
pub(crate) scripts: BTreeMap<String, ScriptGrants>,
}
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]

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

Loading…
Cancel
Save