Skip to main content

kernel/gate/
domain.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The domain doors: whether a run may write one file, reach a declared
7//! area, or change a file already there, each read from its WriteDomain.
8//! The part `crates/kernel/spec/Gate.lean` specifies this module with the
9//! rest of `kernel::gate`.
10
11use 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
18/// Whether one file may be written: inside the prefixes, and the kind
19/// of file this domain writes.
20pub 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
28/// Whether a declared area is reachable at all. A tool declares the
29/// room it works in, not a file, so this asks only the prefix question:
30/// a documents domain asked whether `hall/mayor` is Markdown would say
31/// no, and the Mayor could write nothing.
32pub 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/// Whether a write that may change a file already there is open under
45/// this run's write limit (`crates/kernel/spec/WriteDomain.lean` ยง8-78).
46///
47/// Asked by every write path before it touches a file that exists, or
48/// before it starts anything that could: the edit tool's replacing arm,
49/// and a command placed on the host. Whether the target exists is not
50/// asked here; a create that finds its name taken is refused by the
51/// filesystem at the moment of the write, under either limit.
52#[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
81/// The one refusal for a target beyond the prefixes, whether it is a
82/// file or an area.
83fn 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
114/// The refusal a documents domain owes a target inside its own
115/// prefixes: the target is reachable, and this actor still does not
116/// write that kind of file.
117fn 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        // Tainted violations name their provenance in the violation part.
201        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    /// The full limit adds nothing to the domain; the create limit
210    /// refuses every write that would change a file already there, with
211    /// the target as the subject and the way to a new file as the
212    /// alternative.
213    #[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    /// City Hall's residents write Markdown and nothing else, and the
236    /// plan file is refused to them at whatever depth it sits: the
237    /// `plan` tool is its one editing entrance.
238    #[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        // The reserved subtree is still Outside rather than
277        // NotWritable: it is not this actor's ground at all.
278        assert!(matches!(
279            wd.admits(&Address::parse("hall/.sprawling/MAYOR.md").unwrap()),
280            crate::write_domain::DomainVerdict::Outside { .. }
281        ));
282    }
283}