use std::path::{Path, PathBuf};
pub mod radix_ulid_sort;
fn path_with_canonical_prefix(path: &Path, prefix: &Path) -> PathBuf {
let Ok(suffix) = path.strip_prefix(prefix) else {
return path.to_path_buf();
};
let Ok(canonical_prefix) = prefix.canonicalize() else {
return path.to_path_buf();
};
canonical_prefix.join(suffix)
}
#[must_use]
pub(crate) fn path_with_canonical_process_temp_prefix(path: &Path) -> PathBuf {
let temp_dir = std::env::temp_dir();
path_with_canonical_prefix(path, &temp_dir)
}
pub(crate) const SENSITIVE_PATH_PREFIXES: &[&str] = &[
"/Users/",
"/Volumes/",
"/__w/",
"/app/",
"/data/",
"/dev/",
"/dp/",
"/etc/",
"/github/workspace/",
"/home/",
"/media/",
"/mnt/",
"/private/",
"/proc/",
"/repo/",
"/root/",
"/run/",
"/sys/",
"/tmp/",
"/var/",
"/workspace/",
"/workspaces/",
];
fn uri_authority_slash_at(value: &str, start: usize) -> bool {
if start == 0 || value.as_bytes().get(start) != Some(&b'/') {
return false;
}
if value.as_bytes().get(start - 1) != Some(&b'/') {
return false;
}
let Some(colon) = start.checked_sub(2) else {
return false;
};
if value.as_bytes().get(colon) != Some(&b':') {
return false;
}
value[..colon]
.bytes()
.next_back()
.is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.'))
}
pub(crate) fn sensitive_path_starts_at(value: &str, start: usize) -> bool {
if uri_authority_slash_at(value, start) {
return false;
}
let candidate = &value[start..];
if SENSITIVE_PATH_PREFIXES.iter().any(|prefix| {
candidate
.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
}) {
return true;
}
let token_boundary_before = value[..start].chars().next_back().is_none_or(|previous| {
previous.is_whitespace() || matches!(previous, '"' | '\'' | '`' | '(' | '[' | '{' | '=')
});
if !token_boundary_before {
return false;
}
let bytes = candidate.as_bytes();
if bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'/' | b'\\')
{
return true;
}
candidate.starts_with(r"\\")
}
pub(crate) fn redact_path_like_segments(value: &str, boundary: fn(char) -> bool) -> String {
const REDACTED_PATH: &str = "[REDACTED_PATH]";
let mut output = String::with_capacity(value.len());
let mut cursor = 0;
while cursor < value.len() {
let Some(start) = value[cursor..]
.char_indices()
.map(|(relative, _)| cursor + relative)
.find(|start| sensitive_path_starts_at(value, *start))
else {
output.push_str(&value[cursor..]);
break;
};
output.push_str(&value[cursor..start]);
output.push_str(REDACTED_PATH);
cursor = value[start..]
.char_indices()
.skip(1)
.find_map(|(index, ch)| boundary(ch).then_some(start + index))
.unwrap_or(value.len());
}
output
}
#[cfg(test)]
mod tests {
use super::{path_with_canonical_prefix, path_with_canonical_process_temp_prefix};
use std::path::Path;
#[test]
fn process_temp_child_uses_canonical_temp_prefix() {
let temp_dir = std::env::temp_dir();
let child = temp_dir.join("ee-temp-prefix-test").join("child");
let expected = temp_dir
.canonicalize()
.unwrap_or_else(|_| temp_dir.clone())
.join("ee-temp-prefix-test")
.join("child");
assert_eq!(path_with_canonical_process_temp_prefix(&child), expected);
}
#[test]
fn path_outside_prefix_is_unchanged() {
let path = Path::new("relative-cache/entry.json");
let prefix = std::env::temp_dir();
assert_eq!(path_with_canonical_prefix(path, &prefix), path);
}
#[test]
fn file_scheme_redacts_absolute_targets_and_preserves_relative_ones() {
fn at_whitespace(ch: char) -> bool {
ch.is_whitespace()
}
assert_eq!(
super::redact_path_like_segments(
"source=file:///Users/alice/private/x.json tail",
at_whitespace,
),
"source=file://[REDACTED_PATH] tail",
);
for preserved in [
"file://AGENTS.md#L42",
"file://docs/adr/0001-runtime.md",
"why=file://src/core/search.rs done",
] {
assert_eq!(
super::redact_path_like_segments(preserved, at_whitespace),
preserved,
"repo-relative provenance must survive redaction",
);
}
for preserved in [
"agent://run/public-feedback",
"https://run/foo",
"ee-export://tmp/fixture",
] {
assert_eq!(
super::redact_path_like_segments(preserved, at_whitespace),
preserved,
"a URI authority must not be redacted as a path root",
);
}
assert_eq!(
super::redact_path_like_segments("agent://host/root/.ssh/id_rsa", at_whitespace),
"agent://host[REDACTED_PATH]",
);
}
#[cfg(unix)]
#[test]
fn canonical_prefix_preserves_symlinked_descendant() {
use std::fs;
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().expect("create isolated filesystem");
let canonical_prefix = temp.path().join("canonical-prefix");
fs::create_dir(&canonical_prefix).expect("create canonical prefix");
let alias_prefix = temp.path().join("alias-prefix");
symlink(&canonical_prefix, &alias_prefix).expect("create trusted prefix alias");
let outside = temp.path().join("outside");
fs::create_dir(&outside).expect("create outside directory");
fs::write(outside.join("entry.json"), b"outside").expect("create outside entry");
let descendant_link = canonical_prefix.join("descendant-link");
symlink(&outside, &descendant_link).expect("create untrusted descendant symlink");
let input = alias_prefix.join("descendant-link").join("entry.json");
let normalized = path_with_canonical_prefix(&input, &alias_prefix);
let expected = canonical_prefix
.canonicalize()
.expect("canonicalize trusted prefix")
.join("descendant-link")
.join("entry.json");
assert_eq!(normalized, expected);
assert_ne!(
normalized.canonicalize().expect("resolve full input"),
normalized,
"normalization must leave descendant symlinks visible to the safety walker"
);
}
}