1use crate::PathResolutionContext;
11use crate::PolicyError;
12use cageforge_path::{
13 NativePathKey, case_fold, contains_parent_traversal, is_within, normalize_lexical_path,
14 paths_equal, strings_equal,
15};
16use globset::{GlobBuilder, GlobMatcher};
17use std::cmp::Ordering;
18use std::hash::{Hash, Hasher};
19use std::path::Component;
20use std::path::Path;
21use std::path::PathBuf;
22
23#[derive(Debug, Clone)]
25pub struct PathSelector {
26 kind: PathSelectorKind,
27}
28
29#[derive(Debug, Clone)]
30enum PathSelectorKind {
31 Absolute(PathBuf),
32 WorkspaceRoot(PathBuf),
33 Root,
34 Minimal,
35 Tmpdir,
36 SlashTmp,
37}
38
39#[derive(Debug, Clone)]
41pub struct PathPattern {
42 raw: String,
43 absolute: bool,
44 prefix: Option<String>,
45 components: Vec<String>,
46 matchers: Vec<GlobMatcher>,
47}
48
49impl PartialEq for PathSelector {
50 fn eq(&self, other: &Self) -> bool {
51 selectors_equal(self, other)
52 }
53}
54
55impl Eq for PathSelector {}
56
57impl Hash for PathSelector {
58 fn hash<H: Hasher>(&self, state: &mut H) {
59 selector_kind_rank(&self.kind).hash(state);
60 if let Some(path) = self.path() {
61 NativePathKey::new(path).hash(state);
62 }
63 }
64}
65
66impl PartialOrd for PathSelector {
67 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
68 Some(self.cmp(other))
69 }
70}
71
72impl Ord for PathSelector {
73 fn cmp(&self, other: &Self) -> Ordering {
74 let kind_order = selector_kind_rank(&self.kind).cmp(&selector_kind_rank(&other.kind));
75 if kind_order != Ordering::Equal {
76 return kind_order;
77 }
78 match (self.path(), other.path()) {
79 (Some(left), Some(right)) => NativePathKey::new(left).cmp(&NativePathKey::new(right)),
80 (None, None) => Ordering::Equal,
81 _ => Ordering::Equal,
82 }
83 }
84}
85
86impl PathSelector {
87 pub fn absolute(path: impl Into<PathBuf>) -> Result<Self, PolicyError> {
89 let path = path.into();
90 if path.as_os_str().is_empty() {
91 return Err(PolicyError::EmptyPath);
92 }
93 if contains_nul(&path) {
94 return Err(PolicyError::PathContainsNul { path });
95 }
96 if !path.is_absolute() {
97 return Err(PolicyError::ExpectedAbsolute { path });
98 }
99 if contains_parent_traversal(&path) {
100 return Err(PolicyError::ParentTraversal { path });
101 }
102 Ok(Self {
103 kind: PathSelectorKind::Absolute(path),
104 })
105 }
106
107 pub fn workspace_root() -> Self {
109 Self {
110 kind: PathSelectorKind::WorkspaceRoot(PathBuf::from(".")),
111 }
112 }
113
114 pub const fn root() -> Self {
119 Self {
120 kind: PathSelectorKind::Root,
121 }
122 }
123
124 pub fn workspace(relative: impl Into<PathBuf>) -> Result<Self, PolicyError> {
126 let relative = relative.into();
127 let normalized = normalize_relative_path(relative.clone())?;
128 Ok(Self {
129 kind: PathSelectorKind::WorkspaceRoot(normalized),
130 })
131 }
132
133 pub const fn minimal() -> Self {
135 Self {
136 kind: PathSelectorKind::Minimal,
137 }
138 }
139
140 pub const fn tmpdir() -> Self {
142 Self {
143 kind: PathSelectorKind::Tmpdir,
144 }
145 }
146
147 pub const fn slash_tmp() -> Self {
149 Self {
150 kind: PathSelectorKind::SlashTmp,
151 }
152 }
153
154 pub fn resolve(&self, context: &PathResolutionContext) -> Vec<PathBuf> {
156 match &self.kind {
157 PathSelectorKind::Absolute(path) => vec![path.clone()],
158 PathSelectorKind::WorkspaceRoot(relative) => context
159 .workspace_roots()
160 .iter()
161 .map(|root| root.join(relative))
162 .collect(),
163 PathSelectorKind::Root => context.root_paths().to_vec(),
164 PathSelectorKind::Minimal => context.minimal_paths().to_vec(),
165 PathSelectorKind::Tmpdir => context
166 .tmpdir()
167 .into_iter()
168 .map(Path::to_path_buf)
169 .collect(),
170 PathSelectorKind::SlashTmp => context
171 .slash_tmp()
172 .into_iter()
173 .map(Path::to_path_buf)
174 .collect(),
175 }
176 }
177
178 pub fn path(&self) -> Option<&Path> {
180 match &self.kind {
181 PathSelectorKind::Absolute(path) | PathSelectorKind::WorkspaceRoot(path) => Some(path),
182 PathSelectorKind::Root
183 | PathSelectorKind::Minimal
184 | PathSelectorKind::Tmpdir
185 | PathSelectorKind::SlashTmp => None,
186 }
187 }
188
189 pub const fn is_special(&self) -> bool {
191 matches!(
192 &self.kind,
193 PathSelectorKind::Root
194 | PathSelectorKind::Minimal
195 | PathSelectorKind::Tmpdir
196 | PathSelectorKind::SlashTmp
197 )
198 }
199
200 pub const fn is_workspace_scope(&self) -> bool {
203 matches!(&self.kind, PathSelectorKind::WorkspaceRoot(_))
204 }
205
206 pub const fn is_root_scope(&self) -> bool {
208 matches!(&self.kind, PathSelectorKind::Root)
209 }
210
211 pub const fn is_minimal_scope(&self) -> bool {
213 matches!(&self.kind, PathSelectorKind::Minimal)
214 }
215
216 pub const fn is_tmpdir_scope(&self) -> bool {
218 matches!(&self.kind, PathSelectorKind::Tmpdir)
219 }
220
221 pub const fn is_slash_tmp_scope(&self) -> bool {
223 matches!(&self.kind, PathSelectorKind::SlashTmp)
224 }
225
226 pub const fn is_absolute_scope(&self) -> bool {
228 matches!(&self.kind, PathSelectorKind::Absolute(_))
229 }
230
231 pub(crate) fn is_definitely_outside(&self, parent: &Self) -> bool {
234 match (&self.kind, &parent.kind) {
235 (PathSelectorKind::Absolute(child), PathSelectorKind::Absolute(parent))
236 | (PathSelectorKind::WorkspaceRoot(child), PathSelectorKind::WorkspaceRoot(parent)) => {
237 !is_within(child, parent)
238 }
239 _ => false,
240 }
241 }
242}
243
244impl PartialEq for PathPattern {
245 fn eq(&self, other: &Self) -> bool {
246 self.semantic_key() == other.semantic_key()
247 }
248}
249
250impl Eq for PathPattern {}
251
252impl Hash for PathPattern {
253 fn hash<H: Hasher>(&self, state: &mut H) {
254 self.semantic_key().hash(state);
255 }
256}
257
258impl PartialOrd for PathPattern {
259 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
260 Some(self.cmp(other))
261 }
262}
263
264impl Ord for PathPattern {
265 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
266 self.semantic_key().cmp(&other.semantic_key())
267 }
268}
269
270impl PathPattern {
271 pub fn absolute(pattern: impl Into<String>) -> Result<Self, PolicyError> {
273 Self::new(pattern.into(), true)
274 }
275
276 pub fn workspace(pattern: impl Into<String>) -> Result<Self, PolicyError> {
278 Self::new(pattern.into(), false)
279 }
280
281 pub fn as_str(&self) -> &str {
283 &self.raw
284 }
285
286 pub const fn is_absolute(&self) -> bool {
288 self.absolute
289 }
290
291 pub fn literal_prefix(&self) -> PathBuf {
299 let normalized = normalize_lexical_path(Path::new(&self.raw));
300 let mut prefix = PathBuf::new();
301 for component in normalized.components() {
302 match component {
303 Component::Prefix(value) => prefix.push(value.as_os_str()),
304 Component::RootDir => prefix.push(Path::new("/")),
305 Component::CurDir => {}
306 Component::ParentDir => break,
310 Component::Normal(value) => {
311 let value = value.to_string_lossy();
312 if contains_glob_meta(&value) {
313 break;
314 }
315 prefix.push(value.as_ref());
316 }
317 }
318 }
319 prefix
320 }
321
322 pub fn matches_path(&self, path: &Path, context: &PathResolutionContext) -> bool {
328 self.matches(path, context)
329 }
330
331 pub(crate) fn matches(&self, path: &Path, context: &PathResolutionContext) -> bool {
332 if self.absolute {
333 let (prefix, components) = path_components(path);
334 return path.is_absolute()
335 && prefixes_equal(prefix.as_deref(), self.prefix.as_deref())
336 && glob_components_match(&self.components, &self.matchers, &components);
337 }
338
339 context.workspace_roots().iter().any(|root| {
340 relative_path_components(path, root).is_some_and(|components| {
341 glob_components_match(&self.components, &self.matchers, &components)
342 })
343 })
344 }
345
346 pub(crate) fn specificity(&self) -> usize {
347 self.components
348 .iter()
349 .filter(|component| !contains_glob_meta(component))
350 .count()
351 }
352
353 pub(crate) fn semantic_key(&self) -> (bool, Option<String>, Vec<String>) {
354 (
355 self.absolute,
356 self.prefix.as_deref().map(case_fold),
357 self.components
358 .iter()
359 .map(|component| case_fold(component))
360 .collect(),
361 )
362 }
363
364 fn new(raw: String, absolute: bool) -> Result<Self, PolicyError> {
365 if raw.trim().is_empty() {
366 return Err(PolicyError::InvalidGlobPattern {
367 pattern: raw,
368 reason: "pattern cannot be empty".to_string(),
369 });
370 }
371 if raw.contains('\0') {
372 return Err(PolicyError::InvalidGlobPattern {
373 pattern: raw,
374 reason: "pattern cannot contain a NUL character".to_string(),
375 });
376 }
377 let normalized_path = normalize_lexical_path(Path::new(&raw));
378 let path = normalized_path.as_ref();
379 if absolute && !path.is_absolute() {
380 return Err(PolicyError::InvalidGlobPattern {
381 pattern: raw,
382 reason: "absolute glob must use a native absolute path".to_string(),
383 });
384 }
385 if !absolute && path.is_absolute() {
386 return Err(PolicyError::InvalidGlobPattern {
387 pattern: raw,
388 reason: "workspace glob must be relative".to_string(),
389 });
390 }
391
392 let mut components = Vec::new();
393 let mut matchers = Vec::new();
394 let mut prefix = None;
395 for component in path.components() {
396 match component {
397 Component::Prefix(value) => {
398 prefix = Some(value.as_os_str().to_string_lossy().into_owned());
399 }
400 Component::RootDir => {}
401 Component::CurDir => {}
402 Component::ParentDir => {
403 return Err(PolicyError::InvalidGlobPattern {
404 pattern: raw,
405 reason: "parent traversal is not allowed".to_string(),
406 });
407 }
408 Component::Normal(value) => {
409 let component = value.to_string_lossy();
410 matchers.push(compile_glob_component(&component, &raw)?);
411 components.push(component.into_owned());
412 }
413 }
414 }
415 if components.is_empty() {
416 return Err(PolicyError::InvalidGlobPattern {
417 pattern: raw,
418 reason: "pattern must contain at least one component".to_string(),
419 });
420 }
421 Ok(Self {
422 raw,
423 absolute,
424 prefix,
425 components,
426 matchers,
427 })
428 }
429}
430
431pub(crate) fn selectors_equal(left: &PathSelector, right: &PathSelector) -> bool {
432 match (&left.kind, &right.kind) {
433 (PathSelectorKind::Absolute(left), PathSelectorKind::Absolute(right))
434 | (PathSelectorKind::WorkspaceRoot(left), PathSelectorKind::WorkspaceRoot(right)) => {
435 paths_equal(left, right)
436 }
437 (PathSelectorKind::Root, PathSelectorKind::Root)
438 | (PathSelectorKind::Minimal, PathSelectorKind::Minimal)
439 | (PathSelectorKind::Tmpdir, PathSelectorKind::Tmpdir)
440 | (PathSelectorKind::SlashTmp, PathSelectorKind::SlashTmp) => true,
441 _ => false,
442 }
443}
444
445fn selector_kind_rank(kind: &PathSelectorKind) -> u8 {
446 match kind {
447 PathSelectorKind::Absolute(_) => 0,
448 PathSelectorKind::WorkspaceRoot(_) => 1,
449 PathSelectorKind::Root => 2,
450 PathSelectorKind::Minimal => 3,
451 PathSelectorKind::Tmpdir => 4,
452 PathSelectorKind::SlashTmp => 5,
453 }
454}
455
456fn normalize_relative_path(relative: PathBuf) -> Result<PathBuf, PolicyError> {
457 if relative.as_os_str().is_empty() {
458 return Err(PolicyError::EmptyPath);
459 }
460 if contains_nul(&relative) {
461 return Err(PolicyError::PathContainsNul { path: relative });
462 }
463 if relative.is_absolute() {
464 return Err(PolicyError::ExpectedRelative { path: relative });
465 }
466 let mut normalized = PathBuf::new();
467 for component in relative.components() {
468 match component {
469 Component::CurDir => {}
470 Component::Normal(part) => normalized.push(part),
471 Component::ParentDir => {
472 return Err(PolicyError::ParentTraversal { path: relative });
473 }
474 Component::RootDir | Component::Prefix(_) => {
475 return Err(PolicyError::ExpectedRelative { path: relative });
476 }
477 }
478 }
479 if normalized.as_os_str().is_empty() {
480 normalized.push(".");
481 }
482 Ok(normalized)
483}
484
485pub(crate) fn contains_nul(path: &Path) -> bool {
486 path.as_os_str().to_string_lossy().contains('\0')
487}
488
489fn path_components(path: &Path) -> (Option<String>, Vec<String>) {
490 let normalized_path = normalize_lexical_path(path);
491 let path = normalized_path.as_ref();
492 let mut prefix = None;
493 let mut components = Vec::new();
494 for component in path.components() {
495 match component {
496 Component::Prefix(value) => {
497 prefix = Some(value.as_os_str().to_string_lossy().into_owned());
498 }
499 Component::RootDir | Component::CurDir => {}
500 Component::ParentDir | Component::Normal(_) => {
501 if let Component::Normal(value) = component {
502 components.push(value.to_string_lossy().into_owned());
503 }
504 }
505 }
506 }
507 (prefix, components)
508}
509
510pub(crate) fn normal_component_count(path: &Path) -> usize {
511 path.components()
512 .filter(|component| matches!(component, Component::Normal(_)))
513 .count()
514}
515
516fn relative_path_components(path: &Path, root: &Path) -> Option<Vec<String>> {
517 let (path_prefix, path_parts) = path_components(path);
518 let (root_prefix, root_components) = path_components(root);
519 if !prefixes_equal(path_prefix.as_deref(), root_prefix.as_deref())
520 || root_components.len() > path_parts.len()
521 || !root_components
522 .iter()
523 .zip(&path_parts)
524 .all(|(root, path)| strings_equal(path, root))
525 {
526 return None;
527 }
528 Some(path_parts[root_components.len()..].to_vec())
529}
530
531fn glob_components_match(pattern: &[String], matchers: &[GlobMatcher], path: &[String]) -> bool {
532 let mut current = vec![false; path.len() + 1];
533 let mut next = vec![false; path.len() + 1];
534 current[0] = true;
535 for (pattern_index, pattern_component) in pattern.iter().enumerate() {
536 next.fill(false);
537 if pattern_component == "**" {
538 next[0] = current[0];
539 for index in 1..=path.len() {
540 next[index] = current[index] || next[index - 1];
541 }
542 } else {
543 let matcher = &matchers[pattern_index];
544 for index in 1..=path.len() {
545 next[index] =
546 current[index - 1] && glob_component_matches(matcher, &path[index - 1]);
547 }
548 }
549 std::mem::swap(&mut current, &mut next);
550 }
551 current[path.len()]
552}
553
554fn compile_glob_component(component: &str, pattern: &str) -> Result<GlobMatcher, PolicyError> {
555 let component = case_fold(component);
561 let mut builder = GlobBuilder::new(&component);
562 builder
563 .case_insensitive(false)
564 .literal_separator(true)
565 .backslash_escape(false);
566 builder
567 .build()
568 .map(|glob| glob.compile_matcher())
569 .map_err(|error| PolicyError::InvalidGlobPattern {
570 pattern: pattern.to_string(),
571 reason: format!("invalid glob syntax: {error}"),
572 })
573}
574
575fn contains_glob_meta(component: &str) -> bool {
576 component
577 .chars()
578 .any(|character| matches!(character, '*' | '?' | '[' | ']' | '{' | '}'))
579}
580
581fn prefixes_equal(left: Option<&str>, right: Option<&str>) -> bool {
582 match (left, right) {
583 (Some(left), Some(right)) => strings_equal(left, right),
584 (None, None) => true,
585 _ => false,
586 }
587}
588
589fn glob_component_matches(matcher: &GlobMatcher, component: &str) -> bool {
590 #[cfg(windows)]
591 {
592 matcher.is_match(case_fold(component))
593 }
594 #[cfg(not(windows))]
595 {
596 matcher.is_match(component)
597 }
598}