use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalRule {
pub tool: String,
pub fields: Vec<FieldMatch>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FieldMatch {
pub pointer: String,
#[serde(flatten)]
pub matcher: Matcher,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Matcher {
Exact(Value),
PathPrefix(String),
ArgvPrefix(Vec<String>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StandingApproval {
pub authority: super::AuthorityId,
pub issued_by: crate::core::Operator,
pub basis: String,
}
impl ApprovalRule {
#[must_use]
pub fn new(tool: impl Into<String>, pointer: impl Into<String>, matcher: Matcher) -> Self {
Self {
tool: tool.into(),
fields: vec![FieldMatch {
pointer: pointer.into(),
matcher,
}],
}
}
#[must_use]
pub fn and(mut self, pointer: impl Into<String>, matcher: Matcher) -> Self {
self.fields.push(FieldMatch {
pointer: pointer.into(),
matcher,
});
self
}
#[must_use]
pub fn matches(&self, tool: &str, arguments: &Value) -> bool {
self.tool == tool
&& !self.fields.is_empty()
&& self.fields.iter().all(|field| {
arguments
.pointer(&field.pointer)
.is_some_and(|value| field.matcher.matches(value))
})
}
pub(crate) fn problem(&self) -> Option<&'static str> {
if self.tool.trim().is_empty() {
return Some("an approval rule names no tool");
}
if self.fields.is_empty() {
return Some(
"an approval rule constrains no argument — approving every call to a tool \
is removing `requires_approval`, which the reviewed declaration says",
);
}
for field in &self.fields {
if !field.pointer.starts_with('/') {
return Some("an approval rule's pointer is not a JSON pointer");
}
match &field.matcher {
Matcher::Exact(_) => {}
Matcher::PathPrefix(prefix) => match normalise(prefix) {
Some(segments) if !segments.is_empty() => {}
_ => {
return Some(
"an approval rule's path prefix is empty or leaves its own root",
);
}
},
Matcher::ArgvPrefix(argv) => {
if argv.is_empty() {
return Some("an approval rule's argv prefix is empty");
}
}
}
}
None
}
}
impl Matcher {
fn matches(&self, value: &Value) -> bool {
match self {
Self::Exact(expected) => value == expected,
Self::PathPrefix(prefix) => {
let (Some(prefix), Some(path)) =
(normalise(prefix), value.as_str().and_then(normalise))
else {
return false;
};
prefix.absolute == path.absolute && path.segments.starts_with(&prefix.segments)
}
Self::ArgvPrefix(prefix) => value.as_array().is_some_and(|argv| {
argv.len() >= prefix.len()
&& prefix
.iter()
.zip(argv)
.all(|(want, got)| got.as_str() == Some(want.as_str()))
}),
}
}
}
struct Normal<'a> {
absolute: bool,
segments: Vec<&'a str>,
}
impl Normal<'_> {
const fn is_empty(&self) -> bool {
self.segments.is_empty()
}
}
fn normalise(path: &str) -> Option<Normal<'_>> {
if path.contains('\0') {
return None;
}
let mut segments = Vec::new();
for segment in path.split('/') {
match segment {
"" | "." => {}
".." => {
segments.pop()?;
}
other => segments.push(other),
}
}
Some(Normal {
absolute: path.starts_with('/'),
segments,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn under(prefix: &str) -> ApprovalRule {
ApprovalRule::new(
"tool://fs/write",
"/path",
Matcher::PathPrefix(prefix.into()),
)
}
#[test]
fn a_path_matches_by_segment_and_never_through_an_escape() {
let rule = under("src");
for inside in [
"src/lib.rs",
"src",
"./src/a/b.rs",
"src//x",
"src/a/../b.rs",
] {
assert!(
rule.matches("tool://fs/write", &json!({ "path": inside })),
"{inside}"
);
}
for outside in [
"srcs/lib.rs",
"src/../etc/passwd",
"../src/lib.rs",
"/src/lib.rs",
"lib.rs",
"src/../../x",
"src\0/x",
] {
assert!(
!rule.matches("tool://fs/write", &json!({ "path": outside })),
"{outside}"
);
}
assert!(!rule.matches("tool://fs/delete", &json!({ "path": "src/lib.rs" })));
assert!(!rule.matches("tool://fs/write", &json!({ "path": 7 })));
assert!(!rule.matches("tool://fs/write", &json!({})));
}
#[test]
fn an_argv_prefix_matches_whole_elements() {
let rule = ApprovalRule::new(
"tool://shell/run",
"/argv",
Matcher::ArgvPrefix(vec!["cargo".into(), "test".into()]),
);
assert!(rule.matches(
"tool://shell/run",
&json!({ "argv": ["cargo", "test", "--lib"] })
));
assert!(rule.matches("tool://shell/run", &json!({ "argv": ["cargo", "test"] })));
assert!(!rule.matches("tool://shell/run", &json!({ "argv": ["cargo", "testify"] })));
assert!(!rule.matches("tool://shell/run", &json!({ "argv": ["cargo"] })));
assert!(!rule.matches("tool://shell/run", &json!({ "argv": "cargo test" })));
}
#[test]
fn every_constraint_must_hold() {
let rule = under("src").and("/mode", Matcher::Exact(json!("overwrite")));
assert!(rule.matches(
"tool://fs/write",
&json!({ "path": "src/a", "mode": "overwrite" })
));
assert!(!rule.matches(
"tool://fs/write",
&json!({ "path": "src/a", "mode": "append" })
));
}
#[test]
fn a_rule_that_could_approve_anything_is_refused() {
let no_fields = ApprovalRule {
tool: "tool://fs/write".into(),
fields: Vec::new(),
};
assert!(no_fields.problem().is_some());
assert!(under("..").problem().is_some());
assert!(under("").problem().is_some());
assert!(under("src").problem().is_none());
}
}