1use std::fmt;
9use std::path::{Path, PathBuf};
10
11pub const DEFAULT_CANONICAL_PROJECT_ROOT: &str = "/data/projects";
13
14pub const DEFAULT_ALIAS_PROJECT_ROOT: &str = "/dp";
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct PathTopologyPolicy {
20 canonical_root: PathBuf,
21 alias_root: PathBuf,
22}
23
24impl PathTopologyPolicy {
25 pub fn new(canonical_root: PathBuf, alias_root: PathBuf) -> Self {
27 Self {
28 canonical_root,
29 alias_root,
30 }
31 }
32
33 pub fn canonical_root(&self) -> &Path {
35 &self.canonical_root
36 }
37
38 pub fn alias_root(&self) -> &Path {
40 &self.alias_root
41 }
42}
43
44impl Default for PathTopologyPolicy {
45 fn default() -> Self {
46 Self {
47 canonical_root: PathBuf::from(DEFAULT_CANONICAL_PROJECT_ROOT),
48 alias_root: PathBuf::from(DEFAULT_ALIAS_PROJECT_ROOT),
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum NormalizationDecision {
56 ReceivedInput(PathBuf),
57 VerifiedAbsoluteInput(PathBuf),
58 AliasPrefixDetected(PathBuf),
59 AliasSymlinkVerified {
60 alias_root: PathBuf,
61 alias_target: PathBuf,
62 },
63 AliasSubtreeSymlinkVerified {
64 alias_root: PathBuf,
65 alias_target: PathBuf,
66 canonical_root: PathBuf,
67 },
68 AliasDirectoryEntryVerified {
69 alias_root: PathBuf,
70 canonical_input: PathBuf,
71 },
72 CanonicalRootResolved(PathBuf),
73 CanonicalInputResolved(PathBuf),
74 VerifiedWithinCanonicalRoot {
75 canonical_path: PathBuf,
76 canonical_root: PathBuf,
77 },
78}
79
80impl fmt::Display for NormalizationDecision {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 match self {
83 Self::ReceivedInput(path) => write!(f, "received_input={}", path.display()),
84 Self::VerifiedAbsoluteInput(path) => {
85 write!(f, "verified_absolute_input={}", path.display())
86 }
87 Self::AliasPrefixDetected(alias_root) => {
88 write!(f, "alias_prefix_detected={}", alias_root.display())
89 }
90 Self::AliasSymlinkVerified {
91 alias_root,
92 alias_target,
93 } => write!(
94 f,
95 "alias_symlink_verified={} -> {}",
96 alias_root.display(),
97 alias_target.display()
98 ),
99 Self::AliasSubtreeSymlinkVerified {
100 alias_root,
101 alias_target,
102 canonical_root,
103 } => write!(
104 f,
105 "alias_subtree_symlink_verified={} -> {} root={}",
106 alias_root.display(),
107 alias_target.display(),
108 canonical_root.display()
109 ),
110 Self::AliasDirectoryEntryVerified {
111 alias_root,
112 canonical_input,
113 } => write!(
114 f,
115 "alias_directory_entry_verified={} -> {}",
116 alias_root.display(),
117 canonical_input.display()
118 ),
119 Self::CanonicalRootResolved(path) => {
120 write!(f, "canonical_root_resolved={}", path.display())
121 }
122 Self::CanonicalInputResolved(path) => {
123 write!(f, "canonical_input_resolved={}", path.display())
124 }
125 Self::VerifiedWithinCanonicalRoot {
126 canonical_path,
127 canonical_root,
128 } => write!(
129 f,
130 "verified_within_root={} root={}",
131 canonical_path.display(),
132 canonical_root.display()
133 ),
134 }
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct NormalizedProjectPath {
141 canonical_path: PathBuf,
142 canonical_root: PathBuf,
143 used_alias_prefix: bool,
144 decisions: Vec<NormalizationDecision>,
145}
146
147impl NormalizedProjectPath {
148 pub fn canonical_path(&self) -> &Path {
150 &self.canonical_path
151 }
152
153 pub fn canonical_root(&self) -> &Path {
155 &self.canonical_root
156 }
157
158 pub fn used_alias_prefix(&self) -> bool {
160 self.used_alias_prefix
161 }
162
163 pub fn decision_trace(&self) -> &[NormalizationDecision] {
165 &self.decisions
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum PathNormalizationErrorKind {
172 NotAbsoluteInput,
173 CanonicalRootMissing,
174 CanonicalRootResolveFailed,
175 AliasMissing,
176 AliasNotSymlink,
177 AliasReadLinkFailed,
178 AliasTargetResolveFailed,
179 AliasWrongTarget,
180 InputResolveFailed,
181 OutsideCanonicalRoot,
182}
183
184impl fmt::Display for PathNormalizationErrorKind {
185 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186 match self {
187 Self::NotAbsoluteInput => write!(f, "input path is not absolute"),
188 Self::CanonicalRootMissing => write!(f, "canonical root is missing"),
189 Self::CanonicalRootResolveFailed => write!(f, "failed to resolve canonical root"),
190 Self::AliasMissing => write!(f, "alias root is missing"),
191 Self::AliasNotSymlink => write!(f, "alias root is not a symlink"),
192 Self::AliasReadLinkFailed => write!(f, "failed to read alias symlink"),
193 Self::AliasTargetResolveFailed => write!(f, "failed to resolve alias target"),
194 Self::AliasWrongTarget => write!(f, "alias points to unexpected target"),
195 Self::InputResolveFailed => write!(f, "failed to resolve input path"),
196 Self::OutsideCanonicalRoot => write!(f, "input resolves outside canonical root"),
197 }
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct PathNormalizationError {
204 kind: PathNormalizationErrorKind,
205 input_path: PathBuf,
206 detail: String,
207 decisions: Vec<NormalizationDecision>,
208}
209
210impl PathNormalizationError {
211 fn new(
212 kind: PathNormalizationErrorKind,
213 input_path: &Path,
214 detail: impl Into<String>,
215 decisions: &[NormalizationDecision],
216 ) -> Self {
217 Self {
218 kind,
219 input_path: input_path.to_path_buf(),
220 detail: detail.into(),
221 decisions: decisions.to_vec(),
222 }
223 }
224
225 pub fn kind(&self) -> &PathNormalizationErrorKind {
227 &self.kind
228 }
229
230 pub fn detail(&self) -> &str {
232 &self.detail
233 }
234
235 pub fn decision_trace(&self) -> &[NormalizationDecision] {
237 &self.decisions
238 }
239}
240
241impl fmt::Display for PathNormalizationError {
242 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243 write!(
244 f,
245 "{} (input: {}, detail: {})",
246 self.kind,
247 self.input_path.display(),
248 self.detail
249 )
250 }
251}
252
253impl std::error::Error for PathNormalizationError {}
254
255pub fn normalize_project_path(
257 path: &Path,
258) -> Result<NormalizedProjectPath, PathNormalizationError> {
259 normalize_project_path_with_policy(path, &PathTopologyPolicy::default())
260}
261
262pub fn normalize_project_path_with_policy(
264 path: &Path,
265 policy: &PathTopologyPolicy,
266) -> Result<NormalizedProjectPath, PathNormalizationError> {
267 let mut decisions = vec![NormalizationDecision::ReceivedInput(path.to_path_buf())];
268
269 if !path.is_absolute() {
270 return Err(PathNormalizationError::new(
271 PathNormalizationErrorKind::NotAbsoluteInput,
272 path,
273 "path must be absolute",
274 &decisions,
275 ));
276 }
277 decisions.push(NormalizationDecision::VerifiedAbsoluteInput(
278 path.to_path_buf(),
279 ));
280
281 let canonical_root = resolve_canonical_root(path, policy, &mut decisions)?;
282
283 let used_alias_prefix =
286 policy.alias_root() != policy.canonical_root() && path.starts_with(policy.alias_root());
287 let alias_mapped_input = if used_alias_prefix {
288 decisions.push(NormalizationDecision::AliasPrefixDetected(
289 policy.alias_root().to_path_buf(),
290 ));
291 verify_alias(path, policy.alias_root(), &canonical_root, &mut decisions)?
292 } else {
293 None
294 };
295
296 let canonical_input = if let Some(alias_mapped_input) = alias_mapped_input {
297 alias_mapped_input
298 } else {
299 std::fs::canonicalize(path).map_err(|e| {
300 PathNormalizationError::new(
301 PathNormalizationErrorKind::InputResolveFailed,
302 path,
303 e.to_string(),
304 &decisions,
305 )
306 })?
307 };
308 decisions.push(NormalizationDecision::CanonicalInputResolved(
309 canonical_input.clone(),
310 ));
311
312 if !canonical_input.starts_with(&canonical_root) {
313 return Err(PathNormalizationError::new(
314 PathNormalizationErrorKind::OutsideCanonicalRoot,
315 path,
316 format!(
317 "resolved={} root={}",
318 canonical_input.display(),
319 canonical_root.display()
320 ),
321 &decisions,
322 ));
323 }
324 decisions.push(NormalizationDecision::VerifiedWithinCanonicalRoot {
325 canonical_path: canonical_input.clone(),
326 canonical_root: canonical_root.clone(),
327 });
328
329 Ok(NormalizedProjectPath {
330 canonical_path: canonical_input,
331 canonical_root,
332 used_alias_prefix,
333 decisions,
334 })
335}
336
337fn resolve_canonical_root(
338 input_path: &Path,
339 policy: &PathTopologyPolicy,
340 decisions: &mut Vec<NormalizationDecision>,
341) -> Result<PathBuf, PathNormalizationError> {
342 if !policy.canonical_root().exists() {
343 if let Some(alias_target) = try_resolve_alias_target(policy.alias_root()) {
344 decisions.push(NormalizationDecision::CanonicalRootResolved(
345 alias_target.clone(),
346 ));
347 return Ok(alias_target);
348 }
349
350 return Err(PathNormalizationError::new(
351 PathNormalizationErrorKind::CanonicalRootMissing,
352 input_path,
353 format!("missing root {}", policy.canonical_root().display()),
354 decisions,
355 ));
356 }
357
358 let canonical_root = std::fs::canonicalize(policy.canonical_root()).map_err(|e| {
359 PathNormalizationError::new(
360 PathNormalizationErrorKind::CanonicalRootResolveFailed,
361 input_path,
362 e.to_string(),
363 decisions,
364 )
365 })?;
366 decisions.push(NormalizationDecision::CanonicalRootResolved(
367 canonical_root.clone(),
368 ));
369 Ok(canonical_root)
370}
371
372fn try_resolve_alias_target(alias_root: &Path) -> Option<PathBuf> {
373 let metadata = std::fs::symlink_metadata(alias_root).ok()?;
374 if !metadata.file_type().is_symlink() {
375 return None;
376 }
377
378 let raw_target = std::fs::read_link(alias_root).ok()?;
379 let absolute_target = if raw_target.is_absolute() {
380 raw_target
381 } else {
382 alias_root
383 .parent()
384 .unwrap_or_else(|| Path::new("/"))
385 .join(raw_target)
386 };
387
388 std::fs::canonicalize(absolute_target).ok()
389}
390
391fn verify_alias(
392 input_path: &Path,
393 alias_root: &Path,
394 canonical_root: &Path,
395 decisions: &mut Vec<NormalizationDecision>,
396) -> Result<Option<PathBuf>, PathNormalizationError> {
397 if alias_root == canonical_root {
401 return Ok(None);
402 }
403
404 let metadata = std::fs::symlink_metadata(alias_root).map_err(|e| {
405 let kind = if e.kind() == std::io::ErrorKind::NotFound {
406 PathNormalizationErrorKind::AliasMissing
407 } else {
408 PathNormalizationErrorKind::AliasReadLinkFailed
409 };
410 PathNormalizationError::new(kind, input_path, e.to_string(), decisions)
411 })?;
412
413 if !metadata.file_type().is_symlink() {
414 if let Some(canonical_input) =
415 try_map_alias_directory_entry(input_path, alias_root, canonical_root, decisions)?
416 {
417 return Ok(Some(canonical_input));
418 }
419
420 return Err(PathNormalizationError::new(
421 PathNormalizationErrorKind::AliasNotSymlink,
422 input_path,
423 format!("alias root is not a symlink: {}", alias_root.display()),
424 decisions,
425 ));
426 }
427
428 let raw_target = std::fs::read_link(alias_root).map_err(|e| {
429 PathNormalizationError::new(
430 PathNormalizationErrorKind::AliasReadLinkFailed,
431 input_path,
432 e.to_string(),
433 decisions,
434 )
435 })?;
436 let absolute_target = if raw_target.is_absolute() {
437 raw_target
438 } else {
439 alias_root
440 .parent()
441 .unwrap_or_else(|| Path::new("/"))
442 .join(raw_target)
443 };
444 let resolved_target = std::fs::canonicalize(&absolute_target).map_err(|e| {
445 PathNormalizationError::new(
446 PathNormalizationErrorKind::AliasTargetResolveFailed,
447 input_path,
448 e.to_string(),
449 decisions,
450 )
451 })?;
452
453 if resolved_target != canonical_root {
454 if resolved_target.starts_with(canonical_root) {
455 decisions.push(NormalizationDecision::AliasSubtreeSymlinkVerified {
456 alias_root: alias_root.to_path_buf(),
457 alias_target: resolved_target,
458 canonical_root: canonical_root.to_path_buf(),
459 });
460 return Ok(None);
461 }
462
463 if let Some(canonical_input) =
464 try_map_alias_directory_entry(input_path, alias_root, canonical_root, decisions)?
465 {
466 return Ok(Some(canonical_input));
467 }
468
469 return Err(PathNormalizationError::new(
470 PathNormalizationErrorKind::AliasWrongTarget,
471 input_path,
472 format!(
473 "expected={} actual={}",
474 canonical_root.display(),
475 resolved_target.display()
476 ),
477 decisions,
478 ));
479 }
480
481 decisions.push(NormalizationDecision::AliasSymlinkVerified {
482 alias_root: alias_root.to_path_buf(),
483 alias_target: resolved_target,
484 });
485 Ok(None)
486}
487
488fn try_map_alias_directory_entry(
489 input_path: &Path,
490 alias_root: &Path,
491 canonical_root: &Path,
492 decisions: &mut Vec<NormalizationDecision>,
493) -> Result<Option<PathBuf>, PathNormalizationError> {
494 let relative_input = input_path.strip_prefix(alias_root).map_err(|e| {
495 PathNormalizationError::new(
496 PathNormalizationErrorKind::AliasNotSymlink,
497 input_path,
498 e.to_string(),
499 decisions,
500 )
501 })?;
502 if relative_input.components().any(|component| {
503 matches!(
504 component,
505 std::path::Component::ParentDir
506 | std::path::Component::RootDir
507 | std::path::Component::Prefix(_)
508 )
509 }) {
510 return Err(PathNormalizationError::new(
511 PathNormalizationErrorKind::OutsideCanonicalRoot,
512 input_path,
513 "alias-relative path must stay within the alias root",
514 decisions,
515 ));
516 }
517
518 let canonical_input = canonical_root.join(relative_input);
519 if !canonical_input.exists() {
520 return Ok(None);
521 }
522
523 decisions.push(NormalizationDecision::AliasDirectoryEntryVerified {
524 alias_root: alias_root.to_path_buf(),
525 canonical_input: canonical_input.clone(),
526 });
527 Ok(Some(canonical_input))
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533 use std::fs;
534 use std::sync::atomic::{AtomicU64, Ordering};
535 use tracing::info;
536
537 #[cfg(unix)]
538 use std::os::unix::fs::{PermissionsExt, symlink};
539
540 static COUNTER: AtomicU64 = AtomicU64::new(0);
541
542 struct TestFixture {
543 root: PathBuf,
544 canonical_root: PathBuf,
545 alias_root: PathBuf,
546 }
547
548 impl TestFixture {
549 fn new(prefix: &str, create_alias: bool, alias_target: Option<&Path>) -> Self {
550 let id = COUNTER.fetch_add(1, Ordering::SeqCst);
551 let temp_root = std::env::temp_dir();
557 let temp_root = temp_root.canonicalize().unwrap_or(temp_root);
558 let root = temp_root.join(format!(
559 "rch-path-topology-{}-{}-{}",
560 prefix,
561 std::process::id(),
562 id
563 ));
564 let canonical_root = root.join("data/projects");
565 let alias_root = root.join("dp");
566
567 fs::create_dir_all(&canonical_root).expect("create canonical root");
568
569 #[cfg(unix)]
570 if create_alias {
571 let target = alias_target.unwrap_or(&canonical_root);
572 symlink(target, &alias_root).expect("create alias symlink");
573 }
574
575 #[cfg(not(unix))]
576 {
577 let _ = create_alias;
578 let _ = alias_target;
579 }
580
581 Self {
582 root,
583 canonical_root,
584 alias_root,
585 }
586 }
587
588 fn policy(&self) -> PathTopologyPolicy {
589 PathTopologyPolicy::new(self.canonical_root.clone(), self.alias_root.clone())
590 }
591 }
592
593 impl Drop for TestFixture {
594 fn drop(&mut self) {
595 let _ = fs::remove_dir_all(&self.root);
596 }
597 }
598
599 fn log_normalization_error(test_name: &str, err: &PathNormalizationError) {
600 info!(
601 test = test_name,
602 kind = ?err.kind(),
603 detail = %err.detail(),
604 decisions = ?err.decision_trace(),
605 "topology_normalization_error"
606 );
607 }
608
609 #[test]
610 fn normalize_direct_canonical_path() {
611 let fixture = TestFixture::new("direct", false, None);
612 let project = fixture.canonical_root.join("demo");
613 fs::create_dir_all(&project).expect("create project");
614
615 let normalized = normalize_project_path_with_policy(&project, &fixture.policy())
616 .expect("normalize canonical path");
617
618 assert_eq!(
619 normalized.canonical_path(),
620 project.canonicalize().expect("canonicalize project")
621 );
622 assert!(!normalized.used_alias_prefix());
623 assert!(normalized.decision_trace().len() >= 4);
624 }
625 #[test]
626 fn identical_roots_resolve_contained_parents_and_reject_escape() {
627 let fixture = TestFixture::new("identical-roots", false, None);
628 let nested = fixture.canonical_root.join("repo/crates/a");
629 let dependency = fixture.canonical_root.join("dep");
630 let outside = fixture.root.join("data/outside");
631 fs::create_dir_all(&nested).unwrap();
632 fs::create_dir_all(&dependency).unwrap();
633 fs::create_dir_all(&outside).unwrap();
634 let policy = PathTopologyPolicy::new(
635 fixture.canonical_root.clone(),
636 fixture.canonical_root.clone(),
637 );
638 let normalized =
639 normalize_project_path_with_policy(&nested.join("../../../dep"), &policy).unwrap();
640 assert_eq!(normalized.canonical_path(), dependency);
641 let error =
642 normalize_project_path_with_policy(&nested.join("../../../../outside"), &policy)
643 .unwrap_err();
644 assert_eq!(
645 error.kind(),
646 &PathNormalizationErrorKind::OutsideCanonicalRoot
647 );
648 }
649
650 #[test]
651 fn identical_roots_resolve_parent_components_within_boundary() {
652 let fixture = TestFixture::new("identical-roots", false, None);
653 let package = fixture.canonical_root.join("repo/crates/a");
654 let dependency = fixture.canonical_root.join("dep");
655 fs::create_dir_all(&package).expect("create package");
656 fs::create_dir_all(&dependency).expect("create dependency");
657 let policy = PathTopologyPolicy::new(
658 fixture.canonical_root.clone(),
659 fixture.canonical_root.clone(),
660 );
661 let normalized = normalize_project_path_with_policy(&package.join("../../../dep"), &policy)
662 .expect("resolve sibling dependency inside canonical root");
663 assert_eq!(normalized.canonical_path(), dependency);
664 assert!(!normalized.used_alias_prefix());
665
666 let outside = fixture.root.join("data/outside");
667 fs::create_dir_all(&outside).expect("create outside directory");
668 let error =
669 normalize_project_path_with_policy(&package.join("../../../../outside"), &policy)
670 .expect_err("actual root escape must remain rejected");
671 assert_eq!(
672 error.kind(),
673 &PathNormalizationErrorKind::OutsideCanonicalRoot
674 );
675 }
676
677 #[cfg(unix)]
678 #[test]
679 fn identical_roots_reject_symlink_escape() {
680 let fixture = TestFixture::new("identical-roots-symlink", false, None);
681 let outside = fixture.root.join("outside");
682 fs::create_dir_all(&outside).expect("create outside directory");
683 let link = fixture.canonical_root.join("dep");
684 symlink(&outside, &link).expect("create escaping symlink");
685 let policy = PathTopologyPolicy::new(
686 fixture.canonical_root.clone(),
687 fixture.canonical_root.clone(),
688 );
689 let error = normalize_project_path_with_policy(&link, &policy)
690 .expect_err("symlink must not escape equal-root policy");
691 assert_eq!(
692 error.kind(),
693 &PathNormalizationErrorKind::OutsideCanonicalRoot
694 );
695 }
696
697 #[cfg(unix)]
698 #[test]
699 fn normalize_alias_path_to_same_canonical_identity() {
700 let fixture = TestFixture::new("alias", true, None);
701 let project = fixture.canonical_root.join("repo");
702 fs::create_dir_all(&project).expect("create project");
703 let alias_project = fixture.alias_root.join("repo");
704
705 let from_alias = normalize_project_path_with_policy(&alias_project, &fixture.policy())
706 .expect("normalize alias project");
707 let from_canonical = normalize_project_path_with_policy(&project, &fixture.policy())
708 .expect("normalize canonical project");
709
710 assert!(from_alias.used_alias_prefix());
711 assert_eq!(from_alias.canonical_path(), from_canonical.canonical_path());
712 assert!(
713 from_alias
714 .decision_trace()
715 .iter()
716 .any(|d| matches!(d, NormalizationDecision::AliasSymlinkVerified { .. }))
717 );
718 }
719
720 #[test]
721 fn reject_relative_path_input() {
722 let fixture = TestFixture::new("relative", false, None);
723 let err = normalize_project_path_with_policy(Path::new("relative/repo"), &fixture.policy())
724 .expect_err("relative path must fail");
725 assert_eq!(err.kind(), &PathNormalizationErrorKind::NotAbsoluteInput);
726 }
727
728 #[test]
729 fn reject_path_outside_canonical_root() {
730 let fixture = TestFixture::new("outside", false, None);
731 let outside = fixture.root.join("outside");
732 fs::create_dir_all(&outside).expect("create outside path");
733
734 let err = normalize_project_path_with_policy(&outside, &fixture.policy())
735 .expect_err("outside root must fail");
736 log_normalization_error("reject_path_outside_canonical_root", &err);
737 assert_eq!(
738 err.kind(),
739 &PathNormalizationErrorKind::OutsideCanonicalRoot
740 );
741 assert!(
742 err.decision_trace()
743 .iter()
744 .any(|d| matches!(d, NormalizationDecision::CanonicalInputResolved(_)))
745 );
746 }
747
748 #[cfg(unix)]
749 #[test]
750 fn reject_missing_alias_for_alias_prefixed_input() {
751 let fixture = TestFixture::new("missing-alias", false, None);
752 let input = fixture.alias_root.join("repo");
753 let err = normalize_project_path_with_policy(&input, &fixture.policy())
754 .expect_err("missing alias must fail");
755 log_normalization_error("reject_missing_alias_for_alias_prefixed_input", &err);
756 assert_eq!(err.kind(), &PathNormalizationErrorKind::AliasMissing);
757 }
758
759 #[cfg(unix)]
760 #[test]
761 fn reject_alias_pointing_to_wrong_target() {
762 let fixture = TestFixture::new("wrong-target", false, None);
763 let other_target = fixture.root.join("other-projects");
764 fs::create_dir_all(&other_target).expect("create alternate target");
765 symlink(&other_target, &fixture.alias_root).expect("create wrong alias");
766 let alias_input = fixture.alias_root.join("repo");
767 fs::create_dir_all(&alias_input).expect("create alias repo path");
768
769 let err = normalize_project_path_with_policy(&alias_input, &fixture.policy())
770 .expect_err("alias wrong target must fail");
771 log_normalization_error("reject_alias_pointing_to_wrong_target", &err);
772 assert_eq!(err.kind(), &PathNormalizationErrorKind::AliasWrongTarget);
773 }
774
775 #[cfg(unix)]
776 #[test]
777 fn reject_alias_path_that_is_not_symlink() {
778 let fixture = TestFixture::new("alias-not-symlink", false, None);
779 fs::create_dir_all(&fixture.alias_root).expect("create alias directory");
780 let alias_input = fixture.alias_root.join("repo");
781 fs::create_dir_all(&alias_input).expect("create alias repo path");
782
783 let err = normalize_project_path_with_policy(&alias_input, &fixture.policy())
784 .expect_err("non-symlink alias must fail");
785 log_normalization_error("reject_alias_path_that_is_not_symlink", &err);
786 assert_eq!(err.kind(), &PathNormalizationErrorKind::AliasNotSymlink);
787 assert!(err.detail().contains("not a symlink"));
788 }
789
790 #[cfg(unix)]
791 #[test]
792 fn normalize_alias_directory_with_symlinked_repo_entry() {
793 let fixture = TestFixture::new("alias-dir-entry", false, None);
794 let user_root = fixture.root.join("users/jemanuel");
795 let canonical_projects = user_root.join("projects");
796 let canonical_project = canonical_projects.join("repo");
797 let alias_projects = fixture.root.join("data/projects");
798 let alias_input = alias_projects.join("repo");
799
800 fs::create_dir_all(&canonical_project).expect("create canonical project");
801 fs::create_dir_all(&alias_projects).expect("create alias directory");
802 symlink(&canonical_project, &alias_input).expect("create per-repo alias symlink");
803
804 let policy = PathTopologyPolicy::new(canonical_projects, alias_projects.clone());
805 let normalized = normalize_project_path_with_policy(&alias_input, &policy)
806 .expect("normalize per-repo alias entry");
807
808 assert!(normalized.used_alias_prefix());
809 assert_eq!(
810 normalized.canonical_path(),
811 canonical_project
812 .canonicalize()
813 .expect("canonicalize canonical project")
814 );
815 assert!(normalized.decision_trace().iter().any(|decision| {
816 matches!(
817 decision,
818 NormalizationDecision::AliasDirectoryEntryVerified { .. }
819 )
820 }));
821 }
822
823 #[cfg(unix)]
824 #[test]
825 fn normalize_alias_directory_entry_to_canonical_symlink_namespace() {
826 let fixture = TestFixture::new("alias-dir-entry-canonical-symlink", false, None);
827 let user_root = fixture.root.join("users/jemanuel");
828 let canonical_projects = user_root.join("projects");
829 let outside_projects = user_root.join("dp");
830 let outside_project = outside_projects.join("asupersync");
831 let canonical_project = canonical_projects.join("asupersync");
832 let alias_projects = fixture.root.join("data/projects");
833 let alias_input = alias_projects.join("asupersync");
834
835 fs::create_dir_all(&outside_project).expect("create outside project target");
836 fs::create_dir_all(&canonical_projects).expect("create canonical projects directory");
837 fs::create_dir_all(&alias_projects).expect("create alias directory");
838 symlink(&outside_project, &canonical_project).expect("create canonical repo symlink");
839 symlink(&canonical_project, &alias_input).expect("create per-repo alias symlink");
840
841 let policy = PathTopologyPolicy::new(canonical_projects, alias_projects);
842 let normalized = normalize_project_path_with_policy(&alias_input, &policy)
843 .expect("normalize alias entry to canonical namespace");
844
845 assert!(normalized.used_alias_prefix());
846 assert_eq!(normalized.canonical_path(), canonical_project);
847 assert_ne!(
848 normalized.canonical_path(),
849 outside_project
850 .canonicalize()
851 .expect("canonicalize outside project")
852 );
853 assert!(normalized.decision_trace().iter().any(|decision| {
854 matches!(
855 decision,
856 NormalizationDecision::AliasDirectoryEntryVerified { .. }
857 )
858 }));
859 }
860
861 #[cfg(unix)]
862 #[test]
863 fn reject_alias_directory_entry_parent_component_escape() {
864 let fixture = TestFixture::new("alias-dir-entry-parent", false, None);
865 let project = fixture.canonical_root.join("repo");
866 let alias_input = fixture.alias_root.join("../projects/repo");
867
868 fs::create_dir_all(&project).expect("create canonical project");
869 fs::create_dir_all(&fixture.alias_root).expect("create alias directory");
870
871 let err = normalize_project_path_with_policy(&alias_input, &fixture.policy())
872 .expect_err("parent components must not map through alias directory fallback");
873 log_normalization_error("reject_alias_directory_entry_parent_component_escape", &err);
874 assert_eq!(
875 err.kind(),
876 &PathNormalizationErrorKind::OutsideCanonicalRoot
877 );
878 }
879
880 #[cfg(unix)]
881 #[test]
882 fn normalize_symlink_alias_entry_to_canonical_subtree() {
883 let fixture = TestFixture::new("alias-symlink-entry-canonical-subtree", false, None);
884 let user_root = fixture.root.join("users/jemanuel");
885 let canonical_root = user_root.clone();
886 let canonical_project = canonical_root.join("projects/repo");
887 let alias_target = fixture.root.join("volumes/data");
888 let alias_root = fixture.root.join("alias-data");
889 let alias_input = alias_root.join("projects/repo");
890
891 fs::create_dir_all(&canonical_project).expect("create canonical project");
892 fs::create_dir_all(&alias_target).expect("create alias target directory");
893 symlink(&alias_target, &alias_root).expect("create /data-style alias symlink");
894
895 let policy = PathTopologyPolicy::new(canonical_root, alias_root);
896 let normalized = normalize_project_path_with_policy(&alias_input, &policy)
897 .expect("normalize /data/projects entry into canonical user subtree");
898
899 assert!(normalized.used_alias_prefix());
900 assert_eq!(
901 normalized.canonical_path(),
902 canonical_project
903 .canonicalize()
904 .expect("canonicalize canonical project")
905 );
906 assert!(normalized.decision_trace().iter().any(|decision| {
907 matches!(
908 decision,
909 NormalizationDecision::AliasDirectoryEntryVerified { .. }
910 )
911 }));
912 }
913
914 #[cfg(unix)]
915 #[test]
916 fn normalize_alias_symlink_target_inside_canonical_root() {
917 let fixture = TestFixture::new("alias-subtree-target", false, None);
918 let user_root = fixture.root.join("users/jemanuel");
919 let alias_target = user_root.join("dp");
920 let alias_root = fixture.root.join("dp");
921 let alias_project = alias_root.join("frankentui");
922 let canonical_project = alias_target.join("frankentui");
923
924 fs::create_dir_all(&canonical_project).expect("create canonical subtree project");
925 symlink(&alias_target, &alias_root).expect("create /dp-style subtree alias");
926
927 let policy = PathTopologyPolicy::new(user_root.clone(), alias_root);
928 let normalized = normalize_project_path_with_policy(&alias_project, &policy)
929 .expect("normalize alias rooted at canonical subtree");
930
931 assert!(normalized.used_alias_prefix());
932 assert_eq!(
933 normalized.canonical_path(),
934 canonical_project
935 .canonicalize()
936 .expect("canonicalize subtree project")
937 );
938 assert_eq!(
939 normalized.canonical_root(),
940 user_root.canonicalize().expect("canonicalize user root")
941 );
942 assert!(normalized.decision_trace().iter().any(|decision| {
943 matches!(
944 decision,
945 NormalizationDecision::AliasSubtreeSymlinkVerified { .. }
946 )
947 }));
948 }
949
950 #[cfg(unix)]
951 #[test]
952 fn reject_alias_symlink_loop() {
953 let fixture = TestFixture::new("alias-loop", false, None);
954 symlink("dp", &fixture.alias_root).expect("create alias symlink loop");
955 let alias_input = fixture.alias_root.join("repo");
956
957 let err = normalize_project_path_with_policy(&alias_input, &fixture.policy())
958 .expect_err("alias loop must fail");
959 log_normalization_error("reject_alias_symlink_loop", &err);
960 assert_eq!(
961 err.kind(),
962 &PathNormalizationErrorKind::AliasTargetResolveFailed
963 );
964 assert!(
965 err.decision_trace()
966 .iter()
967 .any(|decision| matches!(decision, NormalizationDecision::AliasPrefixDetected(_)))
968 );
969 }
970
971 #[cfg(unix)]
972 #[test]
973 fn reject_permission_denied_during_canonical_resolution() {
974 let fixture = TestFixture::new("permission-denied", false, None);
975 let project = fixture.canonical_root.join("repo");
976 fs::create_dir_all(&project).expect("create project path");
977
978 let original_permissions = fs::metadata(&fixture.canonical_root)
979 .expect("read canonical root metadata")
980 .permissions();
981 let mut denied_permissions = original_permissions.clone();
982 denied_permissions.set_mode(0o000);
983 fs::set_permissions(&fixture.canonical_root, denied_permissions)
984 .expect("lock canonical root permissions");
985
986 let result = normalize_project_path_with_policy(&project, &fixture.policy());
987
988 fs::set_permissions(&fixture.canonical_root, original_permissions)
989 .expect("restore canonical root permissions");
990
991 let err = match result {
992 Ok(_) => {
993 return;
995 }
996 Err(err) => err,
997 };
998 log_normalization_error("reject_permission_denied_during_canonical_resolution", &err);
999 assert!(matches!(
1000 err.kind(),
1001 PathNormalizationErrorKind::CanonicalRootResolveFailed
1002 | PathNormalizationErrorKind::InputResolveFailed
1003 ));
1004 }
1005
1006 #[test]
1007 fn reject_when_canonical_root_missing() {
1008 let fixture = TestFixture::new("missing-root", false, None);
1009 let missing_root = fixture.root.join("does-not-exist");
1010 let policy = PathTopologyPolicy::new(missing_root.clone(), fixture.alias_root.clone());
1011 let outside = fixture.root.join("somewhere");
1012 fs::create_dir_all(&outside).expect("create input");
1013
1014 let err = normalize_project_path_with_policy(&outside, &policy)
1015 .expect_err("missing canonical root must fail");
1016 log_normalization_error("reject_when_canonical_root_missing", &err);
1017 assert_eq!(
1018 err.kind(),
1019 &PathNormalizationErrorKind::CanonicalRootMissing
1020 );
1021 assert!(
1022 err.detail()
1023 .contains(missing_root.to_string_lossy().as_ref())
1024 );
1025 }
1026
1027 #[test]
1032 fn canonical_root_missing_error_cites_configured_root_not_default() {
1033 let fixture = TestFixture::new("missing-custom-root", false, None);
1034 let missing_root = fixture.root.join("custom-missing-root");
1035 let missing_alias = fixture.root.join("custom-missing-alias");
1036 let policy = PathTopologyPolicy::new(missing_root.clone(), missing_alias);
1037
1038 let probe = fixture.root.join("some-project");
1041 let err = normalize_project_path_with_policy(&probe, &policy)
1042 .expect_err("normalization must fail when canonical root is missing");
1043
1044 assert!(
1045 matches!(err.kind(), PathNormalizationErrorKind::CanonicalRootMissing),
1046 "expected CanonicalRootMissing, got {:?}",
1047 err.kind()
1048 );
1049 let rendered = err.to_string();
1050 assert!(
1051 rendered.contains(&missing_root.display().to_string()),
1052 "error should mention configured canonical root {}: {}",
1053 missing_root.display(),
1054 rendered
1055 );
1056 let stripped = rendered.replace(fixture.root.to_string_lossy().as_ref(), "");
1062 assert!(
1063 !stripped.contains(DEFAULT_CANONICAL_PROJECT_ROOT),
1064 "error must not leak default {} when a custom canonical_root \
1065 is configured. got: {}",
1066 DEFAULT_CANONICAL_PROJECT_ROOT,
1067 rendered
1068 );
1069 }
1070
1071 #[cfg(unix)]
1072 #[test]
1073 fn normalize_direct_path_via_alias_target_when_canonical_root_missing() {
1074 let fixture = TestFixture::new("alias-target-root", true, None);
1075 let missing_root = fixture.root.join("does-not-exist");
1076 let policy = PathTopologyPolicy::new(missing_root, fixture.alias_root.clone());
1077 let project = fixture.canonical_root.join("repo");
1078 fs::create_dir_all(&project).expect("create project");
1079
1080 let normalized = normalize_project_path_with_policy(&project, &policy)
1081 .expect("normalize path using alias target fallback");
1082
1083 assert_eq!(
1084 normalized.canonical_root(),
1085 fixture
1086 .canonical_root
1087 .canonicalize()
1088 .expect("canonicalize alias target root")
1089 );
1090 assert_eq!(
1091 normalized.canonical_path(),
1092 project.canonicalize().expect("canonicalize project")
1093 );
1094 assert!(!normalized.used_alias_prefix());
1095 }
1096}