Skip to main content

runtime/tools/
chosen_path.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 one judgement a path chosen by a model gets.
7//!
8//! Two tools take a path from the model — `read` opens it, `search`
9//! bounds a walk with it — and both have to answer the same three
10//! questions before touching a disk: does this parse as an address of
11//! this city, does it reach a reserved subtree, and may this run's
12//! building read it. A second copy of those answers would be a second
13//! authority, and the copy is always the one that stops being updated;
14//! so the refusals live here, reservedness stays in
15//! `kernel::Address::is_reserved`, and the read bound in
16//! `kernel::address::may_read`.
17//!
18//! The order is fixed and cheapest first: the grammar and the reserved
19//! subtree touch no disk, and the read bound may read one building's
20//! rules.
21//!
22//! A catalog name is not judged here. Admission for a catalog entry
23//! happened when a person wrote the building's reading room, which is
24//! why a skill inside reserved space is still handed over: `read`
25//! resolves the catalog first and only unresolved names reach this.
26//!
27//! `crates/runtime/spec/Tools/ChosenPath.lean` proves what the judgement
28//! must hold: an admitted path is open and outside every reserved
29//! subtree, and `land` judges where the disk really lands (§8-30-1).
30
31use std::borrow::Cow;
32use std::io::ErrorKind;
33use std::path::{Component, Path, PathBuf};
34use std::sync::Arc;
35
36use kernel::{Address, AxCode, AxError, ReadVerdict};
37use same_file::Handle;
38
39/// What one run may read: the read bound closed over the reader's
40/// building and the city's rules. The assembly builds it once per run
41/// and hands a clone to `read` and to `search`, so both ask the same
42/// question of the same rules.
43pub type ReadBound = Arc<dyn Fn(&Address) -> ReadVerdict + Send + Sync>;
44
45/// The address a model may act on, or the refusal that says why not.
46///
47/// `action` names the tool in both errors, so a refusal a model reads
48/// says which of its own calls was turned down.
49///
50/// # Errors
51/// `E_INVALID_ARGS` when the string is not a canonical city-relative
52/// address, `E_GATE_DENIED` when it reaches a reserved subtree or a
53/// building the read bound closes.
54pub(crate) fn admit(
55    asked: &str,
56    action: &'static str,
57    bound: &dyn Fn(&Address) -> ReadVerdict,
58) -> Result<Address, AxError> {
59    let addr = Address::parse(asked).map_err(|err| {
60        AxError::failure(
61            AxCode::InvalidArgs,
62            action,
63            format!("{asked}: {}", err.subject()),
64        )
65        .with_recovery(
66            "pass a city-relative path with no `..`, no leading slash and no empty segment",
67        )
68    })?;
69    if addr.is_reserved() {
70        return Err(AxError::failure(
71            AxCode::GateDenied,
72            action,
73            format!("{asked} is inside a reserved subtree"),
74        )
75        .with_recovery(
76            "a reserved subtree holds what governs a scope or keeps the repository itself \
77             (`.sprawling`, `.git`), and no run reads its own governance; ask for a skill by \
78             its catalog name instead, or name a path outside it",
79        ));
80    }
81    match bound(&addr) {
82        ReadVerdict::Open => Ok(addr),
83        ReadVerdict::Confidential => Err(AxError::failure(
84            AxCode::GateDenied,
85            action,
86            format!("{asked} is inside a confidential building"),
87        )
88        .with_recovery(
89            "a confidential building is read by its own residents and nobody else; signal a \
90             resident there with the question, or work from what your own building holds",
91        )),
92        ReadVerdict::RulesUnreadable(unread) => Err(AxError::failure(
93            AxCode::GateDenied,
94            action,
95            format!(
96                "{asked}: the rules of its building did not read ({})",
97                unread.subject()
98            ),
99        )
100        .with_recovery(
101            "a building whose rules cannot be read is closed to every other building, because \
102             those rules may say it is confidential; the User has to fix them before anyone \
103             outside reads there",
104        )),
105    }
106}
107
108/// The city spelling of a path a model wrote: an absolute path inside
109/// the city becomes the address it names there, and anything else comes
110/// back as written.
111///
112/// The page inserts a dropped file as this machine's path to it, and a
113/// model copies the paths it is shown. The answer is only a spelling:
114/// it still goes through [`admit`] and [`land`], so the reserved
115/// subtree, the read bound and every link keep their one judgement.
116///
117/// # Errors
118/// `E_GATE_DENIED` when the path really lands outside the city;
119/// whatever [`real_location`] refuses the path or the city root with.
120pub(crate) fn within_city<'a>(
121    city_root: &Path,
122    asked: &'a str,
123    action: &'static str,
124) -> Result<Cow<'a, str>, AxError> {
125    let written = Path::new(asked);
126    if !written.is_absolute() {
127        return Ok(Cow::Borrowed(asked));
128    }
129    let root = real_location(city_root, action, asked)?.into_path();
130    let located = real_location(written, action, asked)?;
131    let outside = || {
132        AxError::failure(
133            AxCode::GateDenied,
134            action,
135            format!("{asked} is outside the city"),
136        )
137        .with_recovery(
138            "`read`, `search` and `edit` reach only files inside the city; to read a file \
139             elsewhere on this machine, run a command with `exec`",
140        )
141    };
142    spelled_under(&root, located.path())
143        .map(Cow::Owned)
144        .ok_or_else(outside)
145}
146
147/// The city spelling of `real` under the city's real root: its segments
148/// joined by `/`. `None` when it is not under the root, or when a
149/// segment is not text and so names no address.
150fn spelled_under(root: &Path, real: &Path) -> Option<String> {
151    real.strip_prefix(root)
152        .ok()?
153        .iter()
154        .map(std::ffi::OsStr::to_str)
155        .collect::<Option<Vec<&str>>>()
156        .map(|segments| segments.join("/"))
157}
158
159/// Where an admitted address really lands on disk, judged again there.
160///
161/// The grammar judged the address the model wrote, and the disk opens
162/// whatever every link on the way leads to: a link in an open building
163/// can point into a confidential one, into a reserved subtree, or out of
164/// the city. So the real path is resolved and the address it names in
165/// this city goes through [`admit`] a second time; the one judgement
166/// stays the only one. A path that does not exist is judged at the
167/// place [`real_location`] puts it and comes back [`Located::Absent`],
168/// which no caller opens.
169///
170/// # Errors
171/// `E_GATE_DENIED` when the real path is outside the city or [`admit`]
172/// refuses the address it names; `E_STORAGE_FATAL` when the path exists
173/// and its real location cannot be resolved.
174pub(crate) fn land(
175    city_root: &Path,
176    addr: &Address,
177    action: &'static str,
178    bound: &dyn Fn(&Address) -> ReadVerdict,
179) -> Result<Located, AxError> {
180    let written = addr
181        .as_str()
182        .split('/')
183        .fold(city_root.to_path_buf(), |path, segment| path.join(segment));
184    let located = real_location(&written, action, addr.as_str())?;
185    let root = real_location(city_root, action, addr.as_str())?.into_path();
186    let outside = || {
187        AxError::failure(
188            AxCode::GateDenied,
189            action,
190            format!("{} leads out of the city through a link", addr.as_str()),
191        )
192        .with_recovery("name a path whose every link stays inside the city")
193    };
194    let inside = spelled_under(&root, located.path()).ok_or_else(outside)?;
195    // A path with no link on it names the address already admitted, and
196    // asking the read bound twice would read a building's rules twice.
197    if inside != addr.as_str() {
198        admit(&inside, action, bound)?;
199    }
200    Ok(located)
201}
202
203/// Whether the file `opened` at the judged real location `judged` is
204/// still the file judged there: the location must still resolve to
205/// itself, so no link was swapped onto it after the judgement, and the
206/// file at it now must be the one opened, so no link was there during
207/// the open and gone again before the resolution. The refusal does not
208/// say where a swapped link leads. Every caller that opens what [`land`]
209/// answered asks this before reading a byte.
210///
211/// # Errors
212/// `E_GATE_DENIED`, naming `action`, when either no longer holds.
213pub(crate) fn still_judged(
214    asked: &str,
215    judged: &Path,
216    opened: &Handle,
217    action: &'static str,
218) -> Result<(), AxError> {
219    let changed = |why: &str| {
220        AxError::failure(
221            AxCode::GateDenied,
222            action,
223            format!("{asked} changed after it was judged: {why}"),
224        )
225        .with_recovery("ask again once nothing is moving the directories on its path")
226    };
227    match std::fs::canonicalize(judged) {
228        Ok(real) if real == judged => {}
229        Ok(_) => return Err(changed("a link now stands on its path")),
230        Err(err) => return Err(changed(&err.to_string())),
231    }
232    match Handle::from_path(judged) {
233        Ok(now) if now == *opened => Ok(()),
234        Ok(_) => Err(changed("another file stands there now")),
235        Err(err) => Err(changed(&err.to_string())),
236    }
237}
238
239/// Where a path really lands, and whether the disk had anything there
240/// when it was asked.
241pub(crate) enum Located {
242    /// Every segment exists: the path with every link on it resolved.
243    Present(PathBuf),
244    /// The deepest existing ancestor resolved, the segments below it
245    /// appended as written. Nothing was there to judge, so a caller
246    /// reports the miss and never opens it: a link placed at an absent
247    /// segment after this answer would lead the open wherever it points.
248    Absent(PathBuf),
249}
250
251impl Located {
252    pub(crate) fn path(&self) -> &Path {
253        match self {
254            Located::Present(path) | Located::Absent(path) => path,
255        }
256    }
257
258    pub(crate) fn into_path(self) -> PathBuf {
259        match self {
260            Located::Present(path) | Located::Absent(path) => path,
261        }
262    }
263}
264
265/// Where `written` really lands, every link on it resolved, whether or
266/// not its last segments exist.
267///
268/// The disk resolves the deepest ancestor that exists; the segments
269/// below it exist nowhere, so no link hides in them, and they are
270/// appended as written, and the answer says so: [`Located::Absent`]. An
271/// absent file behind a link is therefore judged under the link's
272/// target, as a present one is, and never at the spelling the link
273/// stood on. Every caller that judges where a path lands asks this
274/// function, so an absent file has one answer.
275///
276/// # Errors
277/// `E_GATE_DENIED` when a segment below that ancestor is not a plain
278/// name: appended to a resolved directory, a `..` steps back out of it
279/// at a place the disk never looked. `E_STORAGE_FATAL` when an entry on
280/// the path exists and does not resolve, a link whose target is gone
281/// among them.
282pub(crate) fn real_location(
283    written: &Path,
284    action: &'static str,
285    subject: &str,
286) -> Result<Located, AxError> {
287    let unresolved = |err: std::io::Error| {
288        AxError::failure(
289            AxCode::StorageFatal,
290            action,
291            format!("{subject}: its real location did not resolve ({err})"),
292        )
293        .with_recovery("the User has to repair the path or the link on it")
294    };
295    for existing in written.ancestors() {
296        match std::fs::canonicalize(existing) {
297            Ok(real) if existing == written => return Ok(Located::Present(real)),
298            Ok(real) => {
299                return written
300                    .components()
301                    .skip(existing.components().count())
302                    .try_fold(real, |path, segment| match segment {
303                        Component::Normal(name) => Ok(path.join(name)),
304                        Component::Prefix(_)
305                        | Component::RootDir
306                        | Component::CurDir
307                        | Component::ParentDir => Err(AxError::failure(
308                            AxCode::GateDenied,
309                            action,
310                            format!("{subject} climbs out of the directory it names"),
311                        )
312                        .with_recovery("name a path made of plain segments")),
313                    })
314                    .map(Located::Absent);
315            }
316            Err(err) if err.kind() == ErrorKind::NotFound && no_entry(existing) => {}
317            Err(err) => return Err(unresolved(err)),
318        }
319    }
320    Err(unresolved(ErrorKind::NotFound.into()))
321}
322
323/// Whether nothing at all is at `path`, not even a link whose target is
324/// gone: only such a segment may be appended unresolved.
325fn no_entry(path: &Path) -> bool {
326    matches!(std::fs::symlink_metadata(path), Err(err) if err.kind() == ErrorKind::NotFound)
327}
328
329/// What a walk may do at one of its entries.
330pub(crate) enum Walked {
331    /// A directory reached without a link: its entries are walked.
332    Directory(PathBuf),
333    /// A file, or the file an admitted link lands on: it is scanned.
334    File(PathBuf),
335    /// A link [`land`] refuses at the gate, or a link to a directory,
336    /// which is not entered because a walk through links can come back
337    /// to where it started. Passed over without a word, as a closed
338    /// building is.
339    Passed,
340}
341
342/// Judges one entry of a walk, asking the disk once about an entry that
343/// is not a link. An entry the disk will not describe is handed on as a
344/// file, so the open that follows names why it could not be looked at.
345///
346/// # Errors
347/// Whatever [`land`] refuses a link with other than `E_GATE_DENIED`:
348/// a link whose real location does not resolve is a place the walk
349/// could not look, not a closed building, and the walker reports it.
350pub(crate) fn walked(
351    city_root: &Path,
352    path: PathBuf,
353    rel: &str,
354    bound: &dyn Fn(&Address) -> ReadVerdict,
355) -> Result<Walked, AxError> {
356    let Ok(kind) = std::fs::symlink_metadata(&path).map(|meta| meta.file_type()) else {
357        return Ok(Walked::File(path));
358    };
359    if kind.is_dir() {
360        return Ok(Walked::Directory(path));
361    }
362    if !kind.is_symlink() {
363        return Ok(Walked::File(path));
364    }
365    match Address::parse(rel).and_then(|addr| land(city_root, &addr, "search", bound)) {
366        Ok(Located::Present(real)) if real.is_file() => Ok(Walked::File(real)),
367        Ok(Located::Present(_) | Located::Absent(_)) => Ok(Walked::Passed),
368        Err(refused) if refused.code() == &AxCode::GateDenied => Ok(Walked::Passed),
369        Err(unresolved) => Err(unresolved),
370    }
371}
372
373/// Places a link at `link` leading to the directory `target`, on a
374/// machine that has no symlink privilege: Windows gets a junction, which
375/// `symlink_metadata` reports through the same name-surrogate predicate
376/// a symlink is reported by.
377#[cfg(all(test, windows))]
378#[expect(clippy::unwrap_used, reason = "test fixture")]
379#[expect(clippy::disallowed_methods, reason = "test fixture (child D4)")]
380pub(super) fn make_link(link: &std::path::Path, target: &std::path::Path) {
381    let made = std::process::Command::new("cmd")
382        .args(["/c", "mklink", "/J"])
383        .arg(link)
384        .arg(target)
385        .output()
386        .unwrap();
387    assert!(
388        made.status.success(),
389        "the fixture could not make a link: {}",
390        String::from_utf8_lossy(&made.stdout)
391    );
392    let meta = std::fs::symlink_metadata(link).unwrap();
393    assert!(meta.file_type().is_symlink(), "{link:?} is not a link");
394}
395
396#[cfg(all(test, unix))]
397#[expect(clippy::unwrap_used, reason = "test fixture")]
398pub(super) fn make_link(link: &std::path::Path, target: &std::path::Path) {
399    std::os::unix::fs::symlink(target, link).unwrap();
400}
401
402#[cfg(test)]
403#[allow(
404    clippy::unwrap_used,
405    clippy::expect_used,
406    clippy::panic,
407    clippy::indexing_slicing,
408    reason = "test code"
409)]
410mod tests {
411    use super::*;
412
413    /// A bound under which every address is open, so a test about the
414    /// grammar or the reserved subtree judges nothing else.
415    fn open(_: &Address) -> ReadVerdict {
416        ReadVerdict::Open
417    }
418
419    #[test]
420    fn a_reserved_subtree_is_closed_at_every_depth() {
421        for asked in [
422            ".sprawling",
423            ".sprawling/ledger/0001.jsonl",
424            "lab/.sprawling/RULES.toml",
425            "lab/room1/.sprawling/notes/one.md",
426        ] {
427            let err = admit(asked, "read", &open).unwrap_err();
428            assert_eq!(err.code(), &AxCode::GateDenied, "{asked} was allowed");
429            assert!(
430                !err.recovery().is_empty(),
431                "{asked} refused with no way out"
432            );
433        }
434    }
435
436    #[test]
437    fn a_path_that_is_not_an_address_is_the_callers_mistake() {
438        for asked in ["", "/etc/passwd", "lab/../.sprawling/CONFIG.toml", "lab//x"] {
439            let err = admit(asked, "search", &open).unwrap_err();
440            assert_eq!(err.code(), &AxCode::InvalidArgs, "{asked} was allowed");
441            assert_eq!(err.action(), "search");
442        }
443    }
444
445    /// A dropped file reaches the conversation as this machine's path
446    /// to it, and it lies inside the city: the model that copies it is
447    /// asking for that file's address, in either separator.
448    #[test]
449    fn an_absolute_path_inside_the_city_comes_back_as_its_address() {
450        let city = tempfile::tempdir().unwrap();
451        std::fs::create_dir_all(city.path().join("hall").join("dropped")).unwrap();
452        std::fs::write(
453            city.path().join("hall").join("dropped").join("plan.md"),
454            "x",
455        )
456        .unwrap();
457        let native = city.path().join("hall").join("dropped").join("plan.md");
458        let native = native.to_str().unwrap();
459        let forward = native.replace('\\', "/");
460        let spelled = [native, forward.as_str()].map(|asked| {
461            within_city(city.path(), asked, "read")
462                .unwrap()
463                .into_owned()
464        });
465        assert_eq!(spelled, ["hall/dropped/plan.md", "hall/dropped/plan.md"]);
466    }
467
468    #[test]
469    fn an_absolute_path_outside_the_city_is_refused_toward_exec() {
470        let city = tempfile::tempdir().unwrap();
471        let elsewhere = tempfile::tempdir().unwrap();
472        let asked = elsewhere.path().join("notes.md");
473        let err = within_city(city.path(), asked.to_str().unwrap(), "read").unwrap_err();
474        assert_eq!(
475            (err.code(), err.recovery().contains("`exec`")),
476            (&AxCode::GateDenied, true),
477            "{err:?}"
478        );
479    }
480
481    #[test]
482    fn an_ordinary_path_comes_back_as_the_address_it_names() {
483        let addr = admit("lab/room1/Memo.md", "read", &open).unwrap();
484        assert_eq!(addr.as_str(), "lab/room1/Memo.md");
485    }
486
487    /// Both ways the read bound closes come back as the gate's refusal,
488    /// each with its own next step: a confidential building is asked
489    /// through a resident, and rules that do not read wait for a person.
490    #[test]
491    fn a_building_the_read_bound_closes_is_refused_with_its_own_way_out() {
492        let unread = AxError::failure(AxCode::ConfigInvalid, "read a building's rules", "annex")
493            .with_recovery("fix the file");
494        for (verdict, says) in [
495            (ReadVerdict::Confidential, "signal a resident"),
496            (ReadVerdict::RulesUnreadable(unread), "the User has to fix"),
497        ] {
498            let err = admit("vault/room1/notes.md", "read", &|_| verdict.clone()).unwrap_err();
499            assert_eq!(err.code(), &AxCode::GateDenied);
500            assert!(err.recovery().contains(says), "{}", err.recovery());
501        }
502    }
503}