use std::borrow::Cow;
use crate::generated::types::TargetClass;
use super::super::facts::FactSet;
use super::super::types::EventEnv;
use super::catalogue::{Catalogue, Origin, PatternKind};
pub const SYSTEM_PATH_GLOBS: &[&str] = &[
"/",
"/etc",
"/etc/**",
"/usr",
"/usr/**",
"/bin",
"/bin/**",
"/sbin",
"/sbin/**",
"/lib",
"/lib/**",
"/opt",
"/opt/**",
"/boot",
"/boot/**",
"/proc",
"/proc/**",
"/sys",
"/sys/**",
"/var",
"/dev",
"/dev/**",
"C:\\",
"C:\\Windows\\**",
];
pub fn glob_match(pattern: &str, value: &str) -> bool {
let value: Vec<char> = value.chars().collect();
let full: Vec<char> = pattern.chars().collect();
if match_from(&full, 0, &value, 0) {
return true;
}
match pattern.strip_prefix("**/") {
Some(rest) => {
let rest: Vec<char> = rest.chars().collect();
match_from(&rest, 0, &value, 0)
}
None => false,
}
}
fn match_from(pattern: &[char], mut pi: usize, value: &[char], mut vi: usize) -> bool {
while pi < pattern.len() {
if pattern[pi] == '*' {
if pattern.get(pi + 1) == Some(&'*') {
let rest = pi + 2;
if rest == pattern.len() {
return true;
}
return (vi..=value.len()).any(|k| match_from(pattern, rest, value, k));
}
let rest = pi + 1;
let mut k = vi;
loop {
if match_from(pattern, rest, value, k) {
return true;
}
if k >= value.len() || value[k] == '/' {
return false;
}
k += 1;
}
}
let Some(&ch) = value.get(vi) else {
return false;
};
if pattern[pi] == '?' {
if ch == '/' {
return false;
}
} else if pattern[pi] != ch {
return false;
}
pi += 1;
vi += 1;
}
vi == value.len()
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PathEnv<'a> {
pub home: String,
pub cwd: String,
pub path_dirs: Vec<String>,
pub additional_dirs: Vec<String>,
pub catalogue: Option<&'a Catalogue>,
}
impl<'a> PathEnv<'a> {
pub fn from_event(env: Option<&EventEnv>) -> PathEnv<'a> {
let Some(env) = env else {
return PathEnv::default();
};
PathEnv {
home: env.home.clone().unwrap_or_default(),
cwd: env.cwd.clone().unwrap_or_default(),
path_dirs: env.path_dirs.clone(),
additional_dirs: env.additional_dirs.clone(),
catalogue: None,
}
}
pub fn with_catalogue(mut self, catalogue: Option<&'a Catalogue>) -> PathEnv<'a> {
self.catalogue = catalogue;
self
}
fn windows_form(&self) -> PathEnv<'a> {
let fix = |value: &String| windows_form(value, true).into_owned();
PathEnv {
home: fix(&self.home),
cwd: fix(&self.cwd),
path_dirs: self.path_dirs.iter().map(fix).collect(),
additional_dirs: self.additional_dirs.iter().map(fix).collect(),
catalogue: self.catalogue,
}
}
}
fn windows_rooted(value: &str) -> bool {
let bytes = value.as_bytes();
value.starts_with("\\\\")
|| value.starts_with("//")
|| (bytes.first().is_some_and(u8::is_ascii_alphabetic)
&& bytes.get(1) == Some(&b':')
&& matches!(bytes.get(2), Some(b'/' | b'\\')))
}
fn windows_form(value: &str, windows: bool) -> Cow<'_, str> {
if windows {
Cow::Owned(value.replace('\\', "/").to_ascii_lowercase())
} else {
Cow::Borrowed(value)
}
}
pub fn fact_members(facts: &FactSet, fact_id: &str) -> Vec<String> {
super::super::facts::resolve_set(facts, fact_id, facts.now_ms).unwrap_or_default()
}
fn under(path: &str, root: &str) -> bool {
if root.is_empty() {
return false;
}
path == root || path.starts_with(&format!("{}/", root.trim_end_matches('/')))
}
fn same_dir(left: &str, right: &str) -> bool {
!left.is_empty() && left.trim_end_matches('/') == right.trim_end_matches('/')
}
fn expand_home(path: &str, home: &str) -> Option<String> {
if home.is_empty() {
return None;
}
if path == "~" {
return Some(home.to_string());
}
path.strip_prefix("~/")
.map(|rest| format!("{}/{rest}", home.trim_end_matches('/')))
}
pub fn shell_word_is_static(raw: &str) -> bool {
let mut single = false;
let mut double = false;
let mut chars = raw.chars();
while let Some(ch) = chars.next() {
match ch {
'\'' if !double => single = !single,
'"' if !single => double = !double,
'\\' if !single => {
chars.next();
}
'$' | '`' if !single => return false,
'*' | '?' if !single && !double => return false,
_ => {}
}
}
true
}
fn shell_expands_home(raw: &str, cooked: &str) -> bool {
cooked.starts_with('~') && raw.starts_with('~')
}
pub fn normalize_shell_target(cooked: &str, raw: &str, env: &PathEnv) -> Option<String> {
if !shell_word_is_static(raw) {
return None;
}
if shell_expands_home(raw, cooked) {
let expanded = expand_home(cooked, &env.home)?;
normalize_target_inner(&expanded, env, false)
} else {
normalize_target_inner(cooked, env, false)
}
}
pub fn shell_target_dependencies(cooked: &str, raw: &str) -> (bool, bool) {
if !shell_word_is_static(raw) {
return (false, false);
}
if shell_expands_home(raw, cooked) {
return (false, true);
}
let drive_absolute = cooked.as_bytes().get(1) == Some(&b':')
&& cooked
.as_bytes()
.first()
.is_some_and(u8::is_ascii_alphabetic)
&& matches!(cooked.as_bytes().get(2), Some(b'/' | b'\\'));
let independent = cooked.starts_with('/') || cooked.starts_with("\\\\") || drive_absolute;
(!independent, false)
}
pub fn normalize_target(path: &str, env: &PathEnv) -> Option<String> {
normalize_target_inner(path, env, true)
}
fn normalize_target_inner(path: &str, env: &PathEnv, expand_tilde: bool) -> Option<String> {
if path.is_empty() || path.contains("://") {
return None;
}
let mut value = if expand_tilde {
expand_home(path, &env.home).unwrap_or_else(|| path.to_string())
} else {
path.to_string()
};
let drive = |text: &str| {
(text.as_bytes().get(1) == Some(&b':')
&& text.as_bytes().first().is_some_and(u8::is_ascii_alphabetic))
.then(|| text[..2].to_string())
};
let drive_absolute =
|text: &str| drive(text).filter(|_| matches!(text.as_bytes().get(2), Some(b'/' | b'\\')));
let unc = value.starts_with("//") || value.starts_with("\\\\");
let cwd_drive = drive_absolute(&env.cwd);
let cwd_unc = env.cwd.starts_with("//") || env.cwd.starts_with("\\\\");
let drive_relative = drive(&value).filter(|_| drive_absolute(&value).is_none());
let windows = unc
|| drive_absolute(&value).is_some()
|| (drive_relative.is_some() && cwd_drive == drive_relative)
|| (!value.starts_with('/')
&& drive_relative.is_none()
&& (cwd_drive.is_some() || cwd_unc));
if let Some(relative_drive) = drive_relative {
if cwd_drive.as_ref() != Some(&relative_drive) {
return None;
}
value = format!("{}/{}", env.cwd.trim_end_matches(['/', '\\']), &value[2..]);
} else if !unc
&& drive_absolute(&value).is_none()
&& !value.starts_with('/')
&& !env.cwd.is_empty()
{
value = format!("{}/{}", env.cwd.trim_end_matches(['/', '\\']), value);
}
if windows {
value = value.replace('\\', "/");
}
let is_unc = windows && value.starts_with("//");
let drive_absolute = drive_absolute(&value);
let is_posix = value.starts_with('/') && !is_unc;
let (prefix, rest, floor) = if is_unc {
("//".to_string(), value.trim_start_matches('/'), 2usize)
} else if let Some(drive) = drive_absolute {
(
format!("{drive}/"),
value[2..].trim_start_matches('/'),
0usize,
)
} else if is_posix {
("/".to_string(), value.trim_start_matches('/'), 0usize)
} else {
(String::new(), value.as_str(), 0usize)
};
let mut parts: Vec<&str> = Vec::new();
for part in rest.split('/') {
match part {
"" | "." => {}
".." if parts.len() > floor => {
parts.pop();
}
".." if prefix.is_empty() => parts.push(part),
".." => {}
_ => parts.push(part),
}
}
let joined = parts.join("/");
if prefix.is_empty() {
Some(joined)
} else if joined.is_empty() {
Some(prefix)
} else {
Some(format!("{prefix}{joined}"))
}
}
pub fn normalize_prefix(prefix: &str) -> Option<String> {
let delimited = prefix.ends_with('/') || prefix.ends_with('\\');
let mut normalized = normalize_target(prefix, &PathEnv::default())?;
if delimited && !normalized.ends_with('/') {
normalized.push('/');
}
Some(normalized)
}
fn candidates(path: &str, env: &PathEnv) -> Vec<String> {
let mut out = vec![path.to_string()];
if !env.home.is_empty() {
if let Some(rest) = path.strip_prefix(env.home.as_str()) {
out.push(format!("~{rest}"));
}
if let Some(expanded) = expand_home(path, &env.home) {
out.push(expanded);
}
}
out
}
fn pattern_matches(pattern: &str, candidates: &[String], env: &PathEnv, windows: bool) -> bool {
let pattern = &*windows_form(pattern, windows);
let expanded = match pattern.strip_prefix('~') {
Some(rest) if !env.home.is_empty() => format!("{}{rest}", env.home),
_ => pattern.to_string(),
};
candidates
.iter()
.any(|candidate| glob_match(pattern, candidate) || glob_match(&expanded, candidate))
}
fn match_any(path: &str, patterns: &[&str], env: &PathEnv, windows: bool) -> bool {
let forms = candidates(path, env);
patterns
.iter()
.any(|pattern| pattern_matches(pattern, &forms, env, windows))
}
fn match_any_owned<I: IntoIterator<Item = String>>(
path: &str,
patterns: I,
env: &PathEnv,
windows: bool,
) -> bool {
let forms = candidates(path, env);
patterns
.into_iter()
.any(|pattern| pattern_matches(&pattern, &forms, env, windows))
}
#[derive(Debug, Clone, PartialEq)]
pub struct PathClass {
pub class: TargetClass,
pub origins: Vec<Origin>,
}
fn rank(class: &str) -> u8 {
match class {
"secret_material" => 1,
"agent_config" => 2,
"system_path" => 3,
"classified_source" => 4,
"data_store" => 5,
"workspace_file" => 6,
_ => 7,
}
}
fn add_class(out: &mut Vec<PathClass>, class: &str, origin: Option<&Origin>) {
let existing = out.iter_mut().find(|c| c.class.0 == class);
let entry = match existing {
Some(entry) => entry,
None => {
out.push(PathClass {
class: TargetClass(class.to_string()),
origins: Vec::new(),
});
out.last_mut().expect("just pushed")
}
};
if let Some(origin) = origin {
if !entry.origins.contains(origin) {
entry.origins.push(origin.clone());
}
}
}
pub fn resolve_all(path: &str, env: &PathEnv, facts: &FactSet) -> Vec<PathClass> {
if path.is_empty() {
return Vec::new();
}
let windows = windows_rooted(path) || (windows_rooted(&env.cwd) && !path.starts_with('/'));
let path = &*windows_form(path, windows);
let windows_env;
let env = if windows {
windows_env = env.windows_form();
&windows_env
} else {
env
};
let absolute = expand_home(path, &env.home).unwrap_or_else(|| path.to_string());
let forms = candidates(path, env);
let mut out: Vec<PathClass> = Vec::new();
if let Some(catalogue) = env.catalogue {
let mut removed: Vec<&str> = Vec::new();
for target in catalogue
.targets
.iter()
.filter(|t| t.kind == PatternKind::Path)
{
if !pattern_matches(&target.pattern, &forms, env, windows) {
continue;
}
if target.narrow {
removed.push(&target.target_class);
} else {
add_class(&mut out, &target.target_class, Some(&target.origin));
}
}
out.retain(|c| !removed.contains(&c.class.0.as_str()));
}
if match_any(path, SYSTEM_PATH_GLOBS, env, windows)
|| same_dir(&absolute, &env.home)
|| (!env.cwd.is_empty() && !same_dir(&absolute, &env.cwd) && under(&env.cwd, &absolute))
|| env.path_dirs.iter().any(|dir| same_dir(&absolute, dir))
{
add_class(&mut out, "system_path", None);
}
if match_any_owned(
path,
fact_members(facts, "classified_sources"),
env,
windows,
) {
add_class(&mut out, "classified_source", None);
}
if fact_members(facts, "data_store_roots")
.iter()
.any(|root| under(&absolute, &windows_form(root, windows)))
{
add_class(&mut out, "data_store", None);
}
if out.is_empty()
&& ((!path.starts_with('/')
&& !path.starts_with('~')
&& !windows_rooted(path)
&& !path.contains("://"))
|| under(&absolute, &env.cwd)
|| env.additional_dirs.iter().any(|dir| under(&absolute, dir)))
{
add_class(&mut out, "workspace_file", None);
}
out.sort_by_key(|c| rank(&c.class.0));
out
}
pub fn resolve_host(host: &str, env: &PathEnv) -> Vec<PathClass> {
let mut out: Vec<PathClass> = Vec::new();
let Some(catalogue) = env.catalogue else {
return out;
};
let mut removed: Vec<&str> = Vec::new();
for target in catalogue
.targets
.iter()
.filter(|t| t.kind == PatternKind::Host)
{
if !super::catalogue::host_matches(&target.pattern, host) {
continue;
}
if target.narrow {
removed.push(&target.target_class);
} else {
add_class(&mut out, &target.target_class, Some(&target.origin));
}
}
out.retain(|c| !removed.contains(&c.class.0.as_str()));
out
}
pub fn resolve(path: &str, env: Option<&EventEnv>, facts: &FactSet) -> Option<TargetClass> {
resolve_with(path, &PathEnv::from_event(env), facts)
}
pub fn resolve_with(path: &str, env: &PathEnv, facts: &FactSet) -> Option<TargetClass> {
resolve_all(path, env, facts)
.into_iter()
.next()
.map(|c| c.class)
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::catalogue::fixtures::{seed_r1, seed_r1_plus};
fn seeded() -> PathEnv<'static> {
PathEnv::default().with_catalogue(Some(seed_r1()))
}
fn env() -> PathEnv<'static> {
PathEnv {
home: "/home/dev".to_string(),
cwd: "/home/dev/proj".to_string(),
path_dirs: vec!["/usr/local/bin".to_string()],
additional_dirs: vec!["/srv/extra".to_string()],
catalogue: Some(seed_r1()),
}
}
fn class_of(path: &str, env: &PathEnv) -> Option<String> {
resolve_with(path, env, &FactSet::default()).map(|c| c.0)
}
fn classes_of(path: &str, env: &PathEnv) -> Vec<String> {
resolve_all(path, env, &FactSet::default())
.into_iter()
.map(|c| c.class.0)
.collect()
}
#[test]
fn double_star_crosses_a_slash_and_single_star_does_not() {
assert!(glob_match("**/*.pem", "certs/nested/server.pem"));
assert!(!glob_match("*.pem", "certs/server.pem"));
assert!(glob_match("*.pem", "server.pem"));
assert!(glob_match("**/migrations/**", "db/migrations/0001.py"));
assert!(!glob_match("/etc/**", "/etcetera/x"));
}
#[test]
fn a_backslash_in_a_windows_seed_is_a_literal() {
assert!(glob_match(
"C:\\Windows\\**",
"C:\\Windows\\System32\\hosts"
));
assert!(!glob_match("C:\\Windows\\**", "C:/Windows/System32"));
}
#[test]
fn occurrence_targets_normalize_lexically_without_losing_boundaries() {
let env = env();
assert_eq!(
normalize_target("/tmp/a/../b", &env).as_deref(),
Some("/tmp/b")
);
assert_eq!(
normalize_target("/tmp/../etc/passwd", &env).as_deref(),
Some("/etc/passwd")
);
assert_eq!(
normalize_target("src/../out", &env).as_deref(),
Some("/home/dev/proj/out")
);
assert_eq!(
normalize_target("~/tmp/x", &env).as_deref(),
Some("/home/dev/tmp/x")
);
assert_eq!(
normalize_target("\\tmp\\out", &env).as_deref(),
Some("/home/dev/proj/\\tmp\\out")
);
assert_eq!(normalize_prefix("/tmp/").as_deref(), Some("/tmp/"));
assert!(!normalize_target("/tmp2/x", &env)
.expect("normalizes")
.starts_with(&normalize_prefix("/tmp/").expect("prefix")));
}
#[test]
fn windows_and_unc_paths_are_data_on_every_host() {
let windows = PathEnv {
home: "C:\\Users\\dev".to_string(),
cwd: "C:\\work\\repo".to_string(),
..PathEnv::default()
};
assert_eq!(
normalize_target(".\\src\\..\\out.txt", &windows).as_deref(),
Some("C:/work/repo/out.txt")
);
assert_eq!(
normalize_target("C:\\tmp\\..\\Windows\\x", &windows).as_deref(),
Some("C:/Windows/x")
);
assert_eq!(
normalize_target("C:tmp\\out", &windows).as_deref(),
Some("C:/work/repo/tmp/out")
);
let other_drive = PathEnv {
cwd: "D:\\work".to_string(),
..PathEnv::default()
};
assert_eq!(normalize_target("C:tmp\\out", &other_drive), None);
assert_eq!(
normalize_target("\\\\server\\share\\a\\..\\b", &windows).as_deref(),
Some("//server/share/b")
);
}
#[test]
fn the_order_is_the_semantics() {
let env = seeded();
assert_eq!(
class_of("config/.env", &env).as_deref(),
Some("secret_material")
);
assert_eq!(
class_of(".claude/x.md", &env).as_deref(),
Some("agent_config")
);
assert_eq!(
class_of("/var/lib/postgresql/16/base", &env).as_deref(),
Some("data_store")
);
}
#[test]
fn rows_three_and_six_need_env_and_stay_silent_without_it() {
let none = seeded();
assert_eq!(class_of("/home/dev", &none), None, "no env, no home root");
assert_eq!(class_of("/srv/extra/notes.md", &none), None);
let env = env();
assert_eq!(class_of("/home/dev", &env).as_deref(), Some("system_path"));
assert_eq!(
class_of("/usr/local/bin", &env).as_deref(),
Some("system_path")
);
assert_eq!(
class_of("/srv/extra/notes.md", &env).as_deref(),
Some("workspace_file")
);
}
#[test]
fn an_unplaceable_absolute_path_is_never_a_guessed_workspace_file() {
assert_eq!(class_of("/srv/blob/opaque.bin", &env()), None);
}
#[test]
fn a_tilde_resolves_against_the_declared_home_and_not_the_host() {
assert_eq!(
class_of("~/proj/notes.md", &env()).as_deref(),
Some("workspace_file")
);
assert_eq!(
class_of("~/tmp/stale", &seeded()),
None,
"with no declared home there is nothing to expand against"
);
}
#[test]
fn a_customer_root_arrives_as_a_fact_and_not_as_a_constant() {
let facts = FactSet::new(
vec![serde_json::from_value(serde_json::json!({
"fact_id": "data_store_roots",
"kind": "set",
"observed_at": "2025-09-01T15:59:00+00:00",
"max_age_s": 86400,
"value": ["/lake"],
}))
.expect("the fixture fact parses")],
1_756_742_400_000,
);
assert_eq!(
resolve_with("/lake/raw/x.parquet", &PathEnv::default(), &facts).map(|c| c.0),
Some("data_store".to_string())
);
assert_eq!(
resolve_with(
"/lake/raw/x.parquet",
&PathEnv::default(),
&FactSet::default()
),
None,
"without the fact the root is unknown, not empty-and-therefore-fine"
);
}
#[test]
fn a_windows_root_matches_in_its_windows_form() {
let facts = FactSet::new(
vec![serde_json::from_value(serde_json::json!({
"fact_id": "data_store_roots",
"kind": "set",
"observed_at": "2025-09-01T15:59:00+00:00",
"max_age_s": 86400,
"value": ["D:\\Lake"],
}))
.expect("the fixture fact parses")],
1_756_742_400_000,
);
let windows = PathEnv {
home: "C:\\Users\\dev".to_string(),
cwd: "C:\\Users\\dev\\project".to_string(),
..PathEnv::default()
};
assert_eq!(
resolve_with("d:\\LAKE\\raw\\x.parquet", &windows, &facts).map(|c| c.0),
Some("data_store".to_string())
);
assert_eq!(
class_of("~\\project\\notes.md", &windows).as_deref(),
Some("workspace_file")
);
}
#[test]
fn a_path_matching_several_patterns_carries_every_class() {
let catalogue = seed_r1_plus(serde_json::json!({"targets": [
{"id": "n1", "origin": "org", "pattern_kind": "path", "key": "**/.npmrc",
"target_class": "secret_material"}
]}));
let env = PathEnv::default().with_catalogue(Some(catalogue));
assert_eq!(
classes_of(".npmrc", &env),
vec!["secret_material", "agent_config"]
);
assert_eq!(
classes_of("/home/dev/p/.npmrc", &env),
vec!["secret_material", "agent_config"]
);
let all = resolve_all(".npmrc", &env, &FactSet::default());
assert_eq!(all[0].origins, vec![Origin::Entry("n1".to_string())]);
}
#[test]
fn a_code_row_and_a_catalogue_row_both_add_their_class() {
let catalogue = seed_r1_plus(serde_json::json!({"targets": [
{"id": "acme", "origin": "org", "pattern_kind": "path", "key": "/opt/acme/**",
"target_class": "secret_material"}
]}));
let env = PathEnv::default().with_catalogue(Some(catalogue));
assert_eq!(
classes_of("/opt/acme/secrets.yaml", &env),
vec!["secret_material", "system_path"]
);
}
#[test]
fn a_leading_double_star_slash_is_optional() {
assert!(glob_match("**/.npmrc", ".npmrc"));
assert!(glob_match("**/.npmrc", "/home/dev/p/.npmrc"));
assert!(glob_match("**/.ssh/**", ".ssh/id_rsa"));
assert!(!glob_match("**/.npmrc", "x.npmrc"));
assert!(
!glob_match(".npmrc", "p/.npmrc"),
"only a leading `**/` is optional"
);
}
#[test]
fn a_narrow_target_removes_its_class_in_every_scope() {
let catalogue = seed_r1_plus(serde_json::json!({"targets": [
{"id": "fix", "origin": "org", "narrow": true, "pattern_kind": "path",
"key": "**/*token*", "target_class": "secret_material"}
]}));
let env = PathEnv::default().with_catalogue(Some(catalogue));
assert_eq!(
classes_of("docs/token-bucket.md", &env),
vec!["workspace_file"]
);
}
#[test]
fn with_no_catalogue_no_pattern_row_fires() {
let env = PathEnv::default();
assert_eq!(classes_of("config/.env", &env), vec!["workspace_file"]);
}
}