1use crate::address::Address;
12use crate::error::{AxCode, AxError, GateRefusal};
13use crate::taint::TaintSet;
14use crate::write_domain::{DocumentReason, DomainVerdict, WriteDomain, WriteLimit};
15
16use super::GateOutcome;
17
18pub fn domain(domain: &WriteDomain, target: &Address, taint: &TaintSet) -> GateOutcome {
21 match domain.admits(target) {
22 DomainVerdict::Within => GateOutcome::Allow,
23 DomainVerdict::NotWritable { reason } => not_writable(target, reason),
24 DomainVerdict::Outside { prefixes } => outside(target, prefixes, taint),
25 }
26}
27
28pub fn reach(domain: &WriteDomain, area: &Address, taint: &TaintSet) -> GateOutcome {
33 if domain.reaches(area) {
34 GateOutcome::Allow
35 } else {
36 outside(
37 area,
38 domain.prefixes().map(|p| p.as_str().to_owned()).collect(),
39 taint,
40 )
41 }
42}
43
44#[must_use]
53pub fn replacing(limit: WriteLimit, target: &Address) -> GateOutcome {
54 match limit {
55 WriteLimit::Full => GateOutcome::Allow,
56 WriteLimit::Create => GateOutcome::Deny {
57 refusal: Box::new(
58 AxError::refusal(
59 AxCode::OutsideWriteDomain,
60 "write file",
61 target.as_str(),
62 GateRefusal::new(
63 "this run creates files and changes none",
64 format!(
65 "{} would change what is already there, and this run was \
66 dispatched under the create write limit",
67 target.as_str()
68 ),
69 "write what you have to the path of a file that does not exist yet",
70 ),
71 )
72 .with_recovery(
73 "the write limit is chosen when the work is dispatched; ask the User to \
74 dispatch it again with the full write limit to change existing files",
75 ),
76 ),
77 },
78 }
79}
80
81fn outside(target: &Address, prefixes: Vec<String>, taint: &TaintSet) -> GateOutcome {
84 let mut violation = format!("target {} is outside the write domain", target.as_str());
85 if !taint.is_empty() {
86 violation.push_str(&format!("; the action carries content from {taint}"));
87 }
88 let alternative = if prefixes.is_empty() {
89 "this actor writes nowhere; read, or hand the change to an actor with a domain".to_owned()
90 } else {
91 format!("write under one of: {}", prefixes.join(", "))
92 };
93 GateOutcome::Deny {
94 refusal: Box::new(
95 AxError::refusal(
96 AxCode::OutsideWriteDomain,
97 "write file",
98 target.as_str(),
99 GateRefusal::new(
100 "writes land inside the write domain",
101 violation,
102 alternative,
103 ),
104 )
105 .with_nearby(prefixes)
106 .with_recovery(
107 "a write domain widens in one place only, the building's \
108 `.sprawling/RULES.toml`, which the User edits; ask the User for that change",
109 ),
110 ),
111 }
112}
113
114fn not_writable(target: &Address, reason: DocumentReason) -> GateOutcome {
118 let (violation, alternative) = match reason {
119 DocumentReason::NotMarkdown => (
120 format!("{} is not a Markdown document", target.as_str()),
121 "this actor writes documents; put what you have to say in a `.md` file, or hand \
122 the change to a resident whose domain takes code"
123 .to_owned(),
124 ),
125 DocumentReason::ThePlan => (
126 format!(
127 "{} is a plan file, and no write domain reaches one",
128 target.as_str()
129 ),
130 format!(
131 "use the `plan` tool: it is the one editing entrance to {}, and it keeps the \
132 six-column table readable",
133 crate::spine::ROADMAP_FILE
134 ),
135 ),
136 };
137 GateOutcome::Deny {
138 refusal: Box::new(
139 AxError::refusal(
140 AxCode::OutsideWriteDomain,
141 "write file",
142 target.as_str(),
143 GateRefusal::new(
144 "this write domain writes Markdown documents and never a plan",
145 violation,
146 alternative,
147 ),
148 )
149 .with_recovery(
150 "the kinds of file this domain takes are declared in the building's \
151 `.sprawling/RULES.toml`, which the User edits; ask the User to change it",
152 ),
153 ),
154 }
155}
156
157#[cfg(test)]
158#[allow(
159 clippy::unwrap_used,
160 clippy::expect_used,
161 clippy::panic,
162 clippy::indexing_slicing,
163 clippy::wildcard_enum_match_arm,
164 clippy::let_underscore_must_use,
165 clippy::let_underscore_untyped,
166 reason = "test code"
167)]
168mod tests {
169 use super::*;
170 use crate::taint::TaintSource;
171
172 fn assert_three_parts(outcome: &GateOutcome, code: AxCode) {
173 match outcome {
174 GateOutcome::Deny { refusal } => {
175 assert_eq!(refusal.code(), &code);
176 let gate = refusal.gate().expect("gate refusals carry three parts");
177 assert!(!gate.rule().is_empty());
178 assert!(!gate.violation().is_empty());
179 assert!(!gate.alternative().is_empty());
180 }
181 _ => panic!("expected a refusal"),
182 }
183 }
184
185 #[test]
186 fn the_domain_door_refuses_with_prefixes_as_nearby() {
187 let wd = WriteDomain::new(vec![Address::parse("b1/room").unwrap()]).unwrap();
188 let inside = Address::parse("b1/room/notes.md").unwrap();
189 assert!(matches!(
190 domain(&wd, &inside, &TaintSet::empty()),
191 GateOutcome::Allow
192 ));
193 let outside = Address::parse("b2/other.md").unwrap();
194 let outcome = domain(&wd, &outside, &TaintSet::empty());
195 assert_three_parts(&outcome, AxCode::OutsideWriteDomain);
196 let GateOutcome::Deny { refusal } = outcome else {
197 panic!("refusal shape asserted above")
198 };
199 assert_eq!(refusal.nearby(), ["b1/room"]);
200 let tainted = TaintSet::of(TaintSource::new("web:evil").unwrap());
202 let outcome = domain(&wd, &outside, &tainted);
203 let GateOutcome::Deny { refusal } = outcome else {
204 panic!("refusal shape asserted above")
205 };
206 assert!(refusal.gate().unwrap().violation().contains("web:evil"));
207 }
208
209 #[test]
214 fn the_create_limit_refuses_a_replacing_write_and_the_full_one_does_not() {
215 let existing = Address::parse("b1/room/notes.md").unwrap();
216 assert!(matches!(
217 replacing(WriteLimit::Full, &existing),
218 GateOutcome::Allow
219 ));
220 let outcome = replacing(WriteLimit::Create, &existing);
221 assert_three_parts(&outcome, AxCode::OutsideWriteDomain);
222 let GateOutcome::Deny { refusal } = outcome else {
223 panic!("refusal shape asserted above")
224 };
225 assert_eq!(refusal.subject(), "b1/room/notes.md");
226 assert!(
227 refusal
228 .gate()
229 .unwrap()
230 .alternative()
231 .contains("does not exist")
232 );
233 }
234
235 #[test]
239 fn a_documents_domain_takes_markdown_refuses_code_and_never_the_plan() {
240 let wd = WriteDomain::documents(vec![Address::parse("hall").unwrap()]).unwrap();
241 let empty = TaintSet::empty();
242 assert!(matches!(
243 domain(&wd, &Address::parse("hall/Memo.md").unwrap(), &empty),
244 GateOutcome::Allow
245 ));
246
247 let code = domain(&wd, &Address::parse("hall/build.rs").unwrap(), &empty);
248 assert_three_parts(&code, AxCode::OutsideWriteDomain);
249 let GateOutcome::Deny { refusal } = code else {
250 panic!("refusal shape asserted above")
251 };
252 assert!(
253 refusal
254 .gate()
255 .unwrap()
256 .violation()
257 .contains("not a Markdown document")
258 );
259
260 for plan in ["hall/Roadmap.md", "hall/mayor/Roadmap.md"] {
261 let outcome = domain(&wd, &Address::parse(plan).unwrap(), &empty);
262 assert_three_parts(&outcome, AxCode::OutsideWriteDomain);
263 let GateOutcome::Deny { refusal } = outcome else {
264 panic!("refusal shape asserted above")
265 };
266 assert!(
267 refusal
268 .gate()
269 .unwrap()
270 .alternative()
271 .contains("`plan` tool"),
272 "{plan} is refused with the door that does reach it"
273 );
274 }
275
276 assert!(matches!(
279 wd.admits(&Address::parse("hall/.sprawling/MAYOR.md").unwrap()),
280 crate::write_domain::DomainVerdict::Outside { .. }
281 ));
282 }
283}