1#[must_use]
17pub fn normalise(raw: &str, home: &str, project: Option<&str>, cwd: &str) -> String {
18 resolve(raw, home, project, Some(cwd)).unwrap_or_else(|| collapse(&under(cwd, raw)))
24}
25
26fn 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#[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#[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#[must_use]
66pub fn has_dotdot(path: &str) -> bool {
67 path.split('/').any(|seg| seg == "..")
68}
69
70fn 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 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#[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 return braced
116 || body.starts_with(|c: char| c.is_ascii_digit() || "@*#?$!-".contains(c));
117 }
118 let known = matches!(name, "HOME" | "PWD");
120 !(known && (!braced || body[end..].starts_with('}')))
121 })
122}
123
124fn 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#[must_use]
135pub fn is_absolute(path: &str) -> bool {
136 path.starts_with('/') || drive_prefix(path).is_some()
137}
138
139fn 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#[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
163pub(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#[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 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
207fn 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 None
226 }
227}
228
229pub(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
235fn 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#[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
283fn 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 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 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}