Skip to main content

openmoat_core/
paths.rs

1//! Path normalisation without touching the filesystem (docs/POLICY.md §3.2).
2//!
3//! All paths are handled in a slash-separated canonical form on every platform:
4//! `/Users/me/x` on Unix, `C:/Users/me/x` or `//server/share/x` on Windows.
5//! Callers convert OS paths to this form (`\` → `/`) before passing them in.
6//! Symlink resolution needs I/O and is done by the caller through
7//! [`crate::realpath::PathResolver`]; the engine checks both paths.
8
9/// Expand `~`, `$HOME`, `${project}` and make the path absolute against `cwd`,
10/// then collapse `.` and `..` lexically. Without a project, `${project}` expands
11/// to nothing, as an unset shell variable does.
12///
13/// A path that depends on a directory nobody knows here (`~-`, the shell's
14/// previous directory) is taken literally, relative to `cwd`; shell words go
15/// through [`resolve`] instead, which refuses it.
16#[must_use]
17pub fn normalise(raw: &str, home: &str, project: Option<&str>, cwd: &str) -> String {
18    // `resolve` returns `None` for a shell-only form (`~-`) or a `~<otheruser>`
19    // path OpenMoat cannot place; keep the raw-under-cwd fallback so a direct
20    // host-side `fs.read` of `~alice/.ssh/id_rsa` still has an atom to check (the
21    // guarded suffix is intact; a rule naming `~alice` matches as written, and
22    // a shell token with it asks through `unparseable` via `resolve`).
23    resolve(raw, home, project, Some(cwd)).unwrap_or_else(|| collapse(&under(cwd, raw)))
24}
25
26/// `rel` below the directory `dir`. A root (`/`, `C:/`) already ends in its
27/// separator: `/` and `x` are `/x`, not `//x`, which is a UNC path naming the
28/// server `x`.
29fn under(dir: &str, rel: &str) -> String {
30    if dir.ends_with('/') {
31        format!("{dir}{rel}")
32    } else {
33        format!("{dir}/{rel}")
34    }
35}
36
37/// `normalise` for a word a shell will expand, against a working directory
38/// that may be unknown (`None`). `None` when the result depends on a directory
39/// that is not known: a relative path and an unknown `cwd`, `~-`, or a `~name`
40/// of another user (OpenMoat cannot know where other users' homes live, so a
41/// cross-user path is unresolvable; a shell token with it asks through
42/// `unparseable`).
43#[must_use]
44pub fn resolve(raw: &str, home: &str, project: Option<&str>, cwd: Option<&str>) -> Option<String> {
45    absolute(raw, home, project, cwd).map(|s| collapse(&s))
46}
47
48/// The absolute form of `raw` with its `..` segments kept, when it has one
49/// (`c/../.ssh/id_rsa` in `/p` is `/p/c/../.ssh/id_rsa`). [`resolve`] collapses
50/// `..` against the name before it, but the OS steps up from where that name
51/// really points: through `ln -s ~/.config c`, `c/..` is the home directory, not
52/// the project. The engine resolves this form with [`crate::realpath::physical`]
53/// and checks where it lands as well as the collapsed path.
54#[must_use]
55pub fn traversal(
56    raw: &str,
57    home: &str,
58    project: Option<&str>,
59    cwd: Option<&str>,
60) -> Option<String> {
61    absolute(raw, home, project, cwd).filter(|s| has_dotdot(s))
62}
63
64/// Whether `path` has a `..` segment.
65#[must_use]
66pub fn has_dotdot(path: &str) -> bool {
67    path.split('/').any(|seg| seg == "..")
68}
69
70/// [`resolve`] before `.` and `..` are collapsed.
71fn absolute(raw: &str, home: &str, project: Option<&str>, cwd: Option<&str>) -> Option<String> {
72    let unified = raw.replace('\\', "/");
73    let expanded = match dir_stack_tilde(&unified) {
74        Some(('+', rest)) => format!("{}{rest}", cwd?),
75        Some(_) => return None,
76        None => expand_home(&unified, home)?,
77    };
78    let mut s = expanded
79        .replace("${project}", project.unwrap_or_default())
80        .replace("${HOME}", home)
81        .replace("$HOME", home);
82    // `$PWD` is the working directory, when it is known
83    if s.contains("$PWD") || s.contains("${PWD}") {
84        let dir = cwd?;
85        s = s.replace("${PWD}", dir).replace("$PWD", dir);
86    }
87    if is_absolute(&s) {
88        Some(s)
89    } else {
90        Some(under(cwd?, &s))
91    }
92}
93
94/// Whether `raw` still names a variable after the ones [`resolve`] expands
95/// in a shell word (`HOME`, `PWD`), or holds the output of a command (`$( … )`,
96/// backticks): the shell replaces it with a value the path itself does not
97/// show, so the path is not known (`cat $d/id_rsa`, `cat $(cat list)`).
98#[must_use]
99pub fn has_unexpanded_variable(raw: &str) -> bool {
100    let is_name = |c: char| c.is_ascii_alphanumeric() || c == '_';
101    if raw.contains("$(") || raw.contains('`') {
102        return true;
103    }
104    raw.match_indices('$').any(|(pos, _)| {
105        let rest = &raw[pos + 1..];
106        let (body, braced) = match rest.strip_prefix('{') {
107            Some(b) => (b, true),
108            None => (rest, false),
109        };
110        let end = body.find(|c: char| !is_name(c)).unwrap_or(body.len());
111        let name = &body[..end];
112        if name.is_empty() || !name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
113            // `$1`, `$$`, `$?` and a lone `$` are positional or special: unknown
114            // too, except a bare `$` that starts no expansion (`a$`, `$/`)
115            return braced
116                || body.starts_with(|c: char| c.is_ascii_digit() || "@*#?$!-".contains(c));
117        }
118        // `${project}` is the policy's syntax; in a shell word it is a variable
119        let known = matches!(name, "HOME" | "PWD");
120        !(known && (!braced || body[end..].starts_with('}')))
121    })
122}
123
124/// `~+` (the working directory) and `~-` (the previous one), alone or before
125/// `/`: the sign and the rest of the word.
126fn dir_stack_tilde(raw: &str) -> Option<(char, &str)> {
127    let rest = raw.strip_prefix('~')?;
128    let sign = rest.chars().next().filter(|c| matches!(c, '+' | '-'))?;
129    let rest = &rest[1..];
130    (rest.is_empty() || rest.starts_with('/')).then_some((sign, rest))
131}
132
133/// Absolute in canonical form: `/…`, `X:/…`, or UNC `//…`.
134#[must_use]
135pub fn is_absolute(path: &str) -> bool {
136    path.starts_with('/') || drive_prefix(path).is_some()
137}
138
139/// `C:` for `C:/x`, `C:x` or `c:\x`; `None` otherwise.
140fn drive_prefix(path: &str) -> Option<&str> {
141    let bytes = path.as_bytes();
142    (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':').then(|| &path[..2])
143}
144
145/// Lexically collapse `//`, `/./` and `/../` segments while preserving the root
146/// (`/`, `C:/` or `//server`).
147#[must_use]
148pub fn collapse(path: &str) -> String {
149    let (root, rest) = split_root(path);
150    let mut out: Vec<&str> = Vec::new();
151    for seg in rest.split('/') {
152        match seg {
153            "" | "." => {}
154            ".." => {
155                out.pop();
156            }
157            s => out.push(s),
158        }
159    }
160    format!("{root}{}", out.join("/"))
161}
162
163/// The root of a canonical path (`/`, `C:/` or `//server/`) and the rest.
164pub(crate) fn split_root(path: &str) -> (String, &str) {
165    if let Some(drive) = drive_prefix(path) {
166        let rest = &path[2..];
167        let root = format!("{}:/", drive[..1].to_ascii_uppercase());
168        return (root, rest.trim_start_matches('/'));
169    }
170    if let Some(unc) = path.strip_prefix("//") {
171        let (server, rest) = unc.split_once('/').unwrap_or((unc, ""));
172        return (format!("//{server}/"), rest);
173    }
174    ("/".to_owned(), path.trim_start_matches('/'))
175}
176
177/// Expand `~` and `${project}` inside a *pattern* (not a path), leaving globs intact.
178///
179/// `None` when the pattern names `${project}` and there is no project: such a
180/// pattern names no location, so it can match nothing (and exclude nothing).
181/// A project whose path holds a glob character (`*`, `?`, `[`, `]`, `{`, `}`)
182/// is treated the same way: spliced into a pattern it would match far more
183/// than the project (a directory named `*` in the home would make
184/// `${project}/**` the whole home), so its rules name nothing and its files
185/// are decided by the rest of the policy.
186#[must_use]
187pub fn expand_pattern(raw: &str, home: &str, project: Option<&str>) -> Option<String> {
188    let project = project.filter(|p| !p.contains(['*', '?', '[', ']', '{', '}']));
189    let (negated, body) = crate::pattern::split_negation(raw);
190    // A pattern naming another user's home (`~alice/.ssh/**`) stays literal:
191    // paths are normalised through `resolve`, which refuses `~<otheruser>`
192    // (#400), so such a pattern names no reachable path. Keep the raw text so
193    // the pattern is still a well-formed glob and the lint still sees it.
194    let body = expand_home(body, home).unwrap_or_else(|| body.to_owned());
195    let expanded = match project {
196        Some(project) => body.replace("${project}", project),
197        None if body.contains("${project}") => return None,
198        None => body,
199    };
200    Some(if negated {
201        format!("!{expanded}")
202    } else {
203        expanded
204    })
205}
206
207/// Expand a leading `~` or `~name`, as a POSIX shell does — safely.
208///
209/// `~` is `home`. `~name` is `home` when `name` is the last component of
210/// `home` (the current user). Any other `~name` is unresolvable: homes do not
211/// always sit side by side (`/root`, `/srv/<name>`, networked homes), so a
212/// guessed `/<parent-of-home>/<name>` can judge one path while the OS reads
213/// another (#400). Return `None` for that case; the caller maps it to `ask`
214/// through `unparseable`. A leading `~` that is not followed by a valid login
215/// name (`~a$b`) stays literal (`Some(raw)`).
216fn expand_home(raw: &str, home: &str) -> Option<String> {
217    let Some((name, tail)) = home_prefix(raw) else {
218        return Some(raw.to_owned());
219    };
220    let (_parent, user) = home.rsplit_once('/').unwrap_or(("", home));
221    if name.is_empty() || name == user {
222        Some(format!("{home}{tail}"))
223    } else {
224        // Another user's home — unresolvable from inside the pure core.
225        None
226    }
227}
228
229/// Whether `raw` starts with `~name` of a user other than the one `home` belongs to.
230pub(crate) fn other_user_home(raw: &str, home: &str) -> bool {
231    let user = home.rsplit_once('/').map_or(home, |(_, user)| user);
232    home_prefix(raw).is_some_and(|(name, _)| !name.is_empty() && name != user)
233}
234
235/// `~` or `~name` followed by nothing or `/…`: the name (empty for `~`) and
236/// the rest of the word.
237fn home_prefix(raw: &str) -> Option<(&str, &str)> {
238    let rest = raw.strip_prefix('~')?;
239    let (name, tail) = rest.split_at(rest.find('/').unwrap_or(rest.len()));
240    let login = name
241        .chars()
242        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
243    (login && !name.starts_with('-')).then_some((name, tail))
244}
245
246/// Heuristic: does this shell token look like a filesystem path?
247///
248/// Besides the obvious prefixes, a token counts as a path when it traverses
249/// (`src/../x`, `a/..`) or names a hidden entry (`.env`, `config/.env.local`,
250/// `.ssh/id_rsa`): those are the relative forms that reach secrets, and a
251/// bare `cat .env` must produce a file-read atom. Plain `a/b` tokens do not
252/// qualify, so branch names, MIME types and `owner/repo` stay unclassified.
253#[must_use]
254pub fn looks_like_path(token: &str) -> bool {
255    if token.contains("://") {
256        return false;
257    }
258    token.starts_with('/')
259        || home_prefix(token).is_some()
260        || dir_stack_tilde(token).is_some()
261        || token.starts_with("./")
262        || token.starts_with("../")
263        || token.starts_with(".\\")
264        || token.starts_with("..\\")
265        || token.starts_with("$HOME/")
266        || token.starts_with("${HOME}/")
267        || token.starts_with("${project}/")
268        || drive_prefix(token)
269            .is_some_and(|_| matches!(token.as_bytes().get(2), Some(b'/' | b'\\')))
270        || token.starts_with("\\\\")
271        || traverses(token)
272        || names_hidden_entry(token)
273}
274
275fn traverses(token: &str) -> bool {
276    token == ".."
277        || token
278            .split(['/', '\\'])
279            .enumerate()
280            .any(|(i, seg)| seg == ".." && (i > 0 || token.len() > 2))
281}
282
283/// A segment such as `.env` or `.ssh` (not `.`, `..`, or a number like `.5`).
284fn names_hidden_entry(token: &str) -> bool {
285    token.split(['/', '\\']).any(|seg| {
286        seg.len() > 1
287            && seg != ".."
288            && seg.starts_with('.')
289            && !seg[1..].chars().all(|c| c.is_ascii_digit())
290    })
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn normalises_unix_paths() {
299        assert_eq!(
300            normalise("~/.ssh/id_rsa", "/Users/me", Some("/p"), "/p"),
301            "/Users/me/.ssh/id_rsa"
302        );
303        assert_eq!(
304            normalise("src/../.env", "/Users/me", Some("/p"), "/p/app"),
305            "/p/app/.env"
306        );
307        assert_eq!(normalise("${project}/x", "/h", Some("/p"), "/c"), "/p/x");
308        assert_eq!(
309            normalise("$HOME/.aws/credentials", "/h", Some("/p"), "/c"),
310            "/h/.aws/credentials"
311        );
312        // from the root, a relative path is below `/`, not a `//server` path
313        assert_eq!(
314            normalise("Users/me/.ssh/id_rsa", "/Users/me", None, "/"),
315            "/Users/me/.ssh/id_rsa"
316        );
317        assert_eq!(
318            resolve("./etc/x", "/h", None, Some("/")).as_deref(),
319            Some("/etc/x")
320        );
321        assert_eq!(normalise("x", "/h", None, "C:/"), "C:/x");
322        assert_eq!(collapse("/a//b/./c/../d"), "/a/b/d");
323        assert_eq!(collapse("/../x"), "/x");
324    }
325
326    #[test]
327    fn project_patterns_match_nothing_without_a_project() {
328        assert_eq!(
329            expand_pattern("!${project}/.git/**", "/h", Some("/p")).as_deref(),
330            Some("!/p/.git/**")
331        );
332        assert_eq!(expand_pattern("${project}/**", "/h", None), None);
333        assert_eq!(
334            expand_pattern("~/.ssh/**", "/h", None).as_deref(),
335            Some("/h/.ssh/**")
336        );
337        assert_eq!(normalise("${project}/x", "/h", None, "/c"), "/x");
338    }
339
340    #[test]
341    fn tilde_names_a_home_directory() {
342        let n = |raw: &str| normalise(raw, "/Users/me", Some("/p"), "/p/app");
343        assert_eq!(n("~me/.ssh/id_rsa"), "/Users/me/.ssh/id_rsa");
344        assert_eq!(n("~me"), "/Users/me");
345        // `~<otheruser>` is unresolvable from the pure core (#400): the parent-of-
346        // home heuristic assumed every home sits next to the current user's, which
347        // is false on common layouts. `normalise` falls back to the literal word
348        // under `cwd`; the shell path (`resolve`) returns `None` and the shell
349        // token asks through `unparseable`.
350        assert_eq!(n("~alice/.ssh/id_rsa"), "/p/app/~alice/.ssh/id_rsa");
351        assert_eq!(n("~+/x"), "/p/app/x");
352        assert_eq!(n("~+"), "/p/app");
353        assert_eq!(n("a/~me"), "/p/app/a/~me", "only a leading tilde expands");
354        assert_eq!(n("~a$b/x"), "/p/app/~a$b/x", "not a login name");
355        assert_eq!(
356            normalise("~bob/x", "C:/Users/me", None, "C:/p"),
357            "C:/p/~bob/x"
358        );
359        let r = |raw: &str| resolve(raw, "/Users/me", Some("/p"), Some("/p/app"));
360        assert_eq!(
361            r("~-/.ssh/id_rsa"),
362            None,
363            "the previous directory is unknown"
364        );
365        assert_eq!(r("~-"), None);
366        assert_eq!(r("~-x/y").as_deref(), Some("/p/app/~-x/y"));
367        assert_eq!(
368            r("~alice/.ssh/id_rsa"),
369            None,
370            "another user's home is unresolvable (#400)"
371        );
372        assert_eq!(r("~alice"), None);
373        assert_eq!(resolve("src", "/h", None, None), None);
374        assert_eq!(
375            resolve("/etc/hosts", "/h", None, None).as_deref(),
376            Some("/etc/hosts")
377        );
378        assert_eq!(resolve("~+/x", "/h", None, None), None);
379        for token in ["~me/.ssh/id_rsa", "~alice", "~+/x", "~-", "~"] {
380            assert!(looks_like_path(token), "{token}");
381        }
382        for token in ["~a$b", "x~y"] {
383            assert!(!looks_like_path(token), "{token}");
384        }
385    }
386
387    #[test]
388    fn normalises_windows_paths() {
389        let home = "C:/Users/me";
390        assert_eq!(
391            normalise("~/.ssh/id_rsa", home, Some("C:/p"), "C:/p"),
392            "C:/Users/me/.ssh/id_rsa"
393        );
394        assert_eq!(
395            normalise(r"C:\p\src\main.rs", home, Some("C:/p"), "C:/p"),
396            "C:/p/src/main.rs"
397        );
398        assert_eq!(
399            normalise(r"src\lib.rs", home, Some("C:/p"), "C:/p"),
400            "C:/p/src/lib.rs"
401        );
402        assert_eq!(normalise("c:/P/../q", home, Some("C:/p"), "C:/p"), "C:/q");
403        assert_eq!(
404            normalise(r"\\server\share\f.txt", home, Some("C:/p"), "C:/p"),
405            "//server/share/f.txt"
406        );
407        assert!(is_absolute("D:/x") && is_absolute("//srv/s") && !is_absolute("x/y"));
408    }
409
410    #[test]
411    fn path_heuristics() {
412        for token in [
413            "/etc/passwd",
414            "~/.ssh",
415            "./a",
416            "../a",
417            r"C:\x",
418            "D:/y",
419            r".\x",
420            r"\\srv\s",
421        ] {
422            assert!(looks_like_path(token), "{token}");
423        }
424        for token in ["main.rs", "C:", "https://x", "-v", "ab/cd"] {
425            assert!(!looks_like_path(token), "{token}");
426        }
427    }
428
429    #[test]
430    fn relative_forms_that_reach_secrets_are_paths() {
431        for token in [
432            ".env",
433            ".env.production",
434            "config/.env.local",
435            ".ssh/id_rsa",
436            "src/../../.ssh/id_rsa",
437            "a/..",
438            "..",
439            r"src\..\.env",
440        ] {
441            assert!(looks_like_path(token), "{token}");
442        }
443        for token in [
444            "origin/main",
445            "application/json",
446            "owner/repo",
447            ".",
448            ".5",
449            "s/foo/bar/",
450            "https://x/.env",
451            "fix.bug",
452        ] {
453            assert!(!looks_like_path(token), "{token}");
454        }
455    }
456}