#[must_use]
pub fn normalise(raw: &str, home: &str, project: Option<&str>, cwd: &str) -> String {
resolve(raw, home, project, Some(cwd)).unwrap_or_else(|| collapse(&format!("{cwd}/{raw}")))
}
#[must_use]
pub fn resolve(raw: &str, home: &str, project: Option<&str>, cwd: Option<&str>) -> Option<String> {
let unified = raw.replace('\\', "/");
let expanded = match dir_stack_tilde(&unified) {
Some(('+', rest)) => format!("{}{rest}", cwd?),
Some(_) => return None,
None => expand_home(&unified, home),
};
let s = expanded
.replace("${project}", project.unwrap_or_default())
.replace("${HOME}", home)
.replace("$HOME", home);
if is_absolute(&s) {
Some(collapse(&s))
} else {
Some(collapse(&format!("{}/{s}", cwd?)))
}
}
fn dir_stack_tilde(raw: &str) -> Option<(char, &str)> {
let rest = raw.strip_prefix('~')?;
let sign = rest.chars().next().filter(|c| matches!(c, '+' | '-'))?;
let rest = &rest[1..];
(rest.is_empty() || rest.starts_with('/')).then_some((sign, rest))
}
#[must_use]
pub fn is_absolute(path: &str) -> bool {
path.starts_with('/') || drive_prefix(path).is_some()
}
fn drive_prefix(path: &str) -> Option<&str> {
let bytes = path.as_bytes();
(bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':').then(|| &path[..2])
}
#[must_use]
pub fn collapse(path: &str) -> String {
let (root, rest) = split_root(path);
let mut out: Vec<&str> = Vec::new();
for seg in rest.split('/') {
match seg {
"" | "." => {}
".." => {
out.pop();
}
s => out.push(s),
}
}
format!("{root}{}", out.join("/"))
}
fn split_root(path: &str) -> (String, &str) {
if let Some(drive) = drive_prefix(path) {
let rest = &path[2..];
let root = format!("{}:/", drive[..1].to_ascii_uppercase());
return (root, rest.trim_start_matches('/'));
}
if let Some(unc) = path.strip_prefix("//") {
let (server, rest) = unc.split_once('/').unwrap_or((unc, ""));
return (format!("//{server}/"), rest);
}
("/".to_owned(), path.trim_start_matches('/'))
}
#[must_use]
pub fn expand_pattern(raw: &str, home: &str, project: Option<&str>) -> Option<String> {
let (negated, body) = crate::pattern::split_negation(raw);
let body = expand_home(body, home);
let expanded = match project {
Some(project) => body.replace("${project}", project),
None if body.contains("${project}") => return None,
None => body,
};
Some(if negated {
format!("!{expanded}")
} else {
expanded
})
}
fn expand_home(raw: &str, home: &str) -> String {
let Some((name, tail)) = home_prefix(raw) else {
return raw.to_owned();
};
let (parent, user) = home.rsplit_once('/').unwrap_or(("", home));
if name.is_empty() || name == user {
format!("{home}{tail}")
} else {
format!("{parent}/{name}{tail}")
}
}
fn home_prefix(raw: &str) -> Option<(&str, &str)> {
let rest = raw.strip_prefix('~')?;
let (name, tail) = rest.split_at(rest.find('/').unwrap_or(rest.len()));
let login = name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
(login && !name.starts_with('-')).then_some((name, tail))
}
#[must_use]
pub fn looks_like_path(token: &str) -> bool {
if token.contains("://") {
return false;
}
token.starts_with('/')
|| home_prefix(token).is_some()
|| dir_stack_tilde(token).is_some()
|| token.starts_with("./")
|| token.starts_with("../")
|| token.starts_with(".\\")
|| token.starts_with("..\\")
|| token.starts_with("$HOME/")
|| token.starts_with("${HOME}/")
|| token.starts_with("${project}/")
|| drive_prefix(token)
.is_some_and(|_| matches!(token.as_bytes().get(2), Some(b'/' | b'\\')))
|| token.starts_with("\\\\")
|| traverses(token)
|| names_hidden_entry(token)
}
fn traverses(token: &str) -> bool {
token == ".."
|| token
.split(['/', '\\'])
.enumerate()
.any(|(i, seg)| seg == ".." && (i > 0 || token.len() > 2))
}
fn names_hidden_entry(token: &str) -> bool {
token.split(['/', '\\']).any(|seg| {
seg.len() > 1
&& seg != ".."
&& seg.starts_with('.')
&& !seg[1..].chars().all(|c| c.is_ascii_digit())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalises_unix_paths() {
assert_eq!(
normalise("~/.ssh/id_rsa", "/Users/me", Some("/p"), "/p"),
"/Users/me/.ssh/id_rsa"
);
assert_eq!(
normalise("src/../.env", "/Users/me", Some("/p"), "/p/app"),
"/p/app/.env"
);
assert_eq!(normalise("${project}/x", "/h", Some("/p"), "/c"), "/p/x");
assert_eq!(
normalise("$HOME/.aws/credentials", "/h", Some("/p"), "/c"),
"/h/.aws/credentials"
);
assert_eq!(collapse("/a//b/./c/../d"), "/a/b/d");
assert_eq!(collapse("/../x"), "/x");
}
#[test]
fn project_patterns_match_nothing_without_a_project() {
assert_eq!(
expand_pattern("!${project}/.git/**", "/h", Some("/p")).as_deref(),
Some("!/p/.git/**")
);
assert_eq!(expand_pattern("${project}/**", "/h", None), None);
assert_eq!(
expand_pattern("~/.ssh/**", "/h", None).as_deref(),
Some("/h/.ssh/**")
);
assert_eq!(normalise("${project}/x", "/h", None, "/c"), "/x");
}
#[test]
fn tilde_names_a_home_directory() {
let n = |raw: &str| normalise(raw, "/Users/me", Some("/p"), "/p/app");
assert_eq!(n("~me/.ssh/id_rsa"), "/Users/me/.ssh/id_rsa");
assert_eq!(n("~me"), "/Users/me");
assert_eq!(n("~alice/.ssh/id_rsa"), "/Users/alice/.ssh/id_rsa");
assert_eq!(n("~+/x"), "/p/app/x");
assert_eq!(n("~+"), "/p/app");
assert_eq!(n("a/~me"), "/p/app/a/~me", "only a leading tilde expands");
assert_eq!(n("~a$b/x"), "/p/app/~a$b/x", "not a login name");
assert_eq!(
normalise("~bob/x", "C:/Users/me", None, "C:/p"),
"C:/Users/bob/x"
);
let r = |raw: &str| resolve(raw, "/Users/me", Some("/p"), Some("/p/app"));
assert_eq!(
r("~-/.ssh/id_rsa"),
None,
"the previous directory is unknown"
);
assert_eq!(r("~-"), None);
assert_eq!(r("~-x/y").as_deref(), Some("/p/app/~-x/y"));
assert_eq!(resolve("src", "/h", None, None), None);
assert_eq!(
resolve("/etc/hosts", "/h", None, None).as_deref(),
Some("/etc/hosts")
);
assert_eq!(resolve("~+/x", "/h", None, None), None);
for token in ["~me/.ssh/id_rsa", "~alice", "~+/x", "~-", "~"] {
assert!(looks_like_path(token), "{token}");
}
for token in ["~a$b", "x~y"] {
assert!(!looks_like_path(token), "{token}");
}
}
#[test]
fn normalises_windows_paths() {
let home = "C:/Users/me";
assert_eq!(
normalise("~/.ssh/id_rsa", home, Some("C:/p"), "C:/p"),
"C:/Users/me/.ssh/id_rsa"
);
assert_eq!(
normalise(r"C:\p\src\main.rs", home, Some("C:/p"), "C:/p"),
"C:/p/src/main.rs"
);
assert_eq!(
normalise(r"src\lib.rs", home, Some("C:/p"), "C:/p"),
"C:/p/src/lib.rs"
);
assert_eq!(normalise("c:/P/../q", home, Some("C:/p"), "C:/p"), "C:/q");
assert_eq!(
normalise(r"\\server\share\f.txt", home, Some("C:/p"), "C:/p"),
"//server/share/f.txt"
);
assert!(is_absolute("D:/x") && is_absolute("//srv/s") && !is_absolute("x/y"));
}
#[test]
fn path_heuristics() {
for token in [
"/etc/passwd",
"~/.ssh",
"./a",
"../a",
r"C:\x",
"D:/y",
r".\x",
r"\\srv\s",
] {
assert!(looks_like_path(token), "{token}");
}
for token in ["main.rs", "C:", "https://x", "-v", "ab/cd"] {
assert!(!looks_like_path(token), "{token}");
}
}
#[test]
fn relative_forms_that_reach_secrets_are_paths() {
for token in [
".env",
".env.production",
"config/.env.local",
".ssh/id_rsa",
"src/../../.ssh/id_rsa",
"a/..",
"..",
r"src\..\.env",
] {
assert!(looks_like_path(token), "{token}");
}
for token in [
"origin/main",
"application/json",
"owner/repo",
".",
".5",
"s/foo/bar/",
"https://x/.env",
"fix.bug",
] {
assert!(!looks_like_path(token), "{token}");
}
}
}