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;