マニフェスト解決の相対パス化
This commit is contained in:
@@ -39,8 +39,6 @@ pub struct PodManifestConfig {
|
||||
pub struct PodMetaConfig {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -137,6 +135,40 @@ impl PodManifestConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve every relative path inside this partial config against
|
||||
/// `base` (assumed absolute). Paths that are already absolute are
|
||||
/// left untouched. This is the only place per-layer path resolution
|
||||
/// happens — cascade merge runs against fully absolute paths so
|
||||
/// rules from different layers do not accidentally inherit another
|
||||
/// layer's base.
|
||||
///
|
||||
/// Affected fields: `provider.api_key_file`,
|
||||
/// `scope.allow[].target`, `scope.deny[].target`,
|
||||
/// `compaction.provider.api_key_file`.
|
||||
pub fn resolve_paths(mut self, base: &Path) -> Self {
|
||||
debug_assert!(
|
||||
base.is_absolute(),
|
||||
"resolve_paths base must be absolute: {}",
|
||||
base.display()
|
||||
);
|
||||
if let Some(ref mut p) = self.provider.api_key_file {
|
||||
*p = join_if_relative(base, p);
|
||||
}
|
||||
for rule in &mut self.scope.allow {
|
||||
rule.target = join_if_relative(base, &rule.target);
|
||||
}
|
||||
for rule in &mut self.scope.deny {
|
||||
rule.target = join_if_relative(base, &rule.target);
|
||||
}
|
||||
if let Some(ref mut compaction) = self.compaction
|
||||
&& let Some(ref mut cp) = compaction.provider
|
||||
&& let Some(ref mut p) = cp.api_key_file
|
||||
{
|
||||
*p = join_if_relative(base, p);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Merge `upper` into `self`. Fields present in `upper` override
|
||||
/// fields from `self`. Map entries merge key-wise with `upper`
|
||||
/// winning on conflict. Scope rules from both layers accumulate
|
||||
@@ -160,7 +192,6 @@ impl PodMetaConfig {
|
||||
fn merge(self, upper: Self) -> Self {
|
||||
Self {
|
||||
name: upper.name.or(self.name),
|
||||
pwd: upper.pwd.or(self.pwd),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,6 +257,18 @@ fn merge_option<T>(lower: Option<T>, upper: Option<T>, merge: fn(T, T) -> T) ->
|
||||
}
|
||||
}
|
||||
|
||||
fn join_if_relative(base: &Path, p: &Path) -> PathBuf {
|
||||
if p.is_absolute() {
|
||||
p.to_path_buf()
|
||||
} else {
|
||||
base.join(p)
|
||||
}
|
||||
}
|
||||
|
||||
/// Invariant check: every path in a fully-resolved [`PodManifestConfig`]
|
||||
/// must be absolute. Relative paths are resolved per-layer via
|
||||
/// [`PodManifestConfig::resolve_paths`]; if one reaches `TryFrom` it
|
||||
/// indicates a caller skipped the per-layer resolve step.
|
||||
fn ensure_absolute(field: &'static str, path: &Path) -> Result<(), ResolveError> {
|
||||
if path.is_absolute() {
|
||||
Ok(())
|
||||
@@ -264,8 +307,6 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||
.pod
|
||||
.name
|
||||
.ok_or(ResolveError::MissingField("pod.name"))?;
|
||||
let pwd = cfg.pod.pwd.ok_or(ResolveError::MissingField("pod.pwd"))?;
|
||||
ensure_absolute("pod.pwd", &pwd)?;
|
||||
|
||||
let provider = resolve_provider(
|
||||
cfg.provider,
|
||||
@@ -333,7 +374,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||
.transpose()?;
|
||||
|
||||
Ok(PodManifest {
|
||||
pod: PodMeta { name, pwd },
|
||||
pod: PodMeta { name },
|
||||
provider,
|
||||
worker,
|
||||
scope: cfg.scope,
|
||||
@@ -355,7 +396,6 @@ mod tests {
|
||||
PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
name: Some("test".into()),
|
||||
pwd: Some(abs("/pod")),
|
||||
},
|
||||
provider: ProviderConfigPartial {
|
||||
kind: Some(ProviderKind::Anthropic),
|
||||
@@ -379,25 +419,54 @@ mod tests {
|
||||
fn resolve_minimal_succeeds() {
|
||||
let manifest: PodManifest = minimal_valid().try_into().unwrap();
|
||||
assert_eq!(manifest.pod.name, "test");
|
||||
assert_eq!(manifest.pod.pwd, abs("/pod"));
|
||||
assert_eq!(manifest.provider.kind, ProviderKind::Anthropic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_rejects_relative_pwd() {
|
||||
fn resolve_paths_joins_relative_api_key_file() {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.pod.pwd = Some(PathBuf::from("./rel"));
|
||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ResolveError::RelativePath { field: "pod.pwd", .. }
|
||||
));
|
||||
cfg.provider.api_key_file = Some(PathBuf::from("keys/anthropic"));
|
||||
let resolved = cfg.resolve_paths(Path::new("/home/user/.config/insomnia"));
|
||||
assert_eq!(
|
||||
resolved.provider.api_key_file.as_deref(),
|
||||
Some(Path::new("/home/user/.config/insomnia/keys/anthropic"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_rejects_relative_api_key_file() {
|
||||
fn resolve_paths_leaves_absolute_paths_untouched() {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.provider.api_key_file = Some(PathBuf::from("~/.config/key"));
|
||||
cfg.provider.api_key_file = Some(PathBuf::from("/etc/already/abs"));
|
||||
let resolved = cfg.resolve_paths(Path::new("/home/user"));
|
||||
assert_eq!(
|
||||
resolved.provider.api_key_file.as_deref(),
|
||||
Some(Path::new("/etc/already/abs"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_paths_joins_relative_scope_targets() {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.scope.allow[0].target = PathBuf::from(".");
|
||||
cfg.scope.deny.push(ScopeRule {
|
||||
target: PathBuf::from("secrets"),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
});
|
||||
let resolved = cfg.resolve_paths(Path::new("/workspace/proj"));
|
||||
assert_eq!(resolved.scope.allow[0].target, Path::new("/workspace/proj"));
|
||||
assert_eq!(
|
||||
resolved.scope.deny[0].target,
|
||||
Path::new("/workspace/proj/secrets")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_from_invariant_rejects_lingering_relative_api_key_file() {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.provider.api_key_file = Some(PathBuf::from("keys/relative"));
|
||||
// Skipping resolve_paths on purpose: TryFrom must catch the
|
||||
// invariant violation.
|
||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
@@ -409,9 +478,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_rejects_relative_scope_target() {
|
||||
fn try_from_invariant_rejects_lingering_relative_scope_target() {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.scope.allow[0].target = PathBuf::from("./docs");
|
||||
cfg.scope.allow[0].target = PathBuf::from("docs");
|
||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
@@ -443,21 +512,23 @@ mod tests {
|
||||
let lower = PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
name: Some("lower".into()),
|
||||
pwd: Some(abs("/lower")),
|
||||
},
|
||||
provider: ProviderConfigPartial {
|
||||
model: Some("lower-model".into()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let upper = PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
name: Some("upper".into()),
|
||||
pwd: None,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let merged = lower.merge(upper);
|
||||
assert_eq!(merged.pod.name.as_deref(), Some("upper"));
|
||||
// pwd not present in upper — retain lower
|
||||
assert_eq!(merged.pod.pwd, Some(abs("/lower")));
|
||||
// model not present in upper — retain lower
|
||||
assert_eq!(merged.provider.model.as_deref(), Some("lower-model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -556,7 +627,6 @@ mod tests {
|
||||
let bad = r#"
|
||||
[pod]
|
||||
name = "x"
|
||||
pwd = "/abs"
|
||||
|
||||
[worker]
|
||||
max_tokens = "not-a-number"
|
||||
@@ -567,10 +637,13 @@ max_tokens = "not-a-number"
|
||||
#[test]
|
||||
fn from_toml_accepts_unknown_field() {
|
||||
// Unknown keys are warn-and-ignored, not hard errors.
|
||||
// `pod.pwd` specifically is silently dropped after the
|
||||
// path-resolution ticket — keep it in the fixture to exercise
|
||||
// that code path.
|
||||
let ok = r#"
|
||||
[pod]
|
||||
name = "x"
|
||||
pwd = "/abs"
|
||||
pwd = "/obsolete"
|
||||
|
||||
[worker]
|
||||
max_tokens = 1000
|
||||
@@ -610,7 +683,6 @@ permission = "write"
|
||||
let overlay = PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
name: Some("x".into()),
|
||||
pwd: Some(abs("/pod")),
|
||||
},
|
||||
provider: ProviderConfigPartial {
|
||||
kind: Some(ProviderKind::Anthropic),
|
||||
@@ -658,7 +730,6 @@ permission = "write"
|
||||
r#"
|
||||
[pod]
|
||||
name = "dbg"
|
||||
pwd = "/abs/project"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -666,7 +737,6 @@ pwd = "/abs/project"
|
||||
let merged = builtin.merge(user).merge(project).merge(overlay);
|
||||
let manifest: PodManifest = merged.try_into().unwrap();
|
||||
assert_eq!(manifest.pod.name, "dbg");
|
||||
assert_eq!(manifest.pod.pwd, PathBuf::from("/abs/project"));
|
||||
assert_eq!(manifest.provider.kind, ProviderKind::Anthropic);
|
||||
assert_eq!(manifest.scope.allow.len(), 1);
|
||||
}
|
||||
|
||||
+12
-36
@@ -17,7 +17,9 @@ use serde::{Deserialize, Serialize};
|
||||
/// Declarative configuration for a Pod.
|
||||
///
|
||||
/// Parsed from a TOML manifest file. Describes the provider, model,
|
||||
/// system prompt, and directory scope (required).
|
||||
/// system prompt, and directory scope (required). The Pod's working
|
||||
/// directory is **not** part of the manifest — it is the process's
|
||||
/// `std::env::current_dir()` at construction time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PodManifest {
|
||||
pub pod: PodMeta,
|
||||
@@ -32,9 +34,6 @@ 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.
|
||||
@@ -163,8 +162,9 @@ pub struct ScopeConfig {
|
||||
/// 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.
|
||||
/// Target path. Must be absolute by the time [`Scope::from_config`]
|
||||
/// runs — relative paths are resolved per-layer against the manifest
|
||||
/// file's directory (cwd for overlay layers) before cascade merge.
|
||||
pub target: PathBuf,
|
||||
/// Permission level this rule grants (allow) or caps strictly below
|
||||
/// (deny).
|
||||
@@ -256,7 +256,6 @@ mod tests {
|
||||
const MINIMAL_REQUIRED: &str = r#"
|
||||
[pod]
|
||||
name = "test-agent"
|
||||
pwd = "./"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
@@ -265,7 +264,7 @@ model = "claude-sonnet-4-20250514"
|
||||
[worker]
|
||||
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
target = "/abs/scope"
|
||||
permission = "write"
|
||||
"#;
|
||||
|
||||
@@ -273,7 +272,6 @@ permission = "write"
|
||||
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());
|
||||
@@ -287,12 +285,11 @@ permission = "write"
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "code-reviewer"
|
||||
pwd = "./src"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
api_key_file = "~/.config/insomnia/keys/anthropic"
|
||||
api_key_file = "/abs/keys/anthropic"
|
||||
|
||||
[worker]
|
||||
instruction = "$user/reviewer"
|
||||
@@ -300,24 +297,23 @@ max_tokens = 4096
|
||||
temperature = 0.3
|
||||
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
target = "/abs/project"
|
||||
permission = "write"
|
||||
|
||||
[[scope.allow]]
|
||||
target = "../docs"
|
||||
target = "/abs/docs"
|
||||
permission = "read"
|
||||
recursive = false
|
||||
|
||||
[[scope.deny]]
|
||||
target = "./secrets.rs"
|
||||
target = "/abs/project/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"))
|
||||
Some(std::path::Path::new("/abs/keys/anthropic"))
|
||||
);
|
||||
assert_eq!(manifest.worker.instruction, "$user/reviewer");
|
||||
assert_eq!(manifest.worker.max_tokens, Some(4096));
|
||||
@@ -337,7 +333,6 @@ permission = "write"
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "missing-scope"
|
||||
pwd = "./"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
@@ -348,25 +343,6 @@ model = "claude-sonnet-4-20250514"
|
||||
assert!(PodManifest::from_toml(toml).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_missing_pwd() {
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "missing-pwd"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
|
||||
[worker]
|
||||
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
permission = "write"
|
||||
"#;
|
||||
assert!(PodManifest::from_toml(toml).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_max_turns() {
|
||||
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nmax_turns = 50\n");
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! 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.
|
||||
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
|
||||
//! rule `target` must already be an absolute path — per-layer path
|
||||
//! resolution runs earlier, inside [`crate::PodManifestConfig::resolve_paths`].
|
||||
//! All rule `target` paths inside the [`Scope`] are canonicalised (where
|
||||
//! possible) so access checks are pure path comparisons.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -33,8 +34,8 @@ struct ResolvedRule {
|
||||
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("scope target must be absolute: {}", .0.display())]
|
||||
RelativeTarget(PathBuf),
|
||||
#[error("failed to resolve scope target {}: {source}", .path.display())]
|
||||
ResolveTarget {
|
||||
path: PathBuf,
|
||||
@@ -44,25 +45,26 @@ pub enum ScopeError {
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
/// 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()));
|
||||
}
|
||||
/// Build a [`Scope`] from a declarative [`ScopeConfig`].
|
||||
///
|
||||
/// Every `target` in `config` must already be absolute — per-layer
|
||||
/// resolution happens upstream in
|
||||
/// [`crate::PodManifestConfig::resolve_paths`] so that cascade merge
|
||||
/// operates on fully-qualified paths. A lingering relative target
|
||||
/// here signals an upstream bug and is rejected.
|
||||
pub fn from_config(config: &ScopeConfig) -> Result<Self, ScopeError> {
|
||||
if config.allow.is_empty() {
|
||||
return Err(ScopeError::EmptyAllow);
|
||||
}
|
||||
let allow = config
|
||||
.allow
|
||||
.iter()
|
||||
.map(|r| resolve_rule(r, base))
|
||||
.map(resolve_rule)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let deny = config
|
||||
.deny
|
||||
.iter()
|
||||
.map(|r| resolve_rule(r, base))
|
||||
.map(resolve_rule)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(Self { allow, deny })
|
||||
}
|
||||
@@ -221,13 +223,11 @@ impl ResolvedRule {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
fn resolve_rule(rule: &ScopeRule) -> Result<ResolvedRule, ScopeError> {
|
||||
if !rule.target.is_absolute() {
|
||||
return Err(ScopeError::RelativeTarget(rule.target.clone()));
|
||||
}
|
||||
let target = resolve_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget {
|
||||
path: rule.target.clone(),
|
||||
source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"),
|
||||
})?;
|
||||
@@ -307,7 +307,7 @@ mod tests {
|
||||
allow: vec![allow_rule(dir.path(), Permission::Write)],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let f = dir.path().join("a.txt");
|
||||
assert_eq!(scope.permission_at(&f), Some(Permission::Write));
|
||||
}
|
||||
@@ -319,7 +319,7 @@ mod tests {
|
||||
allow: vec![allow_rule(dir.path(), Permission::Read)],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let f = dir.path().join("a.txt");
|
||||
assert_eq!(scope.permission_at(&f), Some(Permission::Read));
|
||||
assert!(scope.is_readable(&f));
|
||||
@@ -335,7 +335,7 @@ mod tests {
|
||||
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 scope = Scope::from_config(&cfg).unwrap();
|
||||
let f = sub.join("a.txt");
|
||||
assert_eq!(scope.permission_at(&f), Some(Permission::Read));
|
||||
// outside the deny, still writable.
|
||||
@@ -354,7 +354,7 @@ mod tests {
|
||||
allow: vec![allow_rule(dir.path(), Permission::Write)],
|
||||
deny: vec![allow_rule(&secret, Permission::Read)],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
assert_eq!(scope.permission_at(&secret), None);
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@ mod tests {
|
||||
],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
assert_eq!(
|
||||
scope.permission_at(&dir.path().join("a.txt")),
|
||||
Some(Permission::Read)
|
||||
@@ -394,22 +394,35 @@ mod tests {
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).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();
|
||||
let err = Scope::from_config(&cfg).unwrap_err();
|
||||
assert!(matches!(err, ScopeError::EmptyAllow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_target_rejected_as_invariant_violation() {
|
||||
let cfg = ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: PathBuf::from("relative/path"),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let err = Scope::from_config(&cfg).unwrap_err();
|
||||
assert!(matches!(err, ScopeError::RelativeTarget(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_traversal_attack() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -430,7 +443,7 @@ mod tests {
|
||||
],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let summary = scope.summary();
|
||||
assert!(summary.contains("Readable:"));
|
||||
assert!(summary.contains("Writable:"));
|
||||
@@ -448,7 +461,7 @@ mod tests {
|
||||
allow: vec![allow_rule(dir.path(), Permission::Write)],
|
||||
deny: vec![allow_rule(&secret, Permission::Read)],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let summary = scope.summary();
|
||||
assert!(!summary.contains("secret"));
|
||||
}
|
||||
@@ -473,7 +486,7 @@ mod tests {
|
||||
],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let summary = scope.summary();
|
||||
let docs_canon = docs.canonicalize().unwrap().display().to_string();
|
||||
let dir_canon = dir.path().canonicalize().unwrap().display().to_string();
|
||||
@@ -500,7 +513,7 @@ mod tests {
|
||||
],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let readable: Vec<_> = scope.readable_paths().collect();
|
||||
let writable: Vec<_> = scope.writable_paths().collect();
|
||||
assert_eq!(readable.len(), 2);
|
||||
|
||||
Reference in New Issue
Block a user