use async_trait::async_trait;
use boundgraph_core::{CheckerId, Condensate, Digest, GrantAtom, GrantSet, WorkOrder};
use boundgraph_grant::{Tri, set_leq};
use boundgraph_slice::Store;
use crate::checker::{Checker, Judgement};
pub const MAX_NAMED: usize = 20;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ChangeKind {
Added,
Deleted,
Modified,
Renamed,
}
impl ChangeKind {
pub fn describe(self) -> &'static str {
match self {
Self::Added => "added",
Self::Deleted => "deleted",
Self::Modified => "modified",
Self::Renamed => "renamed",
}
}
fn action(self) -> &'static str {
match self {
Self::Added => "create",
Self::Deleted => "delete",
Self::Modified | Self::Renamed => "write",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Change {
pub path: String,
pub from: Option<String>,
pub kind: ChangeKind,
pub old_mode: Option<String>,
pub new_mode: Option<String>,
pub binary: bool,
}
impl Change {
pub fn paths(&self) -> Vec<&str> {
match &self.from {
Some(from) if from != &self.path => vec![from.as_str(), self.path.as_str()],
_ => vec![self.path.as_str()],
}
}
pub fn mode_changed(&self) -> bool {
match (&self.old_mode, &self.new_mode) {
(Some(old), Some(new)) => old != new,
_ => false,
}
}
pub fn describe(&self) -> String {
let mode = match (&self.old_mode, &self.new_mode) {
(Some(old), Some(new)) if old != new => format!(", mode {old} -> {new}"),
_ => String::new(),
};
match &self.from {
Some(from) if from != &self.path => {
format!("{from} -> {} ({}{mode})", self.path, self.kind.describe())
}
_ => format!("{} ({}{mode})", self.path, self.kind.describe()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum PatchError {
#[error("line {line_number}: unrecognised in a patch: {line:?}")]
Unrecognised {
line_number: usize,
line: String,
},
#[error("line {line_number}: malformed hunk header: {header:?}")]
MalformedHunk {
line_number: usize,
header: String,
},
#[error("the patch ends inside a hunk that began at line {line_number}")]
Truncated {
line_number: usize,
},
#[error("line {line_number}: cannot tell which paths this names: {line:?}")]
Ambiguous {
line_number: usize,
line: String,
},
}
const IGNORED_HEADERS: &[&str] = &[
"index ",
"similarity index ",
"dissimilarity index ",
"old file mode ",
"GIT binary patch",
"Binary files ",
"\\ ",
];
pub fn parse_patch(text: &str) -> Result<Vec<Change>, PatchError> {
let lines: Vec<&str> = text.lines().collect();
let mut changes: Vec<Change> = Vec::new();
let mut starts: Vec<usize> = Vec::new();
let mut seen_minus: Vec<bool> = Vec::new();
let mut index = 0usize;
while index < lines.len() {
let line = lines[index];
let number = index + 1;
if let Some(rest) = line.strip_prefix("diff --git ") {
changes.push(Change {
path: advisory_path(rest).unwrap_or_default(),
from: None,
kind: ChangeKind::Modified,
old_mode: None,
new_mode: None,
binary: false,
});
starts.push(number);
seen_minus.push(false);
index += 1;
continue;
}
if line.starts_with("--- ") && seen_minus.last().is_none_or(|seen| *seen) {
changes.push(Change {
path: String::new(),
from: None,
kind: ChangeKind::Modified,
old_mode: None,
new_mode: None,
binary: false,
});
starts.push(number);
seen_minus.push(false);
}
let Some(current) = changes.last_mut() else {
return Err(PatchError::Unrecognised {
line_number: number,
line: line.to_string(),
});
};
if let Some(rest) = line.strip_prefix("@@") {
let (old, new) = hunk_counts(rest).ok_or_else(|| PatchError::MalformedHunk {
line_number: number,
header: line.to_string(),
})?;
index = consume_hunk(&lines, index + 1, old, new, number)?;
continue;
}
if let Some(rest) = line.strip_prefix("--- ") {
if rest == "/dev/null" {
current.kind = ChangeKind::Added;
} else {
let path = strip_prefix_marker(rest, 'a').ok_or_else(|| PatchError::Ambiguous {
line_number: number,
line: line.to_string(),
})?;
if current.path.is_empty() {
current.path = path.clone();
}
current.from.get_or_insert(path);
}
if let Some(seen) = seen_minus.last_mut() {
*seen = true;
}
index += 1;
continue;
}
if let Some(rest) = line.strip_prefix("+++ ") {
if rest == "/dev/null" {
current.kind = ChangeKind::Deleted;
} else {
let path = strip_prefix_marker(rest, 'b').ok_or_else(|| PatchError::Ambiguous {
line_number: number,
line: line.to_string(),
})?;
current.path = path;
}
index += 1;
continue;
}
if let Some(mode) = line.strip_prefix("new file mode ") {
current.kind = ChangeKind::Added;
current.new_mode = Some(mode.trim().to_string());
index += 1;
continue;
}
if let Some(mode) = line.strip_prefix("deleted file mode ") {
current.kind = ChangeKind::Deleted;
current.old_mode = Some(mode.trim().to_string());
index += 1;
continue;
}
if let Some(mode) = line.strip_prefix("old mode ") {
current.old_mode = Some(mode.trim().to_string());
index += 1;
continue;
}
if let Some(mode) = line.strip_prefix("new mode ") {
current.new_mode = Some(mode.trim().to_string());
index += 1;
continue;
}
if let Some(from) = line.strip_prefix("rename from ") {
current.kind = ChangeKind::Renamed;
current.from = Some(from.trim().to_string());
index += 1;
continue;
}
if let Some(to) = line.strip_prefix("rename to ") {
current.kind = ChangeKind::Renamed;
current.path = to.trim().to_string();
index += 1;
continue;
}
if let Some(from) = line.strip_prefix("copy from ") {
current.from = Some(from.trim().to_string());
index += 1;
continue;
}
if let Some(to) = line.strip_prefix("copy to ") {
current.kind = ChangeKind::Added;
current.path = to.trim().to_string();
index += 1;
continue;
}
if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") {
current.binary = true;
}
if IGNORED_HEADERS.iter().any(|h| line.starts_with(h)) || line.is_empty() {
index += 1;
continue;
}
if current.binary {
index += 1;
continue;
}
return Err(PatchError::Unrecognised {
line_number: number,
line: line.to_string(),
});
}
for (position, change) in changes.iter_mut().enumerate() {
if change.path.is_empty()
&& let Some(from) = &change.from
{
change.path = from.clone();
}
if change.path.is_empty() {
let line_number = starts.get(position).copied().unwrap_or(1);
return Err(PatchError::Ambiguous {
line_number,
line: lines
.get(line_number.saturating_sub(1))
.unwrap_or(&"")
.to_string(),
});
}
}
Ok(changes)
}
fn advisory_path(rest: &str) -> Option<String> {
let parts: Vec<&str> = rest.split_whitespace().collect();
if parts.len() != 2 {
return None;
}
strip_prefix_marker(parts[1], 'b')
}
fn strip_prefix_marker(text: &str, marker: char) -> Option<String> {
let text = text.split('\t').next().unwrap_or(text).trim_end();
let text = text.trim_matches('"');
let prefix = format!("{marker}/");
match text.strip_prefix(&prefix) {
Some(path) => Some(path.to_string()),
None if !text.is_empty() && text != "/dev/null" => Some(text.to_string()),
None => None,
}
}
fn hunk_counts(rest: &str) -> Option<(usize, usize)> {
let inner = rest.split("@@").next()?.trim();
let mut ranges = inner.split_whitespace();
let old = ranges.next()?.strip_prefix('-')?;
let new = ranges.next()?.strip_prefix('+')?;
Some((span(old)?, span(new)?))
}
fn span(range: &str) -> Option<usize> {
match range.split_once(',') {
Some((start, length)) => {
start.parse::<usize>().ok()?;
length.parse().ok()
}
None => {
range.parse::<usize>().ok()?;
Some(1)
}
}
}
fn consume_hunk(
lines: &[&str],
mut index: usize,
mut old: usize,
mut new: usize,
started_at: usize,
) -> Result<usize, PatchError> {
while old > 0 || new > 0 {
let Some(line) = lines.get(index) else {
return Err(PatchError::Truncated {
line_number: started_at,
});
};
index += 1;
match line.chars().next() {
Some('+') => new = new.saturating_sub(1),
Some('-') => old = old.saturating_sub(1),
Some('\\') => {}
Some(' ') | None => {
old = old.saturating_sub(1);
new = new.saturating_sub(1);
}
Some(_) => {
return Err(PatchError::Unrecognised {
line_number: index,
line: (*line).to_string(),
});
}
}
}
Ok(index)
}
fn in_scope(scope: &GrantSet, path: &str, kind: ChangeKind) -> Tri {
let ask = |action: &str| set_leq(&GrantSet::new([GrantAtom::new("fs", action, path)]), scope);
ask("write").or(ask(kind.action()))
}
#[derive(Debug)]
pub struct DiffScope<S: Store> {
store: S,
scope: GrantSet,
root: Option<String>,
id: CheckerId,
}
impl<S: Store> DiffScope<S> {
pub fn new(store: S, scope: GrantSet) -> Self {
Self {
store,
scope,
root: None,
id: CheckerId::new("diff-scope", 1),
}
}
#[must_use]
pub fn rooted_at(mut self, root: impl Into<String>) -> Self {
let root = root.into().replace('\\', "/");
self.root = Some(root.trim_end_matches('/').to_string());
self
}
#[must_use]
pub fn named(mut self, name: impl Into<String>, version: u32) -> Self {
self.id = CheckerId::new(name, version);
self
}
fn resolve(&self, path: &str) -> String {
let path = path.replace('\\', "/");
let path = path.trim_start_matches('/');
match &self.root {
Some(root) => format!("{root}/{path}"),
None => format!("/{path}"),
}
}
}
fn listing(items: &[String]) -> String {
let shown: Vec<&str> = items.iter().take(MAX_NAMED).map(String::as_str).collect();
let rest = items.len().saturating_sub(shown.len());
if rest == 0 {
shown.join(", ")
} else {
format!("{}, and {rest} more", shown.join(", "))
}
}
#[async_trait]
impl<S: Store> Checker for DiffScope<S> {
fn id(&self) -> CheckerId {
self.id.clone()
}
async fn judge(&self, _order: &WorkOrder, condensate: &Condensate) -> Judgement {
let raw = match self.store.get(&condensate.raw).await {
Ok(bytes) => bytes,
Err(e) => {
return Judgement::inconclusive(format!(
"the original patch could not be read from the content store: {e}"
));
}
};
let text = match String::from_utf8(raw) {
Ok(text) => text,
Err(_) => {
return Judgement::inconclusive(
"the original patch is not valid UTF-8, so its paths cannot be read"
.to_string(),
);
}
};
let changes = match parse_patch(&text) {
Ok(changes) => changes,
Err(e) => {
return Judgement::inconclusive(format!(
"the patch could not be parsed, so nothing can be claimed about its scope: {e}"
));
}
};
let mut touched: Vec<String> = Vec::new();
let mut outside: Vec<String> = Vec::new();
let mut undecided: Vec<String> = Vec::new();
for change in &changes {
for path in change.paths() {
let resolved = self.resolve(path);
if !touched.contains(&resolved) {
touched.push(resolved.clone());
}
match in_scope(&self.scope, &resolved, change.kind) {
Tri::Yes => {}
Tri::No => outside.push(format!("{resolved} ({})", change.describe())),
Tri::Unknown => undecided.push(resolved),
}
}
}
touched.sort();
let mut judgement = Judgement::pass();
for path in &touched {
judgement = judgement.covering(Digest::of(path.as_bytes()));
}
judgement = judgement.covering(Digest::of(touched.join("\n").as_bytes()));
if !outside.is_empty() {
return Judgement {
verdict: boundgraph_core::Verdict::Fail(format!(
"{} path{} outside the step's grant: {}",
outside.len(),
if outside.len() == 1 { "" } else { "s" },
listing(&outside)
)),
also: judgement.also,
};
}
if !undecided.is_empty() {
return Judgement {
verdict: boundgraph_core::Verdict::Inconclusive(format!(
"cannot prove {} path{} within the step's grant: {}",
undecided.len(),
if undecided.len() == 1 { "" } else { "s" },
listing(&undecided)
)),
also: judgement.also,
};
}
judgement
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sign::LocalKey;
use boundgraph_core::{CondensatePayload, Intent, ProposalOrigin, SchemaId, SlicerId, Verdict};
use boundgraph_slice::MemoryStore;
fn order() -> WorkOrder {
WorkOrder::new(
Intent::new("patch.apply", &"fix the header parser").unwrap(),
vec![],
GrantSet::parse(["fs:write:/**"]).unwrap(),
ProposalOrigin::new("claude-opus-5", [], Digest::of(b"prompt")),
)
}
fn condensate_for(raw: &[u8]) -> Condensate {
Condensate::new(
SchemaId::new("boundgraph.diff.v1", 1),
CondensatePayload::inline(&"1 file changed in src/").unwrap(),
Digest::of(raw),
400,
true,
SlicerId::new("diff", 1),
)
}
fn key() -> LocalKey {
LocalKey::from_seed("host-key-1", [7u8; 32])
}
fn scope(atoms: &[&str]) -> GrantSet {
GrantSet::parse(atoms.iter().copied()).expect("test scope should parse")
}
async fn verdict_for(patch: &str, granted: &[&str]) -> Verdict {
let store = MemoryStore::new();
store.put(patch.as_bytes()).await.unwrap();
DiffScope::new(store, scope(granted))
.check(&order(), &condensate_for(patch.as_bytes()), &key())
.await
.unwrap()
.verdict
}
const IN_SCOPE: &str = "\
diff --git a/src/parse.rs b/src/parse.rs
--- a/src/parse.rs
+++ b/src/parse.rs
@@ -1,3 +1,3 @@
fn parse() {
- old();
+ new();
}
";
const REACHES_OUT: &str = "\
diff --git a/src/parse.rs b/src/parse.rs
--- a/src/parse.rs
+++ b/src/parse.rs
@@ -1,1 +1,1 @@
-old();
+new();
diff --git a/deploy/production.yaml b/deploy/production.yaml
--- a/deploy/production.yaml
+++ b/deploy/production.yaml
@@ -1,1 +1,1 @@
-replicas: 1
+replicas: 99
";
#[tokio::test]
async fn a_patch_inside_the_grant_passes() {
assert_eq!(
verdict_for(IN_SCOPE, &["fs:write:/src/**"]).await,
Verdict::Pass
);
}
#[tokio::test]
async fn a_path_outside_the_grant_fails_and_names_it() {
let v = verdict_for(REACHES_OUT, &["fs:write:/src/**"]).await;
let Verdict::Fail(reason) = v else {
panic!("expected Fail, got {v:?}");
};
assert!(reason.contains("deploy/production.yaml"), "{reason}");
assert!(
!reason.contains("src/parse.rs"),
"only the offender: {reason}"
);
assert!(reason.contains("1 path outside"), "{reason}");
}
#[tokio::test]
async fn a_deletion_outside_the_grant_is_caught() {
let patch = "\
diff --git a/deploy/secrets.env b/deploy/secrets.env
deleted file mode 100644
--- a/deploy/secrets.env
+++ /dev/null
@@ -1,1 +0,0 @@
-TOKEN=abc
";
let v = verdict_for(patch, &["fs:write:/src/**"]).await;
let Verdict::Fail(reason) = v else {
panic!("expected Fail, got {v:?}");
};
assert!(reason.contains("deploy/secrets.env"), "{reason}");
assert!(reason.contains("deleted"), "{reason}");
}
#[tokio::test]
async fn both_halves_of_a_rename_must_be_in_scope() {
let patch = "\
diff --git a/src/parse.rs b/deploy/parse.rs
similarity index 100%
rename from src/parse.rs
rename to deploy/parse.rs
";
let v = verdict_for(patch, &["fs:write:/src/**"]).await;
let Verdict::Fail(reason) = v else {
panic!("moving a file out of scope must fail, got {v:?}");
};
assert!(reason.contains("deploy/parse.rs"), "{reason}");
let inbound = "\
diff --git a/deploy/parse.rs b/src/parse.rs
similarity index 100%
rename from deploy/parse.rs
rename to src/parse.rs
";
let v = verdict_for(inbound, &["fs:write:/src/**"]).await;
assert!(matches!(v, Verdict::Fail(_)), "{v:?}");
}
#[tokio::test]
async fn a_mode_change_alone_is_a_change() {
let patch = "\
diff --git a/deploy/run.sh b/deploy/run.sh
old mode 100644
new mode 100755
";
let v = verdict_for(patch, &["fs:write:/src/**"]).await;
let Verdict::Fail(reason) = v else {
panic!("chmod +x on an out-of-scope file must fail, got {v:?}");
};
assert!(reason.contains("deploy/run.sh"), "{reason}");
assert!(reason.contains("100644 -> 100755"), "{reason}");
}
#[tokio::test]
async fn a_mode_change_inside_the_grant_still_passes() {
let patch = "\
diff --git a/src/run.sh b/src/run.sh
old mode 100644
new mode 100755
";
assert_eq!(
verdict_for(patch, &["fs:write:/src/**"]).await,
Verdict::Pass
);
}
#[tokio::test]
async fn an_addition_is_authorised_by_write_or_by_create() {
let patch = "\
diff --git a/src/new.rs b/src/new.rs
new file mode 100644
--- /dev/null
+++ b/src/new.rs
@@ -0,0 +1,1 @@
+fn main() {}
";
assert_eq!(
verdict_for(patch, &["fs:write:/src/**"]).await,
Verdict::Pass
);
assert_eq!(
verdict_for(patch, &["fs:create:/src/**"]).await,
Verdict::Pass
);
}
#[tokio::test]
async fn a_narrow_delete_grant_authorises_a_deletion() {
let patch = "\
diff --git a/tmp/scratch b/tmp/scratch
deleted file mode 100644
--- a/tmp/scratch
+++ /dev/null
@@ -1,1 +0,0 @@
-junk
";
assert_eq!(
verdict_for(patch, &["fs:delete:/tmp/**"]).await,
Verdict::Pass
);
}
#[tokio::test]
async fn an_empty_patch_touches_nothing_and_passes() {
assert_eq!(verdict_for("", &["fs:write:/src/**"]).await, Verdict::Pass);
}
#[tokio::test]
async fn an_undecidable_comparison_is_inconclusive_rather_than_a_pass() {
let v = verdict_for(IN_SCOPE, &["fs:write:/src/*.rs"]).await;
assert!(matches!(v, Verdict::Inconclusive(_)), "{v:?}");
assert!(!v.is_pass());
assert!(v.reason().unwrap().contains("src/parse.rs"));
}
#[tokio::test]
async fn a_pass_records_exactly_which_paths_changed() {
let patch = "\
diff --git a/src/a.rs b/src/a.rs
--- a/src/a.rs
+++ b/src/a.rs
@@ -1,1 +1,1 @@
-a
+A
diff --git a/src/b.rs b/src/b.rs
--- a/src/b.rs
+++ b/src/b.rs
@@ -1,1 +1,1 @@
-b
+B
";
let store = MemoryStore::new();
store.put(patch.as_bytes()).await.unwrap();
let evidence = DiffScope::new(store, scope(&["fs:write:/src/**"]))
.check(&order(), &condensate_for(patch.as_bytes()), &key())
.await
.unwrap();
assert!(evidence.permits_commit());
assert!(evidence.covers(&Digest::of(b"/src/a.rs")));
assert!(evidence.covers(&Digest::of(b"/src/b.rs")));
assert!(
!evidence.covers(&Digest::of(b"/src/c.rs")),
"a file it never touched must not be vouched for"
);
assert!(
evidence.covers(&Digest::of(b"/src/a.rs\n/src/b.rs")),
"and the set as a whole, so nothing can be added or removed"
);
}
#[tokio::test]
async fn the_check_reads_the_raw_patch_and_not_the_condensate_payload() {
let store = MemoryStore::new();
store.put(REACHES_OUT.as_bytes()).await.unwrap();
let condensate = condensate_for(REACHES_OUT.as_bytes());
assert!(condensate.lossy, "the summary is the model's-eye view");
let evidence = DiffScope::new(store, scope(&["fs:write:/src/**"]))
.check(&order(), &condensate, &key())
.await
.unwrap();
assert!(
!evidence.permits_commit(),
"the summary said in scope; the patch was not"
);
}
#[tokio::test]
async fn a_raw_patch_that_is_not_in_the_store_is_inconclusive() {
let evidence = DiffScope::new(MemoryStore::new(), scope(&["fs:write:/src/**"]))
.check(&order(), &condensate_for(b"never stored"), &key())
.await
.unwrap();
assert!(matches!(evidence.verdict, Verdict::Inconclusive(_)));
}
#[tokio::test]
async fn the_work_orders_own_request_is_ignored() {
let v = verdict_for(REACHES_OUT, &["fs:write:/src/**"]).await;
assert!(matches!(v, Verdict::Fail(_)), "{v:?}");
}
#[tokio::test]
async fn the_checker_needs_no_authority_of_its_own() {
let checker = DiffScope::new(MemoryStore::new(), scope(&["fs:write:/src/**"]));
assert_eq!(checker.grants(), GrantSet::EMPTY);
}
#[tokio::test]
async fn paths_can_be_resolved_against_a_workspace_root() {
let store = MemoryStore::new();
store.put(IN_SCOPE.as_bytes()).await.unwrap();
let evidence = DiffScope::new(store, scope(&["fs:write:/work/repo/src/**"]))
.rooted_at("/work/repo/")
.check(&order(), &condensate_for(IN_SCOPE.as_bytes()), &key())
.await
.unwrap();
assert!(evidence.permits_commit());
assert!(evidence.covers(&Digest::of(b"/work/repo/src/parse.rs")));
}
#[tokio::test]
async fn the_same_patch_always_gives_the_same_evidence() {
let store = MemoryStore::new();
store.put(IN_SCOPE.as_bytes()).await.unwrap();
let checker = DiffScope::new(store, scope(&["fs:write:/src/**"]));
let (order, k, key) = (order(), condensate_for(IN_SCOPE.as_bytes()), key());
let first = checker.check(&order, &k, &key).await.unwrap();
for _ in 0..4 {
assert_eq!(checker.check(&order, &k, &key).await.unwrap(), first);
}
}
#[test]
fn a_plain_unified_diff_with_no_git_preamble_parses() {
let patch = "\
--- src/old.txt
+++ src/new.txt
@@ -1,1 +1,1 @@
-a
+b
";
let changes = parse_patch(patch).unwrap();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].path, "src/new.txt");
}
#[test]
fn a_hunk_is_consumed_by_its_own_line_counts() {
let patch = "\
diff --git a/src/a.rs b/src/a.rs
--- a/src/a.rs
+++ b/src/a.rs
@@ -1,3 +1,3 @@
context
-diff --git a/x b/x
+diff --git a/x b/x
@@ -9,9 +9,9 @@
diff --git a/evil.sh b/evil.sh
--- a/evil.sh
+++ b/evil.sh
@@ -1,1 +1,1 @@
-x
+y
";
let changes = parse_patch(patch).unwrap();
let paths: Vec<&str> = changes.iter().map(|c| c.path.as_str()).collect();
assert_eq!(
paths,
vec!["src/a.rs", "evil.sh"],
"the second file must survive"
);
}
#[test]
fn an_unrecognised_line_is_refused_rather_than_skipped() {
let patch = "\
diff --git a/src/a.rs b/src/a.rs
what on earth is this line
--- a/src/a.rs
+++ b/src/a.rs
";
let err = parse_patch(patch).unwrap_err();
let PatchError::Unrecognised { line, .. } = err else {
panic!("expected Unrecognised, got {err:?}");
};
assert!(line.contains("what on earth"));
}
#[test]
fn every_file_of_a_multi_file_plain_unified_diff_is_found() {
let patch = "\
--- src/a.rs
+++ src/a.rs
@@ -1,1 +1,1 @@
-a
+A
--- deploy/production.yaml
+++ deploy/production.yaml
@@ -1,1 +1,1 @@
-replicas: 1
+replicas: 99
";
let paths: Vec<String> = parse_patch(patch)
.unwrap()
.into_iter()
.map(|c| c.path)
.collect();
assert_eq!(paths, vec!["src/a.rs", "deploy/production.yaml"]);
}
#[tokio::test]
async fn a_second_file_of_a_plain_unified_diff_is_still_scope_checked() {
let patch = "\
--- src/a.rs
+++ src/a.rs
@@ -1,1 +1,1 @@
-a
+A
--- deploy/production.yaml
+++ deploy/production.yaml
@@ -1,1 +1,1 @@
-replicas: 1
+replicas: 99
";
let v = verdict_for(patch, &["fs:write:/src/**"]).await;
let Verdict::Fail(reason) = v else {
panic!("expected Fail, got {v:?}");
};
assert!(reason.contains("deploy/production.yaml"), "{reason}");
}
#[test]
fn a_file_whose_path_cannot_be_read_is_refused_rather_than_dropped() {
let patch = "\
diff --git a/deploy/my script.sh b/deploy/my script.sh
old mode 100644
new mode 100755
";
assert!(
matches!(parse_patch(patch), Err(PatchError::Ambiguous { .. })),
"got {:?}",
parse_patch(patch)
);
}
#[tokio::test]
async fn an_unreadable_header_leaves_the_verdict_undecided_not_passing() {
let patch = "\
diff --git a/deploy/my script.sh b/deploy/my script.sh
old mode 100644
new mode 100755
";
let v = verdict_for(patch, &["fs:write:/src/**"]).await;
assert!(matches!(v, Verdict::Inconclusive(_)), "{v:?}");
assert!(!v.is_pass(), "an unreadable patch must never pass");
}
#[test]
fn a_malformed_hunk_header_is_refused() {
let patch = "\
diff --git a/src/a.rs b/src/a.rs
@@ not a range @@
";
assert!(matches!(
parse_patch(patch),
Err(PatchError::MalformedHunk { .. })
));
}
#[test]
fn a_patch_that_ends_mid_hunk_is_refused() {
let patch = "\
diff --git a/src/a.rs b/src/a.rs
@@ -1,9 +1,9 @@
one line only
";
assert!(matches!(
parse_patch(patch),
Err(PatchError::Truncated { .. })
));
}
#[test]
fn content_before_the_first_file_header_is_refused() {
assert!(matches!(
parse_patch("Subject: a commit message\n\nbody\n"),
Err(PatchError::Unrecognised { .. })
));
}
#[test]
fn a_no_newline_marker_counts_against_neither_side() {
let patch = "\
diff --git a/a b/a
--- a/a
+++ b/a
@@ -1,1 +1,1 @@
-old
\\ No newline at end of file
+new
\\ No newline at end of file
";
assert_eq!(parse_patch(patch).unwrap().len(), 1);
}
#[test]
fn a_hunk_header_without_a_length_means_one_line() {
let patch = "\
diff --git a/a b/a
--- a/a
+++ b/a
@@ -1 +1 @@
-old
+new
";
assert_eq!(parse_patch(patch).unwrap().len(), 1);
}
#[test]
fn a_binary_change_is_recorded_without_parsing_its_body() {
let patch = "\
diff --git a/logo.png b/logo.png
index 1111111..2222222 100644
GIT binary patch
literal 120
zcmeAS@N?(olHy`uVBq!ia0vp^
";
let changes = parse_patch(patch).unwrap();
assert_eq!(changes.len(), 1);
assert!(changes[0].binary);
assert_eq!(changes[0].path, "logo.png");
}
#[test]
fn a_timestamp_after_the_path_is_not_part_of_it() {
let patch = "\
--- src/a.rs\t2026-01-01 10:00:00
+++ src/a.rs\t2026-01-02 10:00:00
@@ -1,1 +1,1 @@
-a
+b
";
assert_eq!(parse_patch(patch).unwrap()[0].path, "src/a.rs");
}
#[test]
fn a_quoted_path_with_a_space_is_read_from_the_marker_lines() {
let patch = "\
diff --git \"a/src/my file.rs\" \"b/src/my file.rs\"
--- \"a/src/my file.rs\"
+++ \"b/src/my file.rs\"
@@ -1,1 +1,1 @@
-a
+b
";
assert_eq!(parse_patch(patch).unwrap()[0].path, "src/my file.rs");
}
#[test]
fn a_rename_names_both_paths_and_a_plain_edit_names_one() {
let renamed = Change {
path: "b.rs".into(),
from: Some("a.rs".into()),
kind: ChangeKind::Renamed,
old_mode: None,
new_mode: None,
binary: false,
};
assert_eq!(renamed.paths(), vec!["a.rs", "b.rs"]);
let edited = Change {
from: Some("a.rs".into()),
path: "a.rs".into(),
..renamed.clone()
};
assert_eq!(edited.paths(), vec!["a.rs"]);
}
#[test]
fn a_mode_change_is_only_a_change_when_the_modes_differ() {
let same = Change {
path: "a.sh".into(),
from: None,
kind: ChangeKind::Modified,
old_mode: Some("100644".into()),
new_mode: Some("100644".into()),
binary: false,
};
assert!(!same.mode_changed());
let changed = Change {
new_mode: Some("100755".into()),
..same
};
assert!(changed.mode_changed());
assert!(changed.describe().contains("100644 -> 100755"));
}
#[test]
fn a_long_list_of_offenders_is_counted_rather_than_listed() {
let paths: Vec<String> = (0..40).map(|n| format!("f{n}")).collect();
let rendered = listing(&paths);
assert!(rendered.contains("f0"));
assert!(rendered.contains("and 20 more"), "{rendered}");
}
}