parent
c2857f52e8
commit
de5f536e7d
@ -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" |
||||
); |
||||
} |
||||
} |
||||
@ -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 |
||||
); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue