1use 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
39pub type ReadBound = Arc<dyn Fn(&Address) -> ReadVerdict + Send + Sync>;
44
45pub(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; a person has to fix them before anyone \
103 outside reads there",
104 )),
105 }
106}
107
108pub(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
147fn 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
159pub(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 if inside != addr.as_str() {
198 admit(&inside, action, bound)?;
199 }
200 Ok(located)
201}
202
203pub(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
239pub(crate) enum Located {
242 Present(PathBuf),
244 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
265pub(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("a person 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
323fn no_entry(path: &Path) -> bool {
326 matches!(std::fs::symlink_metadata(path), Err(err) if err.kind() == ErrorKind::NotFound)
327}
328
329pub(crate) enum Walked {
331 Directory(PathBuf),
333 File(PathBuf),
335 Passed,
340}
341
342pub(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#[cfg(all(test, windows))]
378#[expect(clippy::unwrap_used, reason = "test fixture")]
379pub(super) fn make_link(link: &std::path::Path, target: &std::path::Path) {
380 let made = std::process::Command::new("cmd")
381 .args(["/c", "mklink", "/J"])
382 .arg(link)
383 .arg(target)
384 .output()
385 .unwrap();
386 assert!(
387 made.status.success(),
388 "the fixture could not make a link: {}",
389 String::from_utf8_lossy(&made.stdout)
390 );
391 let meta = std::fs::symlink_metadata(link).unwrap();
392 assert!(meta.file_type().is_symlink(), "{link:?} is not a link");
393}
394
395#[cfg(all(test, unix))]
396#[expect(clippy::unwrap_used, reason = "test fixture")]
397pub(super) fn make_link(link: &std::path::Path, target: &std::path::Path) {
398 std::os::unix::fs::symlink(target, link).unwrap();
399}
400
401#[cfg(test)]
402#[allow(
403 clippy::unwrap_used,
404 clippy::expect_used,
405 clippy::panic,
406 clippy::indexing_slicing,
407 reason = "test code"
408)]
409mod tests {
410 use super::*;
411
412 fn open(_: &Address) -> ReadVerdict {
415 ReadVerdict::Open
416 }
417
418 #[test]
419 fn a_reserved_subtree_is_closed_at_every_depth() {
420 for asked in [
421 ".sprawling",
422 ".sprawling/ledger/0001.jsonl",
423 "lab/.sprawling/RULES.toml",
424 "lab/room1/.sprawling/notes/one.md",
425 ] {
426 let err = admit(asked, "read", &open).unwrap_err();
427 assert_eq!(err.code(), &AxCode::GateDenied, "{asked} was allowed");
428 assert!(
429 !err.recovery().is_empty(),
430 "{asked} refused with no way out"
431 );
432 }
433 }
434
435 #[test]
436 fn a_path_that_is_not_an_address_is_the_callers_mistake() {
437 for asked in ["", "/etc/passwd", "lab/../.sprawling/CONFIG.toml", "lab//x"] {
438 let err = admit(asked, "search", &open).unwrap_err();
439 assert_eq!(err.code(), &AxCode::InvalidArgs, "{asked} was allowed");
440 assert_eq!(err.action(), "search");
441 }
442 }
443
444 #[test]
448 fn an_absolute_path_inside_the_city_comes_back_as_its_address() {
449 let city = tempfile::tempdir().unwrap();
450 std::fs::create_dir_all(city.path().join("hall").join("dropped")).unwrap();
451 std::fs::write(
452 city.path().join("hall").join("dropped").join("plan.md"),
453 "x",
454 )
455 .unwrap();
456 let native = city.path().join("hall").join("dropped").join("plan.md");
457 let native = native.to_str().unwrap();
458 let forward = native.replace('\\', "/");
459 let spelled = [native, forward.as_str()].map(|asked| {
460 within_city(city.path(), asked, "read")
461 .unwrap()
462 .into_owned()
463 });
464 assert_eq!(spelled, ["hall/dropped/plan.md", "hall/dropped/plan.md"]);
465 }
466
467 #[test]
468 fn an_absolute_path_outside_the_city_is_refused_toward_exec() {
469 let city = tempfile::tempdir().unwrap();
470 let elsewhere = tempfile::tempdir().unwrap();
471 let asked = elsewhere.path().join("notes.md");
472 let err = within_city(city.path(), asked.to_str().unwrap(), "read").unwrap_err();
473 assert_eq!(
474 (err.code(), err.recovery().contains("`exec`")),
475 (&AxCode::GateDenied, true),
476 "{err:?}"
477 );
478 }
479
480 #[test]
481 fn an_ordinary_path_comes_back_as_the_address_it_names() {
482 let addr = admit("lab/room1/Memo.md", "read", &open).unwrap();
483 assert_eq!(addr.as_str(), "lab/room1/Memo.md");
484 }
485
486 #[test]
490 fn a_building_the_read_bound_closes_is_refused_with_its_own_way_out() {
491 let unread = AxError::failure(AxCode::ConfigInvalid, "read a building's rules", "annex")
492 .with_recovery("fix the file");
493 for (verdict, says) in [
494 (ReadVerdict::Confidential, "signal a resident"),
495 (ReadVerdict::RulesUnreadable(unread), "a person has to fix"),
496 ] {
497 let err = admit("vault/room1/notes.md", "read", &|_| verdict.clone()).unwrap_err();
498 assert_eq!(err.code(), &AxCode::GateDenied);
499 assert!(err.recovery().contains(says), "{}", err.recovery());
500 }
501 }
502}