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