Skip to main content

kernel/
address.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//! Address: canonical relative path inside a city, and
7//! the WriteDomain primitive `is_within`.
8//! The part `crates/kernel/spec/Address.lean` specifies this module.
9//!
10//! Invariants owned here:
11//! - one constructor: [`Address::parse`]; whatever it accepts is already
12//!   canonical (no normalization happens, non-canonical spellings are
13//!   rejected), so `as_str` is a byte-exact round trip.
14//! - identity is byte-wise on every platform: `Eq`, `Ord` and `is_within`
15//!   never fold case; `is_reserved` alone does, and it only refuses more.
16//! - symlink resolution is an effect-layer concern; this type only judges
17//!   already-canonical relative paths.
18//! - `is_reserved` answers C17: a protected-metadata subtree
19//!   ([`PROTECTED_METADATA`]) can never enter a WriteDomain, at whatever
20//!   depth it sits, and every WriteDomain constructor must ask first.
21//! - [`may_read`] answers the read bound: what a resident of one
22//!   building may read by a path it chose (`crates/city/Spec.lean` §8-2).
23
24use serde::{Deserialize, Serialize};
25
26use crate::error::{AxCode, AxError};
27
28/// The directory name whose subtree never enters any WriteDomain (C17).
29///
30/// One of these belongs to each scope that has rules of its own: the
31/// city's holds the ledger and the city configuration, a building's
32/// holds the rules that govern it. What governs a scope is therefore
33/// never writable by what runs inside it.
34pub const RESERVED_PREFIX: &str = ".sprawling";
35
36/// git's own metadata directory (`crates/kernel/spec/Address.lean` §8-73). Writing it is
37/// privilege escalation: a hook is code that runs at the next git
38/// operation, a config key can start a program, and the checkpoint
39/// references and the restoration objects a `file_discarded` names all
40/// live in there.
41pub const GIT_METADATA: &str = ".git";
42
43/// Every directory name that is protected metadata, and therefore
44/// reserved at whatever depth it sits: what governs a scope, and what
45/// keeps a repository itself. The one list - [`Address::is_reserved`]
46/// judges it, and no caller re-spells these names.
47pub const PROTECTED_METADATA: [&str; 2] = [RESERVED_PREFIX, GIT_METADATA];
48
49/// Canonical relative path; invariants enforced at the sole constructor.
50#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
51#[serde(transparent)]
52pub struct Address(String);
53
54impl Address {
55    /// Sole constructor; `crate::schema` restates this grammar as one
56    /// pattern for clients. Rejects: empty input, absolute paths,
57    /// backslash, `:` (drive letters and NTFS streams alike), control
58    /// characters, empty segments, `.`/`..`, and a segment ending in a
59    /// dot or whitespace. Fail-closed: not canonical is `E_INVALID_ARGS`.
60    pub fn parse(raw: &str) -> Result<Self, AxError> {
61        let reject = |violation: &str| {
62            Err(
63                AxError::failure(AxCode::InvalidArgs, "parse address", raw).with_recovery(format!(
64                    "{violation}; use a canonical relative path: `/`-separated \
65                     segments without `.`/`..`, backslash, `:`, or control characters"
66                )),
67            )
68        };
69        if raw.is_empty() {
70            return reject("address is empty");
71        }
72        if raw.starts_with('/') {
73            return reject("address is absolute");
74        }
75        if raw.contains('\\') {
76            return reject("address contains a backslash");
77        }
78        if raw.contains(':') {
79            return reject("address contains `:`");
80        }
81        if raw.chars().any(char::is_control) {
82            return reject("address contains a control character");
83        }
84        for segment in raw.split('/') {
85            if segment.is_empty() {
86                return reject("address contains an empty segment");
87            }
88            if segment == "." || segment == ".." {
89                return reject("address contains a `.` or `..` segment");
90            }
91            // Win32 strips trailing dots and spaces from a path component
92            // before opening the file, so `.sprawling.` opens `.sprawling`.
93            // Refusing the alias here spares every later reader, and
94            // `is_reserved` above all, from knowing that rule.
95            if segment.ends_with('.') || segment.ends_with(char::is_whitespace) {
96                return reject(
97                    "a segment ends with a dot or whitespace, which some file systems drop, \
98                     making this address a second spelling of a different one",
99                );
100            }
101        }
102        Ok(Address(raw.to_owned()))
103    }
104
105    /// WriteDomain primitive: true iff `self` equals `prefix` or lies under
106    /// it on a segment boundary. Byte-wise, reflexive, transitive.
107    pub fn is_within(&self, prefix: &Address) -> bool {
108        match self.0.strip_prefix(&prefix.0) {
109            Some(rest) => rest.is_empty() || rest.starts_with('/'),
110            None => false,
111        }
112    }
113
114    /// C17 primitive: true iff any segment names protected metadata
115    /// ([`PROTECTED_METADATA`]), compared without ASCII case, because
116    /// Windows resolves `.SPRAWLING` to the same directory and this
117    /// predicate guards the directory rather than the string. ASCII
118    /// folding is the whole of it: the names are ASCII, so a Unicode
119    /// case table would add a second, version-dependent authority on
120    /// the same question.
121    ///
122    /// Any segment rather than the first: a building's own rules sit at
123    /// `<building>/.sprawling/`, and a run whose write domain is its
124    /// building must not reach them. Widening this predicate can only
125    /// refuse more, which is the direction a fail-closed check may move
126    /// in without a second authority to check it against (`crates/kernel/spec/Address.lean`
127    /// §8-73).
128    pub fn is_reserved(&self) -> bool {
129        self.0.split('/').any(|segment| {
130            PROTECTED_METADATA
131                .iter()
132                .any(|name| segment.eq_ignore_ascii_case(name))
133        })
134    }
135
136    pub fn as_str(&self) -> &str {
137        &self.0
138    }
139
140    /// What this place or resident is called: the last segment, which is
141    /// the word a person typed when they made it. The whole address reads
142    /// like a path; the last segment reads like somebody. The grammar
143    /// admits no empty segment, so there is always one to give.
144    #[must_use]
145    pub fn name(&self) -> &str {
146        self.0.rsplit('/').next().unwrap_or(&self.0)
147    }
148}
149
150impl std::fmt::Display for Address {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        f.write_str(&self.0)
153    }
154}
155
156/// What the read bound answers about one address.
157///
158/// Two of the three arms close, and they stay two arms because a model
159/// refused by each has a different next step: ask somebody inside the
160/// confidential building, or wait for a person to fix rules that do not
161/// read.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum ReadVerdict {
164    /// The reader's own building, or a building whose rules say
165    /// `confidential = false`.
166    Open,
167    /// A confidential building, asked about from outside it.
168    Confidential,
169    /// The rules that would answer did not read, so the building is
170    /// closed: what they would have said may be `confidential = true`,
171    /// and a privacy setting does not fail towards the permissive side.
172    RulesUnreadable(AxError),
173}
174
175/// The read bound: whether a resident of `reader_building` may read
176/// `target`. Its own building is open whole; any other is open unless
177/// its rules say it is confidential or cannot be read.
178///
179/// `rules` answers whether the building holding `target` is
180/// confidential, and is called only when `target` is outside the
181/// reader's building. It is a closure rather than a value because a read
182/// inside one's own building is the common case and should cost no
183/// disk, and because which building holds an address and what its rules
184/// say are the city crate's to answer.
185pub fn may_read(
186    reader_building: &Address,
187    target: &Address,
188    rules: impl FnOnce() -> Result<bool, AxError>,
189) -> ReadVerdict {
190    if target.is_within(reader_building) {
191        return ReadVerdict::Open;
192    }
193    match rules() {
194        Ok(false) => ReadVerdict::Open,
195        Ok(true) => ReadVerdict::Confidential,
196        Err(unread) => ReadVerdict::RulesUnreadable(unread),
197    }
198}
199
200impl<'de> Deserialize<'de> for Address {
201    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
202        let raw = String::deserialize(deserializer)?;
203        Address::parse(&raw).map_err(serde::de::Error::custom)
204    }
205}
206
207/// The longest word a person may give a session. It becomes a directory
208/// name on their file system, and a name longer than this is a sentence
209/// that wanted to be a task. `crate::schema` states the cap in the
210/// pattern it hands the client from this number.
211pub(crate) const SESSION_NAME_MAX: usize = 64;
212
213/// What a person calls one session: one address segment, and therefore
214/// one directory under a building.
215///
216/// A `String` here would put the segment rules in whichever caller
217/// remembered them. This has one constructor, so a name that cannot be
218/// a room cannot be spelled, on the wire or anywhere else.
219#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
220#[serde(transparent)]
221pub struct SessionName(String);
222
223impl SessionName {
224    /// Sole constructor. Trims the ends - a person types a trailing
225    /// space and means nothing by it - and then refuses anything that
226    /// is not exactly one usable segment.
227    ///
228    /// # Errors
229    /// `E_INVALID_ARGS` for empty input, a separator, a `.` or `..`
230    /// segment, a protected metadata name, a control character, or a
231    /// name past [`SESSION_NAME_MAX`].
232    pub fn parse(raw: &str) -> Result<Self, AxError> {
233        let trimmed = raw.trim();
234        let reject = |violation: &str| {
235            Err(
236                AxError::failure(AxCode::InvalidArgs, "name a session", raw).with_recovery(
237                    format!(
238                        "{violation}; give one word or phrase this session can be found by - it \
239                         becomes a folder beside the others in that building"
240                    ),
241                ),
242            )
243        };
244        if trimmed.is_empty() {
245            return reject("a session with no name has no folder to work in");
246        }
247        if trimmed.chars().count() > SESSION_NAME_MAX {
248            return reject("that is longer than a name and shorter than a task");
249        }
250        if trimmed == "." || trimmed == ".." {
251            return reject("that names a directory rather than a session");
252        }
253        // The name and the segment rules are Address's, asked rather
254        // than restated: a second copy of them here would drift from the
255        // one that judges the path this name becomes. Asking the
256        // predicate rather than comparing strings is what also refuses
257        // `.GIT`, the Windows alias of a protected name.
258        if matches!(Address::parse(trimmed), Ok(addr) if addr.is_reserved()) {
259            return reject("that name belongs to the city itself");
260        }
261        if trimmed.contains('/') || Address::parse(trimmed).is_err() {
262            return reject(
263                "a session name is one segment that stands on its own: no `/`, `\\`, `:`, \
264                 control characters, and no trailing dot",
265            );
266        }
267        Ok(SessionName(trimmed.to_owned()))
268    }
269
270    #[must_use]
271    pub fn as_str(&self) -> &str {
272        &self.0
273    }
274}
275
276impl std::fmt::Display for SessionName {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        f.write_str(&self.0)
279    }
280}
281
282impl<'de> Deserialize<'de> for SessionName {
283    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
284        let raw = String::deserialize(deserializer)?;
285        SessionName::parse(&raw).map_err(serde::de::Error::custom)
286    }
287}
288
289#[cfg(test)]
290#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
291pub(crate) mod tests;