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::paths;
15use crate::programs::NoResolver;
16
17/// Resolves symlinks in normalised, slash-separated absolute paths.
18pub trait PathResolver {
19    /// The path `path` really refers to, or `None` when it is the same path or
20    /// cannot be resolved. Paths that do not exist yet resolve through their
21    /// deepest existing ancestor, so a new file written into a symlinked
22    /// directory still resolves to where it would land.
23    fn resolve(&self, path: &str) -> Option<String>;
24
25    /// The names in the directory `dir` (a normalised absolute path), without
26    /// `.` and `..`; empty when it is not a directory or cannot be read. Used
27    /// only to expand an unquoted glob operand as the shell would
28    /// (`crate::expand`). Without a filesystem nothing is listed, and the
29    /// operand stays literal, as a glob that matches nothing does in `bash`.
30    fn read_dir(&self, _dir: &str) -> Vec<String> {
31        Vec::new()
32    }
33}
34
35impl PathResolver for NoResolver {
36    fn resolve(&self, _path: &str) -> Option<String> {
37        None
38    }
39}
40
41/// Where the OS lands for an absolute path that kept its `..` segments
42/// ([`crate::paths::traversal`]): before each `..` the path so far is resolved,
43/// so `..` steps up from the directory a symlink points to, not from the
44/// link's name. `/p/c/../.ssh/id_rsa` with `c` → `/h/.config` is
45/// `/h/.ssh/id_rsa`. Without a filesystem it is the lexical collapse.
46#[must_use]
47pub fn physical(path: &str, resolver: &dyn PathResolver) -> String {
48    let (root, rest) = paths::split_root(path);
49    let mut here = root.clone();
50    for seg in rest.split('/') {
51        match seg {
52            "" | "." => {}
53            ".." => {
54                if let Some(real) = resolver.resolve(&here) {
55                    here = paths::collapse(&real);
56                }
57                here = parent(&here, &root);
58            }
59            name => {
60                if !here.ends_with('/') {
61                    here.push('/');
62                }
63                here.push_str(name);
64            }
65        }
66    }
67    here
68}
69
70/// `path` without its last segment, never above `root`.
71fn parent(path: &str, root: &str) -> String {
72    match path.rfind('/') {
73        Some(cut) if cut >= root.len() => path[..cut].to_owned(),
74        _ => root.to_owned(),
75    }
76}
77
78/// In-memory resolver for tests and conformance fixtures: symlink path →
79/// target, and the files that exist. The longest link that is the path itself
80/// or one of its ancestors is replaced by its target.
81#[derive(Debug, Default, Clone)]
82pub struct MapPathResolver {
83    /// Symlink path → target.
84    pub links: BTreeMap<String, String>,
85    /// Absolute paths of existing files; they and their ancestor directories
86    /// are what [`PathResolver::read_dir`] lists.
87    pub files: Vec<String>,
88}
89
90impl PathResolver for MapPathResolver {
91    /// Lists `dir` where its links lead.
92    fn read_dir(&self, dir: &str) -> Vec<String> {
93        let real = self.resolve(dir).unwrap_or_else(|| dir.to_owned());
94        let prefix = format!("{}/", real.trim_end_matches('/'));
95        let mut names: Vec<String> = self
96            .files
97            .iter()
98            .filter_map(|f| f.strip_prefix(&prefix)?.split('/').next())
99            .map(str::to_owned)
100            .collect();
101        names.sort();
102        names.dedup();
103        names
104    }
105
106    fn resolve(&self, path: &str) -> Option<String> {
107        self.links
108            .iter()
109            .filter_map(|(link, target)| {
110                let rest = path.strip_prefix(link.as_str())?;
111                (rest.is_empty() || rest.starts_with('/')).then_some((link.len(), target, rest))
112            })
113            .max_by_key(|(len, _, _)| *len)
114            .map(|(_, target, rest)| format!("{target}{rest}"))
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn map_resolver_replaces_the_longest_linked_ancestor() {
124        let r = MapPathResolver {
125            links: BTreeMap::from([
126                ("/p/s".to_owned(), "/h/.ssh".to_owned()),
127                ("/p/s/k".to_owned(), "/h/.aws/credentials".to_owned()),
128            ]),
129            ..MapPathResolver::default()
130        };
131        assert_eq!(r.resolve("/p/s").as_deref(), Some("/h/.ssh"));
132        assert_eq!(r.resolve("/p/s/id_rsa").as_deref(), Some("/h/.ssh/id_rsa"));
133        assert_eq!(r.resolve("/p/s/k").as_deref(), Some("/h/.aws/credentials"));
134        assert_eq!(r.resolve("/p/sx/id_rsa"), None);
135        assert_eq!(r.resolve("/p/src/main.rs"), None);
136        assert_eq!(NoResolver.resolve("/p/s"), None);
137    }
138
139    #[test]
140    fn physical_steps_up_from_where_a_link_points() {
141        let r = MapPathResolver {
142            links: BTreeMap::from([("/p/c".to_owned(), "/h/.config".to_owned())]),
143            ..MapPathResolver::default()
144        };
145        assert_eq!(physical("/p/c/../.ssh/id_rsa", &r), "/h/.ssh/id_rsa");
146        assert_eq!(physical("/p/src/../README.md", &r), "/p/README.md");
147        assert_eq!(physical("/p/c/./x/../y", &r), "/h/.config/y");
148        assert_eq!(physical("/p/../../..", &r), "/");
149        assert_eq!(physical("C:/a/../b", &NoResolver), "C:/b");
150        // without a filesystem it is the lexical collapse
151        assert_eq!(
152            physical("/p/c/../.ssh/id_rsa", &NoResolver),
153            "/p/.ssh/id_rsa"
154        );
155    }
156}