Skip to main content

lonca_core/
paths.rs

1//! Repository path validation and the path/symbol pattern language used by
2//! capabilities and policy.
3//!
4//! Patterns are deliberately simple so that "is pattern A contained in
5//! pattern B" is decidable exactly (capabilities may only be narrowed):
6//! - `src/billing/client.ts` matches exactly that path,
7//! - `src/billing/**` matches everything below `src/billing/`,
8//! - `**` matches every path.
9
10use crate::error::{Error, Result};
11
12/// Rejects anything that is not a clean, relative, forward-slash path.
13pub fn validate_repo_path(p: &str) -> Result<()> {
14    let bad = |why: &str| Err(Error::Invalid(format!("path {p:?}: {why}")));
15    if p.is_empty() {
16        return bad("empty");
17    }
18    if p.len() > 4096 {
19        return bad("too long");
20    }
21    if p.starts_with('/') || p.ends_with('/') {
22        return bad("must be relative and name a file");
23    }
24    if p.contains('\\') || p.contains('\0') {
25        return bad("backslash or NUL");
26    }
27    // Bidi controls make a path display differently from what it is.
28    if p.chars().any(|c| matches!(c, '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{200E}' | '\u{200F}')) {
29        return bad("bidirectional control character");
30    }
31    for comp in p.split('/') {
32        if comp.is_empty() || comp == "." || comp == ".." {
33            return bad("empty, '.' or '..' component");
34        }
35        if comp.eq_ignore_ascii_case(".git") {
36            return bad("'.git' component");
37        }
38    }
39    Ok(())
40}
41
42/// Is `p` under a workspace's `.lonca/`? Case is folded: on macOS
43/// `.LONCA/session` is that same file.
44pub fn is_workspace_metadata(p: &str) -> bool {
45    let first = p.split('/').next().unwrap_or_default();
46    first.eq_ignore_ascii_case(".lonca")
47}
48
49pub fn validate_path_pattern(p: &str) -> Result<()> {
50    if p == "**" {
51        return Ok(());
52    }
53    match p.strip_suffix("/**") {
54        Some(prefix) => validate_repo_path(prefix),
55        None => {
56            if p.contains('*') {
57                return Err(Error::Invalid(format!(
58                    "pattern {p:?}: '*' is only allowed as a trailing '/**'"
59                )));
60            }
61            validate_repo_path(p)
62        }
63    }
64}
65
66pub fn path_matches(pattern: &str, path: &str) -> bool {
67    if pattern == "**" {
68        return true;
69    }
70    match pattern.strip_suffix("/**") {
71        Some(prefix) => {
72            path.len() > prefix.len()
73                && path.starts_with(prefix)
74                && path.as_bytes().get(prefix.len()) == Some(&b'/')
75        }
76        None => pattern == path,
77    }
78}
79
80/// True when every path matched by `inner` is also matched by `outer`.
81pub fn path_pattern_within(inner: &str, outer: &str) -> bool {
82    if outer == "**" {
83        return true;
84    }
85    if inner == "**" {
86        return false;
87    }
88    match (inner.strip_suffix("/**"), outer.strip_suffix("/**")) {
89        (_, None) => inner == outer,
90        (Some(i), Some(o)) => i == o || path_matches(outer, i),
91        (None, Some(_)) => path_matches(outer, inner),
92    }
93}
94
95/// Symbol patterns: `Name.member` exactly, or a prefix ending in `*`
96/// (`BillingClient.*`, `*`).
97pub fn validate_symbol_pattern(p: &str) -> Result<()> {
98    let body = p.strip_suffix('*').unwrap_or(p);
99    if body.contains('*') || p.is_empty() || p.len() > 512 {
100        return Err(Error::Invalid(format!(
101            "symbol pattern {p:?}: '*' only at the end"
102        )));
103    }
104    Ok(())
105}
106
107pub fn symbol_matches(pattern: &str, symbol: &str) -> bool {
108    match pattern.strip_suffix('*') {
109        Some(prefix) => symbol.starts_with(prefix),
110        None => pattern == symbol,
111    }
112}
113
114pub fn symbol_pattern_within(inner: &str, outer: &str) -> bool {
115    match (inner.strip_suffix('*'), outer.strip_suffix('*')) {
116        (_, None) => inner == outer,
117        (Some(i), Some(o)) => i.starts_with(o),
118        (None, Some(o)) => inner.starts_with(o),
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn workspace_metadata_is_the_first_component_in_any_case() {
128        for p in [".lonca", ".lonca/session", ".LONCA/session", ".Lonca/a/b"] {
129            assert!(is_workspace_metadata(p), "{p}");
130        }
131        for p in ["src/.lonca/x", ".lonca2/x", "lonca/x", ""] {
132            assert!(!is_workspace_metadata(p), "{p}");
133        }
134    }
135
136    #[test]
137    fn path_validation() {
138        assert!(validate_repo_path("src/a.rs").is_ok());
139        for bad in [
140            "",
141            "/etc/passwd",
142            "a/../b",
143            "a//b",
144            ".git/config",
145            "a/.GIT/x",
146            "a\\b",
147            "dir/",
148            "a/\u{202E}b",
149        ] {
150            assert!(
151                validate_repo_path(bad).is_err(),
152                "{bad:?} should be rejected"
153            );
154        }
155    }
156
157    #[test]
158    fn a_path_may_be_4096_bytes_long() {
159        assert!(validate_repo_path(&"a".repeat(4096)).is_ok());
160        assert!(validate_repo_path(&"a".repeat(4097)).is_err());
161        let deep = format!("{}/b", "a".repeat(4094));
162        assert_eq!(deep.len(), 4096);
163        assert!(validate_repo_path(&deep).is_ok());
164    }
165
166    #[test]
167    fn path_patterns() {
168        assert!(path_matches("src/**", "src/a.rs"));
169        assert!(path_matches("src/**", "src/x/y.rs"));
170        assert!(!path_matches("src/**", "srcx/a.rs"));
171        assert!(!path_matches("src/**", "src"));
172        assert!(path_matches("src/a.rs", "src/a.rs"));
173        assert!(path_matches("**", "anything/at/all"));
174        assert!(validate_path_pattern("src/*.rs").is_err());
175    }
176
177    #[test]
178    fn path_containment() {
179        assert!(path_pattern_within("src/billing/**", "src/**"));
180        assert!(path_pattern_within("src/billing/a.ts", "src/**"));
181        assert!(path_pattern_within("src/**", "src/**"));
182        assert!(!path_pattern_within("src/**", "src/billing/**"));
183        assert!(!path_pattern_within("**", "src/**"));
184        assert!(!path_pattern_within("src/a.ts", "src/b.ts"));
185        assert!(path_pattern_within("anything/**", "**"));
186    }
187
188    #[test]
189    fn symbol_patterns() {
190        assert!(symbol_matches("BillingClient.*", "BillingClient.charge"));
191        assert!(!symbol_matches("BillingClient.*", "Other.charge"));
192        assert!(symbol_pattern_within(
193            "BillingClient.charge",
194            "BillingClient.*"
195        ));
196        assert!(symbol_pattern_within("BillingClient.c*", "BillingClient.*"));
197        assert!(!symbol_pattern_within(
198            "BillingClient.*",
199            "BillingClient.charge"
200        ));
201        assert!(symbol_pattern_within("X", "*"));
202        assert!(validate_symbol_pattern("a*b").is_err());
203        assert!(validate_symbol_pattern("").is_err());
204        assert!(validate_symbol_pattern(&"a".repeat(512)).is_ok());
205        assert!(validate_symbol_pattern(&format!("{}*", "a".repeat(511))).is_ok());
206        assert!(validate_symbol_pattern(&"a".repeat(513)).is_err());
207    }
208}