scopeの再設計
This commit is contained in:
+144
-115
@@ -1,6 +1,6 @@
|
||||
mod scope;
|
||||
|
||||
pub use scope::Scope;
|
||||
pub use scope::{Scope, ScopeError};
|
||||
|
||||
use std::num::NonZeroU32;
|
||||
use std::path::PathBuf;
|
||||
@@ -10,14 +10,13 @@ use serde::{Deserialize, Serialize};
|
||||
/// Declarative configuration for a Pod.
|
||||
///
|
||||
/// Parsed from a TOML manifest file. Describes the provider, model,
|
||||
/// system prompt, and optional directory scope.
|
||||
/// system prompt, and directory scope (required).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PodManifest {
|
||||
pub pod: PodMeta,
|
||||
pub provider: ProviderConfig,
|
||||
pub worker: WorkerManifest,
|
||||
#[serde(default)]
|
||||
pub scope: Option<ScopeConfig>,
|
||||
pub scope: ScopeConfig,
|
||||
#[serde(default)]
|
||||
pub compaction: Option<CompactionConfig>,
|
||||
}
|
||||
@@ -26,6 +25,9 @@ pub struct PodManifest {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PodMeta {
|
||||
pub name: String,
|
||||
/// Working directory for the Pod. Relative paths are resolved against
|
||||
/// the directory containing the manifest file.
|
||||
pub pwd: PathBuf,
|
||||
}
|
||||
|
||||
/// LLM provider configuration.
|
||||
@@ -79,10 +81,53 @@ pub struct WorkerManifest {
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
/// Directory scope configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
/// Declarative scope configuration.
|
||||
///
|
||||
/// A Pod may only touch paths whose effective permission (computed from
|
||||
/// allow/deny rules below) is at least `Read` / `Write`. See
|
||||
/// [`Scope`] for the resolved runtime form.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ScopeConfig {
|
||||
pub root: PathBuf,
|
||||
/// Rules granting access. At least one entry is required for the
|
||||
/// scope to be meaningful; [`Scope::from_config`] enforces this.
|
||||
#[serde(default)]
|
||||
pub allow: Vec<ScopeRule>,
|
||||
/// Rules capping access below the stated permission level. Empty by
|
||||
/// default.
|
||||
#[serde(default)]
|
||||
pub deny: Vec<ScopeRule>,
|
||||
}
|
||||
|
||||
/// A single allow or deny rule inside [`ScopeConfig`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScopeRule {
|
||||
/// Target path. Relative paths are resolved against the Pod's pwd
|
||||
/// when [`Scope::from_config`] runs.
|
||||
pub target: PathBuf,
|
||||
/// Permission level this rule grants (allow) or caps strictly below
|
||||
/// (deny).
|
||||
pub permission: Permission,
|
||||
/// When `false`, the rule only matches the target itself and its
|
||||
/// direct children. Defaults to `true`.
|
||||
#[serde(default = "default_recursive")]
|
||||
pub recursive: bool,
|
||||
}
|
||||
|
||||
fn default_recursive() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Permission lattice used by [`ScopeRule`].
|
||||
///
|
||||
/// The derived `Ord` instance follows declaration order, so
|
||||
/// `Read < Write`. Allow rules grant the stated level (and by extension
|
||||
/// everything below); deny rules cap the effective level **strictly
|
||||
/// below** the stated level.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Permission {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
/// Context compaction configuration.
|
||||
@@ -146,24 +191,32 @@ impl PodManifest {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_manifest() {
|
||||
let toml = r#"
|
||||
const MINIMAL_REQUIRED: &str = r#"
|
||||
[pod]
|
||||
name = "test-agent"
|
||||
pwd = "./"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
|
||||
[worker]
|
||||
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
permission = "write"
|
||||
"#;
|
||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_manifest() {
|
||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
assert_eq!(manifest.pod.name, "test-agent");
|
||||
assert_eq!(manifest.pod.pwd, PathBuf::from("./"));
|
||||
assert_eq!(manifest.provider.kind, ProviderKind::Anthropic);
|
||||
assert_eq!(manifest.provider.model, "claude-sonnet-4-20250514");
|
||||
assert!(manifest.provider.api_key_file.is_none());
|
||||
assert!(manifest.scope.is_none());
|
||||
assert_eq!(manifest.scope.allow.len(), 1);
|
||||
assert!(manifest.scope.deny.is_empty());
|
||||
assert!(manifest.worker.system_prompt.is_none());
|
||||
}
|
||||
|
||||
@@ -172,6 +225,7 @@ model = "claude-sonnet-4-20250514"
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "code-reviewer"
|
||||
pwd = "./src"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
@@ -183,11 +237,22 @@ system_prompt = "You are a code reviewer."
|
||||
max_tokens = 4096
|
||||
temperature = 0.3
|
||||
|
||||
[scope]
|
||||
root = "./src"
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
permission = "write"
|
||||
|
||||
[[scope.allow]]
|
||||
target = "../docs"
|
||||
permission = "read"
|
||||
recursive = false
|
||||
|
||||
[[scope.deny]]
|
||||
target = "./secrets.rs"
|
||||
permission = "write"
|
||||
"#;
|
||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
||||
assert_eq!(manifest.pod.name, "code-reviewer");
|
||||
assert_eq!(manifest.pod.pwd, PathBuf::from("./src"));
|
||||
assert_eq!(
|
||||
manifest.provider.api_key_file.as_deref(),
|
||||
Some(std::path::Path::new("~/.config/insomnia/keys/anthropic"))
|
||||
@@ -198,83 +263,37 @@ root = "./src"
|
||||
);
|
||||
assert_eq!(manifest.worker.max_tokens, Some(4096));
|
||||
assert_eq!(manifest.worker.temperature, Some(0.3));
|
||||
assert_eq!(
|
||||
manifest.scope.as_ref().unwrap().root,
|
||||
PathBuf::from("./src")
|
||||
);
|
||||
let allow = &manifest.scope.allow;
|
||||
assert_eq!(allow.len(), 2);
|
||||
assert_eq!(allow[0].permission, Permission::Write);
|
||||
assert!(allow[0].recursive);
|
||||
assert_eq!(allow[1].permission, Permission::Read);
|
||||
assert!(!allow[1].recursive);
|
||||
assert_eq!(manifest.scope.deny.len(), 1);
|
||||
assert_eq!(manifest.scope.deny[0].permission, Permission::Write);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ollama_no_api_key() {
|
||||
fn reject_missing_scope() {
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "local-agent"
|
||||
|
||||
[provider]
|
||||
kind = "ollama"
|
||||
model = "llama3"
|
||||
|
||||
[worker]
|
||||
"#;
|
||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
||||
assert_eq!(manifest.provider.kind, ProviderKind::Ollama);
|
||||
assert!(manifest.provider.api_key_file.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_max_turns() {
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "test"
|
||||
name = "missing-scope"
|
||||
pwd = "./"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
|
||||
[worker]
|
||||
max_turns = 50
|
||||
"#;
|
||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
||||
assert_eq!(manifest.worker.max_turns.unwrap().get(), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_max_turns_is_none() {
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "test"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
|
||||
[worker]
|
||||
"#;
|
||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
||||
assert!(manifest.worker.max_turns.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_max_turns_zero() {
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "test"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
|
||||
[worker]
|
||||
max_turns = 0
|
||||
"#;
|
||||
assert!(PodManifest::from_toml(toml).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_compaction_config() {
|
||||
fn reject_missing_pwd() {
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "test"
|
||||
name = "missing-pwd"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
@@ -282,10 +301,36 @@ model = "claude-sonnet-4-20250514"
|
||||
|
||||
[worker]
|
||||
|
||||
[compaction]
|
||||
compact_threshold = 80000
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
permission = "write"
|
||||
"#;
|
||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
||||
assert!(PodManifest::from_toml(toml).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_max_turns() {
|
||||
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nmax_turns = 50\n");
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
assert_eq!(manifest.worker.max_turns.unwrap().get(), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_max_turns_is_none() {
|
||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
assert!(manifest.worker.max_turns.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_max_turns_zero() {
|
||||
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nmax_turns = 0\n");
|
||||
assert!(PodManifest::from_toml(&toml).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_compaction_config() {
|
||||
let toml = format!("{MINIMAL_REQUIRED}\n[compaction]\ncompact_threshold = 80000\n");
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let c = manifest.compaction.unwrap();
|
||||
assert_eq!(c.prune_protected_turns, 3);
|
||||
assert_eq!(c.prune_min_savings, 4096);
|
||||
@@ -295,24 +340,15 @@ compact_threshold = 80000
|
||||
|
||||
#[test]
|
||||
fn parse_compaction_with_provider() {
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "test"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
|
||||
[worker]
|
||||
|
||||
[compaction]
|
||||
compact_threshold = 80000
|
||||
|
||||
[compaction.provider]
|
||||
kind = "gemini"
|
||||
model = "gemini-2.0-flash"
|
||||
"#;
|
||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
||||
let toml = format!(
|
||||
"{MINIMAL_REQUIRED}\n\
|
||||
[compaction]\n\
|
||||
compact_threshold = 80000\n\n\
|
||||
[compaction.provider]\n\
|
||||
kind = \"gemini\"\n\
|
||||
model = \"gemini-2.0-flash\"\n"
|
||||
);
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let c = manifest.compaction.unwrap();
|
||||
let p = c.provider.unwrap();
|
||||
assert_eq!(p.kind, ProviderKind::Gemini);
|
||||
@@ -321,32 +357,25 @@ model = "gemini-2.0-flash"
|
||||
|
||||
#[test]
|
||||
fn omitted_compaction_is_none() {
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "test"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
|
||||
[worker]
|
||||
"#;
|
||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
assert!(manifest.compaction.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_unknown_provider() {
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "test"
|
||||
let toml = MINIMAL_REQUIRED.replace("kind = \"anthropic\"", "kind = \"unknown_provider\"");
|
||||
assert!(PodManifest::from_toml(&toml).is_err());
|
||||
}
|
||||
|
||||
[provider]
|
||||
kind = "unknown_provider"
|
||||
model = "x"
|
||||
|
||||
[worker]
|
||||
"#;
|
||||
assert!(PodManifest::from_toml(toml).is_err());
|
||||
#[test]
|
||||
fn default_recursive_true() {
|
||||
let rule: ScopeRule = toml::from_str(
|
||||
r#"
|
||||
target = "./"
|
||||
permission = "read"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(rule.recursive);
|
||||
}
|
||||
}
|
||||
|
||||
+299
-65
@@ -1,115 +1,349 @@
|
||||
//! Runtime representation of a Pod's access scope.
|
||||
//!
|
||||
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`] once the
|
||||
//! Pod's pwd (working directory) has been resolved to an absolute path.
|
||||
//! All rule `target` paths inside the [`Scope`] are absolute and lexically
|
||||
//! stable, so access checks are pure path comparisons.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Directory scope constraining a Pod's write access.
|
||||
use crate::{Permission, ScopeConfig, ScopeRule};
|
||||
|
||||
/// Parsed, pwd-resolved set of allow/deny rules for a Pod.
|
||||
///
|
||||
/// Read access is unrestricted — only write operations are checked against the scope.
|
||||
/// Read/write access decisions are pure functions of the path being
|
||||
/// queried and these rules — see [`Scope::permission_at`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Scope {
|
||||
root: PathBuf,
|
||||
allow: Vec<ResolvedRule>,
|
||||
deny: Vec<ResolvedRule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ResolvedRule {
|
||||
/// Absolute, canonicalized-or-normalized target directory/file.
|
||||
target: PathBuf,
|
||||
permission: Permission,
|
||||
recursive: bool,
|
||||
}
|
||||
|
||||
/// Errors raised when constructing a [`Scope`] from a [`ScopeConfig`].
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ScopeError {
|
||||
#[error("scope must declare at least one [[scope.allow]] rule")]
|
||||
EmptyAllow,
|
||||
#[error("scope base path must be absolute: {}", .0.display())]
|
||||
BaseNotAbsolute(PathBuf),
|
||||
#[error("failed to resolve scope target {}: {source}", .path.display())]
|
||||
ResolveTarget {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
/// Create a new scope rooted at the given directory.
|
||||
///
|
||||
/// The path is canonicalized to resolve symlinks and relative components.
|
||||
pub fn new(root: impl Into<PathBuf>) -> std::io::Result<Self> {
|
||||
let root = root.into().canonicalize()?;
|
||||
Ok(Self { root })
|
||||
/// Build a [`Scope`] from a declarative [`ScopeConfig`], resolving
|
||||
/// relative `target` paths against `base` (conventionally the Pod's
|
||||
/// absolute pwd).
|
||||
pub fn from_config(config: &ScopeConfig, base: &Path) -> Result<Self, ScopeError> {
|
||||
if !base.is_absolute() {
|
||||
return Err(ScopeError::BaseNotAbsolute(base.to_path_buf()));
|
||||
}
|
||||
if config.allow.is_empty() {
|
||||
return Err(ScopeError::EmptyAllow);
|
||||
}
|
||||
let allow = config
|
||||
.allow
|
||||
.iter()
|
||||
.map(|r| resolve_rule(r, base))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let deny = config
|
||||
.deny
|
||||
.iter()
|
||||
.map(|r| resolve_rule(r, base))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(Self { allow, deny })
|
||||
}
|
||||
|
||||
/// The root directory of this scope.
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
/// Convenience constructor for tests and simple setups: a single
|
||||
/// recursive `allow(Write)` rule rooted at `root`.
|
||||
pub fn writable(root: impl AsRef<Path>) -> std::io::Result<Self> {
|
||||
let root = root.as_ref().canonicalize()?;
|
||||
Ok(Self {
|
||||
allow: vec![ResolvedRule {
|
||||
target: root,
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check whether `path` falls within this scope.
|
||||
/// Effective permission for `path`.
|
||||
///
|
||||
/// The path is canonicalized before comparison. If the path does not
|
||||
/// exist yet (typical for new-file writes), the closest existing
|
||||
/// ancestor is canonicalized and checked, so deep new directory
|
||||
/// hierarchies inside the scope are also accepted.
|
||||
pub fn contains(&self, path: &Path) -> bool {
|
||||
let mut cur = path;
|
||||
loop {
|
||||
if let Ok(canonical) = cur.canonicalize() {
|
||||
return canonical.starts_with(&self.root);
|
||||
}
|
||||
match cur.parent() {
|
||||
Some(parent) if parent != cur => cur = parent,
|
||||
_ => return false,
|
||||
/// Returns `None` when `path` is outside every allow rule, or when
|
||||
/// deny rules have knocked it below `Read`.
|
||||
pub fn permission_at(&self, path: &Path) -> Option<Permission> {
|
||||
let resolved = resolve_path(path)?;
|
||||
let mut effective: Option<Permission> = None;
|
||||
for rule in &self.allow {
|
||||
if rule.matches(&resolved) {
|
||||
effective = match effective {
|
||||
None => Some(rule.permission),
|
||||
Some(cur) => Some(cur.max(rule.permission)),
|
||||
};
|
||||
}
|
||||
}
|
||||
let mut effective = effective?;
|
||||
|
||||
// Deny: min(min_deny) dictates the cap. Effective level is capped
|
||||
// strictly below that value, so deny(read) wipes access entirely.
|
||||
let mut min_deny: Option<Permission> = None;
|
||||
for rule in &self.deny {
|
||||
if rule.matches(&resolved) {
|
||||
min_deny = match min_deny {
|
||||
None => Some(rule.permission),
|
||||
Some(cur) => Some(cur.min(rule.permission)),
|
||||
};
|
||||
}
|
||||
}
|
||||
if let Some(cap) = min_deny {
|
||||
match cap {
|
||||
Permission::Read => return None,
|
||||
Permission::Write => effective = effective.min(Permission::Read),
|
||||
}
|
||||
}
|
||||
Some(effective)
|
||||
}
|
||||
|
||||
/// Shorthand: `permission_at(path) >= Some(Read)`.
|
||||
pub fn is_readable(&self, path: &Path) -> bool {
|
||||
matches!(
|
||||
self.permission_at(path),
|
||||
Some(Permission::Read | Permission::Write)
|
||||
)
|
||||
}
|
||||
|
||||
/// Shorthand: `permission_at(path) == Some(Write)`.
|
||||
pub fn is_writable(&self, path: &Path) -> bool {
|
||||
matches!(self.permission_at(path), Some(Permission::Write))
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvedRule {
|
||||
fn matches(&self, path: &Path) -> bool {
|
||||
if self.recursive {
|
||||
path.starts_with(&self.target)
|
||||
} else {
|
||||
path == self.target || path.parent() == Some(self.target.as_path())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_rule(rule: &ScopeRule, base: &Path) -> Result<ResolvedRule, ScopeError> {
|
||||
let joined = if rule.target.is_absolute() {
|
||||
rule.target.clone()
|
||||
} else {
|
||||
base.join(&rule.target)
|
||||
};
|
||||
let target = resolve_path(&joined).ok_or_else(|| ScopeError::ResolveTarget {
|
||||
path: rule.target.clone(),
|
||||
source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"),
|
||||
})?;
|
||||
Ok(ResolvedRule {
|
||||
target,
|
||||
permission: rule.permission,
|
||||
recursive: rule.recursive,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert `path` to an absolute form suitable for prefix comparison.
|
||||
///
|
||||
/// Tries `canonicalize` on the full path first (resolves symlinks). If
|
||||
/// the path doesn't exist yet, climbs to the closest existing ancestor,
|
||||
/// canonicalizes it, then rejoins the missing tail. Returns `None` for
|
||||
/// relative inputs that have no existing ancestor to anchor against.
|
||||
fn resolve_path(path: &Path) -> Option<PathBuf> {
|
||||
if !path.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
if let Ok(canonical) = path.canonicalize() {
|
||||
return Some(canonical);
|
||||
}
|
||||
let mut tail: Vec<OsString> = Vec::new();
|
||||
let mut cur = path.to_path_buf();
|
||||
loop {
|
||||
if let Ok(canonical) = cur.canonicalize() {
|
||||
let mut out = canonical;
|
||||
for segment in tail.iter().rev() {
|
||||
out.push(segment);
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
let name = cur.file_name()?.to_os_string();
|
||||
tail.push(name);
|
||||
let parent = cur.parent()?.to_path_buf();
|
||||
if parent == cur {
|
||||
return None;
|
||||
}
|
||||
cur = parent;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn contains_file_inside_scope() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = Scope::new(dir.path()).unwrap();
|
||||
|
||||
let file = dir.path().join("test.txt");
|
||||
fs::write(&file, "hello").unwrap();
|
||||
|
||||
assert!(scope.contains(&file));
|
||||
fn allow_rule(target: &Path, permission: Permission) -> ScopeRule {
|
||||
ScopeRule {
|
||||
target: target.to_path_buf(),
|
||||
permission,
|
||||
recursive: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_file_outside_scope() {
|
||||
fn writable_shortcut_permits_root() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = Scope::writable(dir.path()).unwrap();
|
||||
assert!(scope.is_writable(&dir.path().join("a.txt")));
|
||||
assert!(scope.is_readable(&dir.path().join("a.txt")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writable_shortcut_rejects_outside() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let scope = Scope::new(dir.path()).unwrap();
|
||||
|
||||
let file = outside.path().join("test.txt");
|
||||
fs::write(&file, "hello").unwrap();
|
||||
|
||||
assert!(!scope.contains(&file));
|
||||
let scope = Scope::writable(dir.path()).unwrap();
|
||||
assert!(!scope.is_readable(&outside.path().join("x")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_new_file_in_existing_parent() {
|
||||
fn allow_write_grants_read_and_write() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = Scope::new(dir.path()).unwrap();
|
||||
|
||||
// File doesn't exist yet, but parent dir is inside scope
|
||||
let new_file = dir.path().join("new.txt");
|
||||
assert!(scope.contains(&new_file));
|
||||
let cfg = ScopeConfig {
|
||||
allow: vec![allow_rule(dir.path(), Permission::Write)],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let f = dir.path().join("a.txt");
|
||||
assert_eq!(scope.permission_at(&f), Some(Permission::Write));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_nested_directory() {
|
||||
fn allow_read_only() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let nested = dir.path().join("a/b/c");
|
||||
fs::create_dir_all(&nested).unwrap();
|
||||
let scope = Scope::new(dir.path()).unwrap();
|
||||
let cfg = ScopeConfig {
|
||||
allow: vec![allow_rule(dir.path(), Permission::Read)],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let f = dir.path().join("a.txt");
|
||||
assert_eq!(scope.permission_at(&f), Some(Permission::Read));
|
||||
assert!(scope.is_readable(&f));
|
||||
assert!(!scope.is_writable(&f));
|
||||
}
|
||||
|
||||
let file = nested.join("test.txt");
|
||||
assert!(scope.contains(&file));
|
||||
#[test]
|
||||
fn deny_write_downgrades_to_read() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let sub = dir.path().join("sub");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
let cfg = ScopeConfig {
|
||||
allow: vec![allow_rule(dir.path(), Permission::Write)],
|
||||
deny: vec![allow_rule(&sub, Permission::Write)],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let f = sub.join("a.txt");
|
||||
assert_eq!(scope.permission_at(&f), Some(Permission::Read));
|
||||
// outside the deny, still writable.
|
||||
assert_eq!(
|
||||
scope.permission_at(&dir.path().join("top.txt")),
|
||||
Some(Permission::Write)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_read_removes_access_entirely() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let secret = dir.path().join("secret.txt");
|
||||
std::fs::write(&secret, b"").unwrap();
|
||||
let cfg = ScopeConfig {
|
||||
allow: vec![allow_rule(dir.path(), Permission::Write)],
|
||||
deny: vec![allow_rule(&secret, Permission::Read)],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
assert_eq!(scope.permission_at(&secret), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_allow_rules_take_max() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let docs = dir.path().join("docs");
|
||||
std::fs::create_dir(&docs).unwrap();
|
||||
let cfg = ScopeConfig {
|
||||
allow: vec![
|
||||
allow_rule(dir.path(), Permission::Read),
|
||||
allow_rule(&docs, Permission::Write),
|
||||
],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
assert_eq!(
|
||||
scope.permission_at(&dir.path().join("a.txt")),
|
||||
Some(Permission::Read)
|
||||
);
|
||||
assert_eq!(
|
||||
scope.permission_at(&docs.join("a.txt")),
|
||||
Some(Permission::Write)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_recursive_rule_matches_direct_children_only() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let nested = dir.path().join("a/b");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
let cfg = ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: false,
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
assert!(scope.is_writable(&dir.path().join("top.txt")));
|
||||
assert!(!scope.is_writable(&nested.join("deep.txt")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_allow_rejected() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let cfg = ScopeConfig {
|
||||
allow: Vec::new(),
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let err = Scope::from_config(&cfg, dir.path()).unwrap_err();
|
||||
assert!(matches!(err, ScopeError::EmptyAllow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_traversal_attack() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = Scope::new(dir.path()).unwrap();
|
||||
|
||||
let scope = Scope::writable(dir.path()).unwrap();
|
||||
let traversal = dir.path().join("../../../etc/passwd");
|
||||
assert!(!scope.contains(&traversal));
|
||||
assert!(!scope.is_readable(&traversal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_deeply_nested_new_path() {
|
||||
fn resolves_new_nested_file_inside_scope() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = Scope::new(dir.path()).unwrap();
|
||||
|
||||
// Neither the file nor any of its ancestors (a, a/b, a/b/c) exist yet
|
||||
// under the scope; contains should still accept because the closest
|
||||
// existing ancestor (the scope root) is inside the scope.
|
||||
let scope = Scope::writable(dir.path()).unwrap();
|
||||
let deep = dir.path().join("a/b/c/new.txt");
|
||||
assert!(scope.contains(&deep));
|
||||
assert!(scope.is_writable(&deep));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user