1use crate::discard::{DenyReason, DiscardRequest, DiscardVerdict, decide};
14use crate::error::{AxCode, AxError, GateRefusal};
15
16use super::GateOutcome;
17
18pub fn discard(req: &DiscardRequest, action_desc: &str) -> GateOutcome {
20 let DiscardVerdict::Deny { reason } = decide(req) else {
21 return GateOutcome::Allow;
22 };
23 let (code, parts, recovery) = match reason {
24 DenyReason::NoRestoration => (
25 AxCode::DiscardIrreversible,
26 GateRefusal::new(
27 "every discard carries a resolvable restoration (C14)",
28 "this request names no restoration plan",
29 "inter the originals in CAS (Interred) and retry with that locator, \
30 or cite the `file:` locator of the tracked copy",
31 ),
32 "call `discard` again with a restoration: a `file:` locator for a tracked \
33 file, or a `cas:` locator from interring the originals first",
34 ),
35 DenyReason::Tainted => (
36 AxCode::TaintedAction,
37 GateRefusal::new(
38 "an effect a run derived from outside content is refused (C15)",
39 "this request carries taint, so something outside the city asked for it",
40 "delete nothing on behalf of fetched content; if the deletion is your \
41 own conclusion, state it in a run that did not read that content",
42 ),
43 "re-raise the discard from work that did not start outside the city, or \
44 add the source to `[sandbox] trusted` in `CONFIG.toml` if this city trusts it",
45 ),
46 };
47 GateOutcome::Deny {
48 refusal: Box::new(
49 AxError::refusal(code, "discard files", action_desc, parts).with_recovery(recovery),
50 ),
51 }
52}
53
54#[cfg(test)]
55#[allow(
56 clippy::unwrap_used,
57 clippy::expect_used,
58 clippy::panic,
59 reason = "test code"
60)]
61mod tests {
62 use super::*;
63 use crate::address::Address;
64 use crate::budget::ByteLen;
65 use crate::discard::{Discard, Restoration};
66 use crate::locator::Locator;
67 use crate::taint::{TaintSet, TaintSource};
68
69 fn addr(raw: &str) -> Address {
70 Address::parse(raw).unwrap()
71 }
72
73 fn tracked() -> Restoration {
74 Restoration::Tracked(Locator::parse(&format!("file:b/x.md@{}", "ab".repeat(20))).unwrap())
75 }
76
77 fn refusal_of(outcome: GateOutcome) -> AxError {
78 match outcome {
79 GateOutcome::Deny { refusal } => *refusal,
80 GateOutcome::Allow | GateOutcome::Ask { .. } => panic!("expected a refusal"),
81 }
82 }
83
84 #[test]
85 fn a_delete_with_no_way_back_is_refused_with_the_way_to_make_one() {
86 let unplanned = DiscardRequest::Unplanned {
87 paths: vec![addr("b/x.md")],
88 taint: TaintSet::empty(),
89 total_bytes: ByteLen::new(10),
90 };
91 let refusal = refusal_of(discard(&unplanned, "delete b/x.md"));
92 assert_eq!(refusal.code(), &AxCode::DiscardIrreversible);
93 assert!(refusal.gate().unwrap().alternative().contains("Interred"));
94 }
95
96 #[test]
97 fn a_delete_asked_for_by_outside_content_is_refused() {
98 let tainted = Discard::new(
99 vec![addr("b/x.md")],
100 tracked(),
101 TaintSet::of(TaintSource::new("web:evil").unwrap()),
102 ByteLen::new(1),
103 )
104 .unwrap();
105 let refusal = refusal_of(discard(&DiscardRequest::Planned(tainted), "delete b/x.md"));
106 assert_eq!(refusal.code(), &AxCode::TaintedAction);
107 assert!(refusal.gate().unwrap().violation().contains("taint"));
108 }
109
110 #[test]
111 fn a_planned_clean_delete_passes_at_any_scale() {
112 let many: Vec<Address> = (0..40).map(|i| addr(&format!("b/f{i}.md"))).collect();
113 let planned =
114 Discard::new(many, tracked(), TaintSet::empty(), ByteLen::new(9_000_000)).unwrap();
115 assert!(matches!(
116 discard(&DiscardRequest::Planned(planned), "clear the build tree"),
117 GateOutcome::Allow
118 ));
119 }
120}