Skip to main content

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}