1use async_trait::async_trait;
52use boundgraph_core::{CheckerId, Condensate, Digest, GrantAtom, GrantSet, WorkOrder};
53use boundgraph_grant::{Tri, set_leq};
54use boundgraph_slice::Store;
55
56use crate::checker::{Checker, Judgement};
57
58pub const MAX_NAMED: usize = 20;
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
63pub enum ChangeKind {
64 Added,
66 Deleted,
68 Modified,
70 Renamed,
72}
73
74impl ChangeKind {
75 pub fn describe(self) -> &'static str {
77 match self {
78 Self::Added => "added",
79 Self::Deleted => "deleted",
80 Self::Modified => "modified",
81 Self::Renamed => "renamed",
82 }
83 }
84
85 fn action(self) -> &'static str {
90 match self {
91 Self::Added => "create",
92 Self::Deleted => "delete",
93 Self::Modified | Self::Renamed => "write",
94 }
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Change {
101 pub path: String,
103 pub from: Option<String>,
105 pub kind: ChangeKind,
107 pub old_mode: Option<String>,
109 pub new_mode: Option<String>,
111 pub binary: bool,
113}
114
115impl Change {
116 pub fn paths(&self) -> Vec<&str> {
121 match &self.from {
122 Some(from) if from != &self.path => vec![from.as_str(), self.path.as_str()],
123 _ => vec![self.path.as_str()],
124 }
125 }
126
127 pub fn mode_changed(&self) -> bool {
129 match (&self.old_mode, &self.new_mode) {
130 (Some(old), Some(new)) => old != new,
131 _ => false,
132 }
133 }
134
135 pub fn describe(&self) -> String {
137 let mode = match (&self.old_mode, &self.new_mode) {
138 (Some(old), Some(new)) if old != new => format!(", mode {old} -> {new}"),
139 _ => String::new(),
140 };
141 match &self.from {
142 Some(from) if from != &self.path => {
143 format!("{from} -> {} ({}{mode})", self.path, self.kind.describe())
144 }
145 _ => format!("{} ({}{mode})", self.path, self.kind.describe()),
146 }
147 }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
152#[non_exhaustive]
153pub enum PatchError {
154 #[error("line {line_number}: unrecognised in a patch: {line:?}")]
159 Unrecognised {
160 line_number: usize,
162 line: String,
164 },
165
166 #[error("line {line_number}: malformed hunk header: {header:?}")]
168 MalformedHunk {
169 line_number: usize,
171 header: String,
173 },
174
175 #[error("the patch ends inside a hunk that began at line {line_number}")]
177 Truncated {
178 line_number: usize,
180 },
181
182 #[error("line {line_number}: cannot tell which paths this names: {line:?}")]
184 Ambiguous {
185 line_number: usize,
187 line: String,
189 },
190}
191
192const IGNORED_HEADERS: &[&str] = &[
194 "index ",
195 "similarity index ",
196 "dissimilarity index ",
197 "old file mode ",
198 "GIT binary patch",
199 "Binary files ",
200 "\\ ",
204];
205
206pub fn parse_patch(text: &str) -> Result<Vec<Change>, PatchError> {
217 let lines: Vec<&str> = text.lines().collect();
218 let mut changes: Vec<Change> = Vec::new();
219 let mut starts: Vec<usize> = Vec::new();
224 let mut seen_minus: Vec<bool> = Vec::new();
225 let mut index = 0usize;
226
227 while index < lines.len() {
228 let line = lines[index];
229 let number = index + 1;
230
231 if let Some(rest) = line.strip_prefix("diff --git ") {
235 changes.push(Change {
236 path: advisory_path(rest).unwrap_or_default(),
237 from: None,
238 kind: ChangeKind::Modified,
239 old_mode: None,
240 new_mode: None,
241 binary: false,
242 });
243 starts.push(number);
244 seen_minus.push(false);
245 index += 1;
246 continue;
247 }
248
249 if line.starts_with("--- ") && seen_minus.last().is_none_or(|seen| *seen) {
254 changes.push(Change {
255 path: String::new(),
256 from: None,
257 kind: ChangeKind::Modified,
258 old_mode: None,
259 new_mode: None,
260 binary: false,
261 });
262 starts.push(number);
263 seen_minus.push(false);
264 }
265
266 let Some(current) = changes.last_mut() else {
267 return Err(PatchError::Unrecognised {
271 line_number: number,
272 line: line.to_string(),
273 });
274 };
275
276 if let Some(rest) = line.strip_prefix("@@") {
277 let (old, new) = hunk_counts(rest).ok_or_else(|| PatchError::MalformedHunk {
278 line_number: number,
279 header: line.to_string(),
280 })?;
281 index = consume_hunk(&lines, index + 1, old, new, number)?;
282 continue;
283 }
284
285 if let Some(rest) = line.strip_prefix("--- ") {
286 if rest == "/dev/null" {
287 current.kind = ChangeKind::Added;
288 } else {
289 let path = strip_prefix_marker(rest, 'a').ok_or_else(|| PatchError::Ambiguous {
290 line_number: number,
291 line: line.to_string(),
292 })?;
293 if current.path.is_empty() {
294 current.path = path.clone();
295 }
296 current.from.get_or_insert(path);
297 }
298 if let Some(seen) = seen_minus.last_mut() {
299 *seen = true;
300 }
301 index += 1;
302 continue;
303 }
304
305 if let Some(rest) = line.strip_prefix("+++ ") {
306 if rest == "/dev/null" {
307 current.kind = ChangeKind::Deleted;
308 } else {
309 let path = strip_prefix_marker(rest, 'b').ok_or_else(|| PatchError::Ambiguous {
310 line_number: number,
311 line: line.to_string(),
312 })?;
313 current.path = path;
314 }
315 index += 1;
316 continue;
317 }
318
319 if let Some(mode) = line.strip_prefix("new file mode ") {
320 current.kind = ChangeKind::Added;
321 current.new_mode = Some(mode.trim().to_string());
322 index += 1;
323 continue;
324 }
325 if let Some(mode) = line.strip_prefix("deleted file mode ") {
326 current.kind = ChangeKind::Deleted;
327 current.old_mode = Some(mode.trim().to_string());
328 index += 1;
329 continue;
330 }
331 if let Some(mode) = line.strip_prefix("old mode ") {
332 current.old_mode = Some(mode.trim().to_string());
333 index += 1;
334 continue;
335 }
336 if let Some(mode) = line.strip_prefix("new mode ") {
337 current.new_mode = Some(mode.trim().to_string());
338 index += 1;
339 continue;
340 }
341 if let Some(from) = line.strip_prefix("rename from ") {
342 current.kind = ChangeKind::Renamed;
343 current.from = Some(from.trim().to_string());
344 index += 1;
345 continue;
346 }
347 if let Some(to) = line.strip_prefix("rename to ") {
348 current.kind = ChangeKind::Renamed;
349 current.path = to.trim().to_string();
350 index += 1;
351 continue;
352 }
353 if let Some(from) = line.strip_prefix("copy from ") {
354 current.from = Some(from.trim().to_string());
358 index += 1;
359 continue;
360 }
361 if let Some(to) = line.strip_prefix("copy to ") {
362 current.kind = ChangeKind::Added;
363 current.path = to.trim().to_string();
364 index += 1;
365 continue;
366 }
367
368 if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") {
369 current.binary = true;
370 }
371
372 if IGNORED_HEADERS.iter().any(|h| line.starts_with(h)) || line.is_empty() {
373 index += 1;
374 continue;
375 }
376
377 if current.binary {
381 index += 1;
382 continue;
383 }
384
385 return Err(PatchError::Unrecognised {
386 line_number: number,
387 line: line.to_string(),
388 });
389 }
390
391 for (position, change) in changes.iter_mut().enumerate() {
392 if change.path.is_empty()
393 && let Some(from) = &change.from
394 {
395 change.path = from.clone();
396 }
397
398 if change.path.is_empty() {
405 let line_number = starts.get(position).copied().unwrap_or(1);
406 return Err(PatchError::Ambiguous {
407 line_number,
408 line: lines
409 .get(line_number.saturating_sub(1))
410 .unwrap_or(&"")
411 .to_string(),
412 });
413 }
414 }
415 Ok(changes)
416}
417
418fn advisory_path(rest: &str) -> Option<String> {
420 let parts: Vec<&str> = rest.split_whitespace().collect();
421 if parts.len() != 2 {
422 return None;
424 }
425 strip_prefix_marker(parts[1], 'b')
426}
427
428fn strip_prefix_marker(text: &str, marker: char) -> Option<String> {
430 let text = text.split('\t').next().unwrap_or(text).trim_end();
432 let text = text.trim_matches('"');
433
434 let prefix = format!("{marker}/");
435 match text.strip_prefix(&prefix) {
436 Some(path) => Some(path.to_string()),
437 None if !text.is_empty() && text != "/dev/null" => Some(text.to_string()),
439 None => None,
440 }
441}
442
443fn hunk_counts(rest: &str) -> Option<(usize, usize)> {
445 let inner = rest.split("@@").next()?.trim();
446 let mut ranges = inner.split_whitespace();
447
448 let old = ranges.next()?.strip_prefix('-')?;
449 let new = ranges.next()?.strip_prefix('+')?;
450
451 Some((span(old)?, span(new)?))
452}
453
454fn span(range: &str) -> Option<usize> {
456 match range.split_once(',') {
457 Some((start, length)) => {
458 start.parse::<usize>().ok()?;
459 length.parse().ok()
460 }
461 None => {
462 range.parse::<usize>().ok()?;
463 Some(1)
464 }
465 }
466}
467
468fn consume_hunk(
474 lines: &[&str],
475 mut index: usize,
476 mut old: usize,
477 mut new: usize,
478 started_at: usize,
479) -> Result<usize, PatchError> {
480 while old > 0 || new > 0 {
481 let Some(line) = lines.get(index) else {
482 return Err(PatchError::Truncated {
483 line_number: started_at,
484 });
485 };
486 index += 1;
487
488 match line.chars().next() {
489 Some('+') => new = new.saturating_sub(1),
490 Some('-') => old = old.saturating_sub(1),
491 Some('\\') => {}
494 Some(' ') | None => {
498 old = old.saturating_sub(1);
499 new = new.saturating_sub(1);
500 }
501 Some(_) => {
502 return Err(PatchError::Unrecognised {
503 line_number: index,
504 line: (*line).to_string(),
505 });
506 }
507 }
508 }
509 Ok(index)
510}
511
512fn in_scope(scope: &GrantSet, path: &str, kind: ChangeKind) -> Tri {
521 let ask = |action: &str| set_leq(&GrantSet::new([GrantAtom::new("fs", action, path)]), scope);
522 ask("write").or(ask(kind.action()))
523}
524
525#[derive(Debug)]
534pub struct DiffScope<S: Store> {
535 store: S,
536 scope: GrantSet,
537 root: Option<String>,
538 id: CheckerId,
539}
540
541impl<S: Store> DiffScope<S> {
542 pub fn new(store: S, scope: GrantSet) -> Self {
544 Self {
545 store,
546 scope,
547 root: None,
548 id: CheckerId::new("diff-scope", 1),
549 }
550 }
551
552 #[must_use]
562 pub fn rooted_at(mut self, root: impl Into<String>) -> Self {
563 let root = root.into().replace('\\', "/");
564 self.root = Some(root.trim_end_matches('/').to_string());
565 self
566 }
567
568 #[must_use]
570 pub fn named(mut self, name: impl Into<String>, version: u32) -> Self {
571 self.id = CheckerId::new(name, version);
572 self
573 }
574
575 fn resolve(&self, path: &str) -> String {
582 let path = path.replace('\\', "/");
583 let path = path.trim_start_matches('/');
584 match &self.root {
585 Some(root) => format!("{root}/{path}"),
586 None => format!("/{path}"),
587 }
588 }
589}
590
591fn listing(items: &[String]) -> String {
593 let shown: Vec<&str> = items.iter().take(MAX_NAMED).map(String::as_str).collect();
594 let rest = items.len().saturating_sub(shown.len());
595 if rest == 0 {
596 shown.join(", ")
597 } else {
598 format!("{}, and {rest} more", shown.join(", "))
599 }
600}
601
602#[async_trait]
603impl<S: Store> Checker for DiffScope<S> {
604 fn id(&self) -> CheckerId {
605 self.id.clone()
606 }
607
608 async fn judge(&self, _order: &WorkOrder, condensate: &Condensate) -> Judgement {
613 let raw = match self.store.get(&condensate.raw).await {
616 Ok(bytes) => bytes,
617 Err(e) => {
618 return Judgement::inconclusive(format!(
619 "the original patch could not be read from the content store: {e}"
620 ));
621 }
622 };
623
624 let text = match String::from_utf8(raw) {
625 Ok(text) => text,
626 Err(_) => {
627 return Judgement::inconclusive(
628 "the original patch is not valid UTF-8, so its paths cannot be read"
629 .to_string(),
630 );
631 }
632 };
633
634 let changes = match parse_patch(&text) {
635 Ok(changes) => changes,
636 Err(e) => {
637 return Judgement::inconclusive(format!(
638 "the patch could not be parsed, so nothing can be claimed about its scope: {e}"
639 ));
640 }
641 };
642
643 let mut touched: Vec<String> = Vec::new();
644 let mut outside: Vec<String> = Vec::new();
645 let mut undecided: Vec<String> = Vec::new();
646
647 for change in &changes {
648 for path in change.paths() {
649 let resolved = self.resolve(path);
650 if !touched.contains(&resolved) {
651 touched.push(resolved.clone());
652 }
653 match in_scope(&self.scope, &resolved, change.kind) {
654 Tri::Yes => {}
655 Tri::No => outside.push(format!("{resolved} ({})", change.describe())),
656 Tri::Unknown => undecided.push(resolved),
657 }
658 }
659 }
660
661 touched.sort();
662
663 let mut judgement = Judgement::pass();
667 for path in &touched {
668 judgement = judgement.covering(Digest::of(path.as_bytes()));
669 }
670 judgement = judgement.covering(Digest::of(touched.join("\n").as_bytes()));
671
672 if !outside.is_empty() {
673 return Judgement {
674 verdict: boundgraph_core::Verdict::Fail(format!(
675 "{} path{} outside the step's grant: {}",
676 outside.len(),
677 if outside.len() == 1 { "" } else { "s" },
678 listing(&outside)
679 )),
680 also: judgement.also,
681 };
682 }
683
684 if !undecided.is_empty() {
685 return Judgement {
686 verdict: boundgraph_core::Verdict::Inconclusive(format!(
687 "cannot prove {} path{} within the step's grant: {}",
688 undecided.len(),
689 if undecided.len() == 1 { "" } else { "s" },
690 listing(&undecided)
691 )),
692 also: judgement.also,
693 };
694 }
695
696 judgement
697 }
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703 use crate::sign::LocalKey;
704 use boundgraph_core::{CondensatePayload, Intent, ProposalOrigin, SchemaId, SlicerId, Verdict};
705 use boundgraph_slice::MemoryStore;
706
707 fn order() -> WorkOrder {
708 WorkOrder::new(
709 Intent::new("patch.apply", &"fix the header parser").unwrap(),
710 vec![],
711 GrantSet::parse(["fs:write:/**"]).unwrap(),
714 ProposalOrigin::new("claude-opus-5", [], Digest::of(b"prompt")),
715 )
716 }
717
718 fn condensate_for(raw: &[u8]) -> Condensate {
719 Condensate::new(
720 SchemaId::new("boundgraph.diff.v1", 1),
721 CondensatePayload::inline(&"1 file changed in src/").unwrap(),
724 Digest::of(raw),
725 400,
726 true,
727 SlicerId::new("diff", 1),
728 )
729 }
730
731 fn key() -> LocalKey {
732 LocalKey::from_seed("host-key-1", [7u8; 32])
733 }
734
735 fn scope(atoms: &[&str]) -> GrantSet {
736 GrantSet::parse(atoms.iter().copied()).expect("test scope should parse")
737 }
738
739 async fn verdict_for(patch: &str, granted: &[&str]) -> Verdict {
740 let store = MemoryStore::new();
741 store.put(patch.as_bytes()).await.unwrap();
742
743 DiffScope::new(store, scope(granted))
744 .check(&order(), &condensate_for(patch.as_bytes()), &key())
745 .await
746 .unwrap()
747 .verdict
748 }
749
750 const IN_SCOPE: &str = "\
751diff --git a/src/parse.rs b/src/parse.rs
752--- a/src/parse.rs
753+++ b/src/parse.rs
754@@ -1,3 +1,3 @@
755 fn parse() {
756- old();
757+ new();
758 }
759";
760
761 const REACHES_OUT: &str = "\
762diff --git a/src/parse.rs b/src/parse.rs
763--- a/src/parse.rs
764+++ b/src/parse.rs
765@@ -1,1 +1,1 @@
766-old();
767+new();
768diff --git a/deploy/production.yaml b/deploy/production.yaml
769--- a/deploy/production.yaml
770+++ b/deploy/production.yaml
771@@ -1,1 +1,1 @@
772-replicas: 1
773+replicas: 99
774";
775
776 #[tokio::test]
779 async fn a_patch_inside_the_grant_passes() {
780 assert_eq!(
781 verdict_for(IN_SCOPE, &["fs:write:/src/**"]).await,
782 Verdict::Pass
783 );
784 }
785
786 #[tokio::test]
788 async fn a_path_outside_the_grant_fails_and_names_it() {
789 let v = verdict_for(REACHES_OUT, &["fs:write:/src/**"]).await;
790
791 let Verdict::Fail(reason) = v else {
792 panic!("expected Fail, got {v:?}");
793 };
794 assert!(reason.contains("deploy/production.yaml"), "{reason}");
795 assert!(
796 !reason.contains("src/parse.rs"),
797 "only the offender: {reason}"
798 );
799 assert!(reason.contains("1 path outside"), "{reason}");
800 }
801
802 #[tokio::test]
804 async fn a_deletion_outside_the_grant_is_caught() {
805 let patch = "\
806diff --git a/deploy/secrets.env b/deploy/secrets.env
807deleted file mode 100644
808--- a/deploy/secrets.env
809+++ /dev/null
810@@ -1,1 +0,0 @@
811-TOKEN=abc
812";
813 let v = verdict_for(patch, &["fs:write:/src/**"]).await;
814
815 let Verdict::Fail(reason) = v else {
816 panic!("expected Fail, got {v:?}");
817 };
818 assert!(reason.contains("deploy/secrets.env"), "{reason}");
819 assert!(reason.contains("deleted"), "{reason}");
820 }
821
822 #[tokio::test]
825 async fn both_halves_of_a_rename_must_be_in_scope() {
826 let patch = "\
827diff --git a/src/parse.rs b/deploy/parse.rs
828similarity index 100%
829rename from src/parse.rs
830rename to deploy/parse.rs
831";
832 let v = verdict_for(patch, &["fs:write:/src/**"]).await;
833
834 let Verdict::Fail(reason) = v else {
835 panic!("moving a file out of scope must fail, got {v:?}");
836 };
837 assert!(reason.contains("deploy/parse.rs"), "{reason}");
838
839 let inbound = "\
841diff --git a/deploy/parse.rs b/src/parse.rs
842similarity index 100%
843rename from deploy/parse.rs
844rename to src/parse.rs
845";
846 let v = verdict_for(inbound, &["fs:write:/src/**"]).await;
847 assert!(matches!(v, Verdict::Fail(_)), "{v:?}");
848 }
849
850 #[tokio::test]
852 async fn a_mode_change_alone_is_a_change() {
853 let patch = "\
854diff --git a/deploy/run.sh b/deploy/run.sh
855old mode 100644
856new mode 100755
857";
858 let v = verdict_for(patch, &["fs:write:/src/**"]).await;
859
860 let Verdict::Fail(reason) = v else {
861 panic!("chmod +x on an out-of-scope file must fail, got {v:?}");
862 };
863 assert!(reason.contains("deploy/run.sh"), "{reason}");
864 assert!(reason.contains("100644 -> 100755"), "{reason}");
865 }
866
867 #[tokio::test]
868 async fn a_mode_change_inside_the_grant_still_passes() {
869 let patch = "\
870diff --git a/src/run.sh b/src/run.sh
871old mode 100644
872new mode 100755
873";
874 assert_eq!(
875 verdict_for(patch, &["fs:write:/src/**"]).await,
876 Verdict::Pass
877 );
878 }
879
880 #[tokio::test]
881 async fn an_addition_is_authorised_by_write_or_by_create() {
882 let patch = "\
883diff --git a/src/new.rs b/src/new.rs
884new file mode 100644
885--- /dev/null
886+++ b/src/new.rs
887@@ -0,0 +1,1 @@
888+fn main() {}
889";
890 assert_eq!(
891 verdict_for(patch, &["fs:write:/src/**"]).await,
892 Verdict::Pass
893 );
894 assert_eq!(
895 verdict_for(patch, &["fs:create:/src/**"]).await,
896 Verdict::Pass
897 );
898 }
899
900 #[tokio::test]
901 async fn a_narrow_delete_grant_authorises_a_deletion() {
902 let patch = "\
903diff --git a/tmp/scratch b/tmp/scratch
904deleted file mode 100644
905--- a/tmp/scratch
906+++ /dev/null
907@@ -1,1 +0,0 @@
908-junk
909";
910 assert_eq!(
911 verdict_for(patch, &["fs:delete:/tmp/**"]).await,
912 Verdict::Pass
913 );
914 }
915
916 #[tokio::test]
917 async fn an_empty_patch_touches_nothing_and_passes() {
918 assert_eq!(verdict_for("", &["fs:write:/src/**"]).await, Verdict::Pass);
919 }
920
921 #[tokio::test]
923 async fn an_undecidable_comparison_is_inconclusive_rather_than_a_pass() {
924 let v = verdict_for(IN_SCOPE, &["fs:write:/src/*.rs"]).await;
926
927 assert!(matches!(v, Verdict::Inconclusive(_)), "{v:?}");
928 assert!(!v.is_pass());
929 assert!(v.reason().unwrap().contains("src/parse.rs"));
930 }
931
932 #[tokio::test]
934 async fn a_pass_records_exactly_which_paths_changed() {
935 let patch = "\
936diff --git a/src/a.rs b/src/a.rs
937--- a/src/a.rs
938+++ b/src/a.rs
939@@ -1,1 +1,1 @@
940-a
941+A
942diff --git a/src/b.rs b/src/b.rs
943--- a/src/b.rs
944+++ b/src/b.rs
945@@ -1,1 +1,1 @@
946-b
947+B
948";
949 let store = MemoryStore::new();
950 store.put(patch.as_bytes()).await.unwrap();
951
952 let evidence = DiffScope::new(store, scope(&["fs:write:/src/**"]))
953 .check(&order(), &condensate_for(patch.as_bytes()), &key())
954 .await
955 .unwrap();
956
957 assert!(evidence.permits_commit());
958 assert!(evidence.covers(&Digest::of(b"/src/a.rs")));
959 assert!(evidence.covers(&Digest::of(b"/src/b.rs")));
960 assert!(
961 !evidence.covers(&Digest::of(b"/src/c.rs")),
962 "a file it never touched must not be vouched for"
963 );
964 assert!(
965 evidence.covers(&Digest::of(b"/src/a.rs\n/src/b.rs")),
966 "and the set as a whole, so nothing can be added or removed"
967 );
968 }
969
970 #[tokio::test]
975 async fn the_check_reads_the_raw_patch_and_not_the_condensate_payload() {
976 let store = MemoryStore::new();
977 store.put(REACHES_OUT.as_bytes()).await.unwrap();
978
979 let condensate = condensate_for(REACHES_OUT.as_bytes());
981 assert!(condensate.lossy, "the summary is the model's-eye view");
982
983 let evidence = DiffScope::new(store, scope(&["fs:write:/src/**"]))
984 .check(&order(), &condensate, &key())
985 .await
986 .unwrap();
987
988 assert!(
989 !evidence.permits_commit(),
990 "the summary said in scope; the patch was not"
991 );
992 }
993
994 #[tokio::test]
995 async fn a_raw_patch_that_is_not_in_the_store_is_inconclusive() {
996 let evidence = DiffScope::new(MemoryStore::new(), scope(&["fs:write:/src/**"]))
997 .check(&order(), &condensate_for(b"never stored"), &key())
998 .await
999 .unwrap();
1000
1001 assert!(matches!(evidence.verdict, Verdict::Inconclusive(_)));
1002 }
1003
1004 #[tokio::test]
1006 async fn the_work_orders_own_request_is_ignored() {
1007 let v = verdict_for(REACHES_OUT, &["fs:write:/src/**"]).await;
1009 assert!(matches!(v, Verdict::Fail(_)), "{v:?}");
1010 }
1011
1012 #[tokio::test]
1013 async fn the_checker_needs_no_authority_of_its_own() {
1014 let checker = DiffScope::new(MemoryStore::new(), scope(&["fs:write:/src/**"]));
1015 assert_eq!(checker.grants(), GrantSet::EMPTY);
1016 }
1017
1018 #[tokio::test]
1019 async fn paths_can_be_resolved_against_a_workspace_root() {
1020 let store = MemoryStore::new();
1021 store.put(IN_SCOPE.as_bytes()).await.unwrap();
1022
1023 let evidence = DiffScope::new(store, scope(&["fs:write:/work/repo/src/**"]))
1024 .rooted_at("/work/repo/")
1025 .check(&order(), &condensate_for(IN_SCOPE.as_bytes()), &key())
1026 .await
1027 .unwrap();
1028
1029 assert!(evidence.permits_commit());
1030 assert!(evidence.covers(&Digest::of(b"/work/repo/src/parse.rs")));
1031 }
1032
1033 #[tokio::test]
1034 async fn the_same_patch_always_gives_the_same_evidence() {
1035 let store = MemoryStore::new();
1036 store.put(IN_SCOPE.as_bytes()).await.unwrap();
1037 let checker = DiffScope::new(store, scope(&["fs:write:/src/**"]));
1038 let (order, k, key) = (order(), condensate_for(IN_SCOPE.as_bytes()), key());
1039
1040 let first = checker.check(&order, &k, &key).await.unwrap();
1041 for _ in 0..4 {
1042 assert_eq!(checker.check(&order, &k, &key).await.unwrap(), first);
1043 }
1044 }
1045
1046 #[test]
1049 fn a_plain_unified_diff_with_no_git_preamble_parses() {
1050 let patch = "\
1051--- src/old.txt
1052+++ src/new.txt
1053@@ -1,1 +1,1 @@
1054-a
1055+b
1056";
1057 let changes = parse_patch(patch).unwrap();
1058 assert_eq!(changes.len(), 1);
1059 assert_eq!(changes[0].path, "src/new.txt");
1060 }
1061
1062 #[test]
1063 fn a_hunk_is_consumed_by_its_own_line_counts() {
1064 let patch = "\
1067diff --git a/src/a.rs b/src/a.rs
1068--- a/src/a.rs
1069+++ b/src/a.rs
1070@@ -1,3 +1,3 @@
1071 context
1072-diff --git a/x b/x
1073+diff --git a/x b/x
1074 @@ -9,9 +9,9 @@
1075diff --git a/evil.sh b/evil.sh
1076--- a/evil.sh
1077+++ b/evil.sh
1078@@ -1,1 +1,1 @@
1079-x
1080+y
1081";
1082 let changes = parse_patch(patch).unwrap();
1083 let paths: Vec<&str> = changes.iter().map(|c| c.path.as_str()).collect();
1084
1085 assert_eq!(
1086 paths,
1087 vec!["src/a.rs", "evil.sh"],
1088 "the second file must survive"
1089 );
1090 }
1091
1092 #[test]
1095 fn an_unrecognised_line_is_refused_rather_than_skipped() {
1096 let patch = "\
1097diff --git a/src/a.rs b/src/a.rs
1098what on earth is this line
1099--- a/src/a.rs
1100+++ b/src/a.rs
1101";
1102 let err = parse_patch(patch).unwrap_err();
1103 let PatchError::Unrecognised { line, .. } = err else {
1104 panic!("expected Unrecognised, got {err:?}");
1105 };
1106 assert!(line.contains("what on earth"));
1107 }
1108
1109 #[test]
1114 fn every_file_of_a_multi_file_plain_unified_diff_is_found() {
1115 let patch = "\
1116--- src/a.rs
1117+++ src/a.rs
1118@@ -1,1 +1,1 @@
1119-a
1120+A
1121--- deploy/production.yaml
1122+++ deploy/production.yaml
1123@@ -1,1 +1,1 @@
1124-replicas: 1
1125+replicas: 99
1126";
1127 let paths: Vec<String> = parse_patch(patch)
1128 .unwrap()
1129 .into_iter()
1130 .map(|c| c.path)
1131 .collect();
1132
1133 assert_eq!(paths, vec!["src/a.rs", "deploy/production.yaml"]);
1134 }
1135
1136 #[tokio::test]
1137 async fn a_second_file_of_a_plain_unified_diff_is_still_scope_checked() {
1138 let patch = "\
1139--- src/a.rs
1140+++ src/a.rs
1141@@ -1,1 +1,1 @@
1142-a
1143+A
1144--- deploy/production.yaml
1145+++ deploy/production.yaml
1146@@ -1,1 +1,1 @@
1147-replicas: 1
1148+replicas: 99
1149";
1150 let v = verdict_for(patch, &["fs:write:/src/**"]).await;
1151
1152 let Verdict::Fail(reason) = v else {
1153 panic!("expected Fail, got {v:?}");
1154 };
1155 assert!(reason.contains("deploy/production.yaml"), "{reason}");
1156 }
1157
1158 #[test]
1161 fn a_file_whose_path_cannot_be_read_is_refused_rather_than_dropped() {
1162 let patch = "\
1165diff --git a/deploy/my script.sh b/deploy/my script.sh
1166old mode 100644
1167new mode 100755
1168";
1169 assert!(
1170 matches!(parse_patch(patch), Err(PatchError::Ambiguous { .. })),
1171 "got {:?}",
1172 parse_patch(patch)
1173 );
1174 }
1175
1176 #[tokio::test]
1177 async fn an_unreadable_header_leaves_the_verdict_undecided_not_passing() {
1178 let patch = "\
1179diff --git a/deploy/my script.sh b/deploy/my script.sh
1180old mode 100644
1181new mode 100755
1182";
1183 let v = verdict_for(patch, &["fs:write:/src/**"]).await;
1184
1185 assert!(matches!(v, Verdict::Inconclusive(_)), "{v:?}");
1186 assert!(!v.is_pass(), "an unreadable patch must never pass");
1187 }
1188
1189 #[test]
1190 fn a_malformed_hunk_header_is_refused() {
1191 let patch = "\
1192diff --git a/src/a.rs b/src/a.rs
1193@@ not a range @@
1194";
1195 assert!(matches!(
1196 parse_patch(patch),
1197 Err(PatchError::MalformedHunk { .. })
1198 ));
1199 }
1200
1201 #[test]
1202 fn a_patch_that_ends_mid_hunk_is_refused() {
1203 let patch = "\
1204diff --git a/src/a.rs b/src/a.rs
1205@@ -1,9 +1,9 @@
1206 one line only
1207";
1208 assert!(matches!(
1209 parse_patch(patch),
1210 Err(PatchError::Truncated { .. })
1211 ));
1212 }
1213
1214 #[test]
1215 fn content_before_the_first_file_header_is_refused() {
1216 assert!(matches!(
1217 parse_patch("Subject: a commit message\n\nbody\n"),
1218 Err(PatchError::Unrecognised { .. })
1219 ));
1220 }
1221
1222 #[test]
1223 fn a_no_newline_marker_counts_against_neither_side() {
1224 let patch = "\
1225diff --git a/a b/a
1226--- a/a
1227+++ b/a
1228@@ -1,1 +1,1 @@
1229-old
1230\\ No newline at end of file
1231+new
1232\\ No newline at end of file
1233";
1234 assert_eq!(parse_patch(patch).unwrap().len(), 1);
1235 }
1236
1237 #[test]
1238 fn a_hunk_header_without_a_length_means_one_line() {
1239 let patch = "\
1240diff --git a/a b/a
1241--- a/a
1242+++ b/a
1243@@ -1 +1 @@
1244-old
1245+new
1246";
1247 assert_eq!(parse_patch(patch).unwrap().len(), 1);
1248 }
1249
1250 #[test]
1251 fn a_binary_change_is_recorded_without_parsing_its_body() {
1252 let patch = "\
1253diff --git a/logo.png b/logo.png
1254index 1111111..2222222 100644
1255GIT binary patch
1256literal 120
1257zcmeAS@N?(olHy`uVBq!ia0vp^
1258";
1259 let changes = parse_patch(patch).unwrap();
1260 assert_eq!(changes.len(), 1);
1261 assert!(changes[0].binary);
1262 assert_eq!(changes[0].path, "logo.png");
1263 }
1264
1265 #[test]
1266 fn a_timestamp_after_the_path_is_not_part_of_it() {
1267 let patch = "\
1268--- src/a.rs\t2026-01-01 10:00:00
1269+++ src/a.rs\t2026-01-02 10:00:00
1270@@ -1,1 +1,1 @@
1271-a
1272+b
1273";
1274 assert_eq!(parse_patch(patch).unwrap()[0].path, "src/a.rs");
1275 }
1276
1277 #[test]
1278 fn a_quoted_path_with_a_space_is_read_from_the_marker_lines() {
1279 let patch = "\
1280diff --git \"a/src/my file.rs\" \"b/src/my file.rs\"
1281--- \"a/src/my file.rs\"
1282+++ \"b/src/my file.rs\"
1283@@ -1,1 +1,1 @@
1284-a
1285+b
1286";
1287 assert_eq!(parse_patch(patch).unwrap()[0].path, "src/my file.rs");
1288 }
1289
1290 #[test]
1293 fn a_rename_names_both_paths_and_a_plain_edit_names_one() {
1294 let renamed = Change {
1295 path: "b.rs".into(),
1296 from: Some("a.rs".into()),
1297 kind: ChangeKind::Renamed,
1298 old_mode: None,
1299 new_mode: None,
1300 binary: false,
1301 };
1302 assert_eq!(renamed.paths(), vec!["a.rs", "b.rs"]);
1303
1304 let edited = Change {
1305 from: Some("a.rs".into()),
1306 path: "a.rs".into(),
1307 ..renamed.clone()
1308 };
1309 assert_eq!(edited.paths(), vec!["a.rs"]);
1310 }
1311
1312 #[test]
1313 fn a_mode_change_is_only_a_change_when_the_modes_differ() {
1314 let same = Change {
1315 path: "a.sh".into(),
1316 from: None,
1317 kind: ChangeKind::Modified,
1318 old_mode: Some("100644".into()),
1319 new_mode: Some("100644".into()),
1320 binary: false,
1321 };
1322 assert!(!same.mode_changed());
1323
1324 let changed = Change {
1325 new_mode: Some("100755".into()),
1326 ..same
1327 };
1328 assert!(changed.mode_changed());
1329 assert!(changed.describe().contains("100644 -> 100755"));
1330 }
1331
1332 #[test]
1333 fn a_long_list_of_offenders_is_counted_rather_than_listed() {
1334 let paths: Vec<String> = (0..40).map(|n| format!("f{n}")).collect();
1335 let rendered = listing(&paths);
1336
1337 assert!(rendered.contains("f0"));
1338 assert!(rendered.contains("and 20 more"), "{rendered}");
1339 }
1340}