openmoat_core/realpath.rs
1//! Symlink resolution of action paths, supplied by the caller.
2//!
3//! Policy patterns name locations (`~/.ssh/**`, `${project}/**`), but a path an
4//! agent writes can reach a location through a symlink: after
5//! `ln -s ~/.ssh ./s`, `cat ./s/id_rsa` reads the private key while its literal
6//! path is inside the project. The core cannot look at the filesystem, so the
7//! caller resolves each normalised path and the engine checks the literal and
8//! the resolved path; the strictest verdict wins (ADR-009). The same caller
9//! lists directories, so a glob operand is checked as the files it names
10//! (`crate::expand`).
11
12use std::collections::BTreeMap;
13
14use crate::programs::NoResolver;
15
16/// Resolves symlinks in normalised, slash-separated absolute paths.
17pub trait PathResolver {
18 /// The path `path` really refers to, or `None` when it is the same path or
19 /// cannot be resolved. Paths that do not exist yet resolve through their
20 /// deepest existing ancestor, so a new file written into a symlinked
21 /// directory still resolves to where it would land.
22 fn resolve(&self, path: &str) -> Option<String>;
23
24 /// The names in the directory `dir` (a normalised absolute path), without
25 /// `.` and `..`; empty when it is not a directory or cannot be read. Used
26 /// only to expand an unquoted glob operand as the shell would
27 /// (`crate::expand`). Without a filesystem nothing is listed, and the
28 /// operand stays literal, as a glob that matches nothing does in `bash`.
29 fn read_dir(&self, _dir: &str) -> Vec<String> {
30 Vec::new()
31 }
32}
33
34impl PathResolver for NoResolver {
35 fn resolve(&self, _path: &str) -> Option<String> {
36 None
37 }
38}
39
40/// In-memory resolver for tests and conformance fixtures: symlink path →
41/// target, and the files that exist. The longest link that is the path itself
42/// or one of its ancestors is replaced by its target.
43#[derive(Debug, Default, Clone)]
44pub struct MapPathResolver {
45 /// Symlink path → target.
46 pub links: BTreeMap<String, String>,
47 /// Absolute paths of existing files; they and their ancestor directories
48 /// are what [`PathResolver::read_dir`] lists.
49 pub files: Vec<String>,
50}
51
52impl PathResolver for MapPathResolver {
53 /// Lists `dir` where its links lead.
54 fn read_dir(&self, dir: &str) -> Vec<String> {
55 let real = self.resolve(dir).unwrap_or_else(|| dir.to_owned());
56 let prefix = format!("{}/", real.trim_end_matches('/'));
57 let mut names: Vec<String> = self
58 .files
59 .iter()
60 .filter_map(|f| f.strip_prefix(&prefix)?.split('/').next())
61 .map(str::to_owned)
62 .collect();
63 names.sort();
64 names.dedup();
65 names
66 }
67
68 fn resolve(&self, path: &str) -> Option<String> {
69 self.links
70 .iter()
71 .filter_map(|(link, target)| {
72 let rest = path.strip_prefix(link.as_str())?;
73 (rest.is_empty() || rest.starts_with('/')).then_some((link.len(), target, rest))
74 })
75 .max_by_key(|(len, _, _)| *len)
76 .map(|(_, target, rest)| format!("{target}{rest}"))
77 }
78}
79
80#[cfg(test)]
81mod tests {
82 use super::*;
83
84 #[test]
85 fn map_resolver_replaces_the_longest_linked_ancestor() {
86 let r = MapPathResolver {
87 links: BTreeMap::from([
88 ("/p/s".to_owned(), "/h/.ssh".to_owned()),
89 ("/p/s/k".to_owned(), "/h/.aws/credentials".to_owned()),
90 ]),
91 ..MapPathResolver::default()
92 };
93 assert_eq!(r.resolve("/p/s").as_deref(), Some("/h/.ssh"));
94 assert_eq!(r.resolve("/p/s/id_rsa").as_deref(), Some("/h/.ssh/id_rsa"));
95 assert_eq!(r.resolve("/p/s/k").as_deref(), Some("/h/.aws/credentials"));
96 assert_eq!(r.resolve("/p/sx/id_rsa"), None);
97 assert_eq!(r.resolve("/p/src/main.rs"), None);
98 assert_eq!(NoResolver.resolve("/p/s"), None);
99 }
100}