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 AliasDirectoryEntryVerified {
64 alias_root: PathBuf,
65 canonical_input: PathBuf,
66 },
67 CanonicalRootResolved(PathBuf),
68 CanonicalInputResolved(PathBuf),
69 VerifiedWithinCanonicalRoot {
70 canonical_path: PathBuf,
71 canonical_root: PathBuf,
72 },
73}
74
75impl fmt::Display for NormalizationDecision {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 match self {
78 Self::ReceivedInput(path) => write!(f, "received_input={}", path.display()),
79 Self::VerifiedAbsoluteInput(path) => {
80 write!(f, "verified_absolute_input={}", path.display())
81 }
82 Self::AliasPrefixDetected(alias_root) => {
83 write!(f, "alias_prefix_detected={}", alias_root.display())
84 }
85 Self::AliasSymlinkVerified {
86 alias_root,
87 alias_target,
88 } => write!(
89 f,
90 "alias_symlink_verified={} -> {}",
91 alias_root.display(),
92 alias_target.display()
93 ),
94 Self::AliasDirectoryEntryVerified {
95 alias_root,
96 canonical_input,
97 } => write!(
98 f,
99 "alias_directory_entry_verified={} -> {}",
100 alias_root.display(),
101 canonical_input.display()
102 ),
103 Self::CanonicalRootResolved(path) => {
104 write!(f, "canonical_root_resolved={}", path.display())
105 }
106 Self::CanonicalInputResolved(path) => {
107 write!(f, "canonical_input_resolved={}", path.display())
108 }
109 Self::VerifiedWithinCanonicalRoot {
110 canonical_path,
111 canonical_root,
112 } => write!(
113 f,
114 "verified_within_root={} root={}",
115 canonical_path.display(),
116 canonical_root.display()
117 ),
118 }
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct NormalizedProjectPath {
125 canonical_path: PathBuf,
126 canonical_root: PathBuf,
127 used_alias_prefix: bool,
128 decisions: Vec<NormalizationDecision>,
129}
130
131impl NormalizedProjectPath {
132 pub fn canonical_path(&self) -> &Path {
134 &self.canonical_path
135 }
136
137 pub fn canonical_root(&self) -> &Path {
139 &self.canonical_root
140 }
141
142 pub fn used_alias_prefix(&self) -> bool {
144 self.used_alias_prefix
145 }
146
147 pub fn decision_trace(&self) -> &[NormalizationDecision] {
149 &self.decisions
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum PathNormalizationErrorKind {
156 NotAbsoluteInput,
157 CanonicalRootMissing,
158 CanonicalRootResolveFailed,
159 AliasMissing,
160 AliasNotSymlink,
161 AliasReadLinkFailed,
162 AliasTargetResolveFailed,
163 AliasWrongTarget,
164 InputResolveFailed,
165 OutsideCanonicalRoot,
166}
167
168impl fmt::Display for PathNormalizationErrorKind {
169 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170 match self {
171 Self::NotAbsoluteInput => write!(f, "input path is not absolute"),
172 Self::CanonicalRootMissing => write!(f, "canonical root is missing"),
173 Self::CanonicalRootResolveFailed => write!(f, "failed to resolve canonical root"),
174 Self::AliasMissing => write!(f, "alias root is missing"),
175 Self::AliasNotSymlink => write!(f, "alias root is not a symlink"),
176 Self::AliasReadLinkFailed => write!(f, "failed to read alias symlink"),
177 Self::AliasTargetResolveFailed => write!(f, "failed to resolve alias target"),
178 Self::AliasWrongTarget => write!(f, "alias points to unexpected target"),
179 Self::InputResolveFailed => write!(f, "failed to resolve input path"),
180 Self::OutsideCanonicalRoot => write!(f, "input resolves outside canonical root"),
181 }
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct PathNormalizationError {
188 kind: PathNormalizationErrorKind,
189 input_path: PathBuf,
190 detail: String,
191 decisions: Vec<NormalizationDecision>,
192}
193
194impl PathNormalizationError {
195 fn new(
196 kind: PathNormalizationErrorKind,
197 input_path: &Path,
198 detail: impl Into<String>,
199 decisions: &[NormalizationDecision],
200 ) -> Self {
201 Self {
202 kind,
203 input_path: input_path.to_path_buf(),
204 detail: detail.into(),
205 decisions: decisions.to_vec(),
206 }
207 }
208
209 pub fn kind(&self) -> &PathNormalizationErrorKind {
211 &self.kind
212 }
213
214 pub fn detail(&self) -> &str {
216 &self.detail
217 }
218
219 pub fn decision_trace(&self) -> &[NormalizationDecision] {
221 &self.decisions
222 }
223}
224
225impl fmt::Display for PathNormalizationError {
226 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227 write!(
228 f,
229 "{} (input: {}, detail: {})",
230 self.kind,
231 self.input_path.display(),
232 self.detail
233 )
234 }
235}
236
237impl std::error::Error for PathNormalizationError {}
238
239pub fn normalize_project_path(
241 path: &Path,
242) -> Result<NormalizedProjectPath, PathNormalizationError> {
243 normalize_project_path_with_policy(path, &PathTopologyPolicy::default())
244}
245
246pub fn normalize_project_path_with_policy(
248 path: &Path,
249 policy: &PathTopologyPolicy,
250) -> Result<NormalizedProjectPath, PathNormalizationError> {
251 let mut decisions = vec![NormalizationDecision::ReceivedInput(path.to_path_buf())];
252
253 if !path.is_absolute() {
254 return Err(PathNormalizationError::new(
255 PathNormalizationErrorKind::NotAbsoluteInput,
256 path,
257 "path must be absolute",
258 &decisions,
259 ));
260 }
261 decisions.push(NormalizationDecision::VerifiedAbsoluteInput(
262 path.to_path_buf(),
263 ));
264
265 let canonical_root = resolve_canonical_root(path, policy, &mut decisions)?;
266
267 let used_alias_prefix = path.starts_with(policy.alias_root());
268 let alias_mapped_input = if used_alias_prefix {
269 decisions.push(NormalizationDecision::AliasPrefixDetected(
270 policy.alias_root().to_path_buf(),
271 ));
272 verify_alias(path, policy.alias_root(), &canonical_root, &mut decisions)?
273 } else {
274 None
275 };
276
277 let canonical_input = if let Some(alias_mapped_input) = alias_mapped_input {
278 alias_mapped_input
279 } else {
280 std::fs::canonicalize(path).map_err(|e| {
281 PathNormalizationError::new(
282 PathNormalizationErrorKind::InputResolveFailed,
283 path,
284 e.to_string(),
285 &decisions,
286 )
287 })?
288 };
289 decisions.push(NormalizationDecision::CanonicalInputResolved(
290 canonical_input.clone(),
291 ));
292
293 if !canonical_input.starts_with(&canonical_root) {
294 return Err(PathNormalizationError::new(
295 PathNormalizationErrorKind::OutsideCanonicalRoot,
296 path,
297 format!(
298 "resolved={} root={}",
299 canonical_input.display(),
300 canonical_root.display()
301 ),
302 &decisions,
303 ));
304 }
305 decisions.push(NormalizationDecision::VerifiedWithinCanonicalRoot {
306 canonical_path: canonical_input.clone(),
307 canonical_root: canonical_root.clone(),
308 });
309
310 Ok(NormalizedProjectPath {
311 canonical_path: canonical_input,
312 canonical_root,
313 used_alias_prefix,
314 decisions,
315 })
316}
317
318fn resolve_canonical_root(
319 input_path: &Path,
320 policy: &PathTopologyPolicy,
321 decisions: &mut Vec<NormalizationDecision>,
322) -> Result<PathBuf, PathNormalizationError> {
323 if !policy.canonical_root().exists() {
324 if let Some(alias_target) = try_resolve_alias_target(policy.alias_root()) {
325 decisions.push(NormalizationDecision::CanonicalRootResolved(
326 alias_target.clone(),
327 ));
328 return Ok(alias_target);
329 }
330
331 return Err(PathNormalizationError::new(
332 PathNormalizationErrorKind::CanonicalRootMissing,
333 input_path,
334 format!("missing root {}", policy.canonical_root().display()),
335 decisions,
336 ));
337 }
338
339 let canonical_root = std::fs::canonicalize(policy.canonical_root()).map_err(|e| {
340 PathNormalizationError::new(
341 PathNormalizationErrorKind::CanonicalRootResolveFailed,
342 input_path,
343 e.to_string(),
344 decisions,
345 )
346 })?;
347 decisions.push(NormalizationDecision::CanonicalRootResolved(
348 canonical_root.clone(),
349 ));
350 Ok(canonical_root)
351}
352
353fn try_resolve_alias_target(alias_root: &Path) -> Option<PathBuf> {
354 let metadata = std::fs::symlink_metadata(alias_root).ok()?;
355 if !metadata.file_type().is_symlink() {
356 return None;
357 }
358
359 let raw_target = std::fs::read_link(alias_root).ok()?;
360 let absolute_target = if raw_target.is_absolute() {
361 raw_target
362 } else {
363 alias_root
364 .parent()
365 .unwrap_or_else(|| Path::new("/"))
366 .join(raw_target)
367 };
368
369 std::fs::canonicalize(absolute_target).ok()
370}
371
372fn verify_alias(
373 input_path: &Path,
374 alias_root: &Path,
375 canonical_root: &Path,
376 decisions: &mut Vec<NormalizationDecision>,
377) -> Result<Option<PathBuf>, PathNormalizationError> {
378 let metadata = std::fs::symlink_metadata(alias_root).map_err(|e| {
379 let kind = if e.kind() == std::io::ErrorKind::NotFound {
380 PathNormalizationErrorKind::AliasMissing
381 } else {
382 PathNormalizationErrorKind::AliasReadLinkFailed
383 };
384 PathNormalizationError::new(kind, input_path, e.to_string(), decisions)
385 })?;
386
387 if !metadata.file_type().is_symlink() {
388 let relative_input = input_path.strip_prefix(alias_root).map_err(|e| {
389 PathNormalizationError::new(
390 PathNormalizationErrorKind::AliasNotSymlink,
391 input_path,
392 e.to_string(),
393 decisions,
394 )
395 })?;
396 let canonical_input = canonical_root.join(relative_input);
397 if canonical_input.exists() {
398 decisions.push(NormalizationDecision::AliasDirectoryEntryVerified {
399 alias_root: alias_root.to_path_buf(),
400 canonical_input: canonical_input.clone(),
401 });
402 return Ok(Some(canonical_input));
403 }
404
405 return Err(PathNormalizationError::new(
406 PathNormalizationErrorKind::AliasNotSymlink,
407 input_path,
408 format!("alias root is not a symlink: {}", alias_root.display()),
409 decisions,
410 ));
411 }
412
413 let raw_target = std::fs::read_link(alias_root).map_err(|e| {
414 PathNormalizationError::new(
415 PathNormalizationErrorKind::AliasReadLinkFailed,
416 input_path,
417 e.to_string(),
418 decisions,
419 )
420 })?;
421 let absolute_target = if raw_target.is_absolute() {
422 raw_target
423 } else {
424 alias_root
425 .parent()
426 .unwrap_or_else(|| Path::new("/"))
427 .join(raw_target)
428 };
429 let resolved_target = std::fs::canonicalize(&absolute_target).map_err(|e| {
430 PathNormalizationError::new(
431 PathNormalizationErrorKind::AliasTargetResolveFailed,
432 input_path,
433 e.to_string(),
434 decisions,
435 )
436 })?;
437
438 if resolved_target != canonical_root {
439 return Err(PathNormalizationError::new(
440 PathNormalizationErrorKind::AliasWrongTarget,
441 input_path,
442 format!(
443 "expected={} actual={}",
444 canonical_root.display(),
445 resolved_target.display()
446 ),
447 decisions,
448 ));
449 }
450
451 decisions.push(NormalizationDecision::AliasSymlinkVerified {
452 alias_root: alias_root.to_path_buf(),
453 alias_target: resolved_target,
454 });
455 Ok(None)
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461 use std::fs;
462 use std::sync::atomic::{AtomicU64, Ordering};
463 use tracing::info;
464
465 #[cfg(unix)]
466 use std::os::unix::fs::{PermissionsExt, symlink};
467
468 static COUNTER: AtomicU64 = AtomicU64::new(0);
469
470 struct TestFixture {
471 root: PathBuf,
472 canonical_root: PathBuf,
473 alias_root: PathBuf,
474 }
475
476 impl TestFixture {
477 fn new(prefix: &str, create_alias: bool, alias_target: Option<&Path>) -> Self {
478 let id = COUNTER.fetch_add(1, Ordering::SeqCst);
479 let root = std::env::temp_dir().join(format!(
480 "rch-path-topology-{}-{}-{}",
481 prefix,
482 std::process::id(),
483 id
484 ));
485 let canonical_root = root.join("data/projects");
486 let alias_root = root.join("dp");
487
488 fs::create_dir_all(&canonical_root).expect("create canonical root");
489
490 #[cfg(unix)]
491 if create_alias {
492 let target = alias_target.unwrap_or(&canonical_root);
493 symlink(target, &alias_root).expect("create alias symlink");
494 }
495
496 #[cfg(not(unix))]
497 {
498 let _ = create_alias;
499 let _ = alias_target;
500 }
501
502 Self {
503 root,
504 canonical_root,
505 alias_root,
506 }
507 }
508
509 fn policy(&self) -> PathTopologyPolicy {
510 PathTopologyPolicy::new(self.canonical_root.clone(), self.alias_root.clone())
511 }
512 }
513
514 impl Drop for TestFixture {
515 fn drop(&mut self) {
516 let _ = fs::remove_dir_all(&self.root);
517 }
518 }
519
520 fn log_normalization_error(test_name: &str, err: &PathNormalizationError) {
521 info!(
522 test = test_name,
523 kind = ?err.kind(),
524 detail = %err.detail(),
525 decisions = ?err.decision_trace(),
526 "topology_normalization_error"
527 );
528 }
529
530 #[test]
531 fn normalize_direct_canonical_path() {
532 let fixture = TestFixture::new("direct", false, None);
533 let project = fixture.canonical_root.join("demo");
534 fs::create_dir_all(&project).expect("create project");
535
536 let normalized = normalize_project_path_with_policy(&project, &fixture.policy())
537 .expect("normalize canonical path");
538
539 assert_eq!(
540 normalized.canonical_path(),
541 project.canonicalize().expect("canonicalize project")
542 );
543 assert!(!normalized.used_alias_prefix());
544 assert!(normalized.decision_trace().len() >= 4);
545 }
546
547 #[cfg(unix)]
548 #[test]
549 fn normalize_alias_path_to_same_canonical_identity() {
550 let fixture = TestFixture::new("alias", true, None);
551 let project = fixture.canonical_root.join("repo");
552 fs::create_dir_all(&project).expect("create project");
553 let alias_project = fixture.alias_root.join("repo");
554
555 let from_alias = normalize_project_path_with_policy(&alias_project, &fixture.policy())
556 .expect("normalize alias project");
557 let from_canonical = normalize_project_path_with_policy(&project, &fixture.policy())
558 .expect("normalize canonical project");
559
560 assert!(from_alias.used_alias_prefix());
561 assert_eq!(from_alias.canonical_path(), from_canonical.canonical_path());
562 assert!(
563 from_alias
564 .decision_trace()
565 .iter()
566 .any(|d| matches!(d, NormalizationDecision::AliasSymlinkVerified { .. }))
567 );
568 }
569
570 #[test]
571 fn reject_relative_path_input() {
572 let fixture = TestFixture::new("relative", false, None);
573 let err = normalize_project_path_with_policy(Path::new("relative/repo"), &fixture.policy())
574 .expect_err("relative path must fail");
575 assert_eq!(err.kind(), &PathNormalizationErrorKind::NotAbsoluteInput);
576 }
577
578 #[test]
579 fn reject_path_outside_canonical_root() {
580 let fixture = TestFixture::new("outside", false, None);
581 let outside = fixture.root.join("outside");
582 fs::create_dir_all(&outside).expect("create outside path");
583
584 let err = normalize_project_path_with_policy(&outside, &fixture.policy())
585 .expect_err("outside root must fail");
586 log_normalization_error("reject_path_outside_canonical_root", &err);
587 assert_eq!(
588 err.kind(),
589 &PathNormalizationErrorKind::OutsideCanonicalRoot
590 );
591 assert!(
592 err.decision_trace()
593 .iter()
594 .any(|d| matches!(d, NormalizationDecision::CanonicalInputResolved(_)))
595 );
596 }
597
598 #[cfg(unix)]
599 #[test]
600 fn reject_missing_alias_for_alias_prefixed_input() {
601 let fixture = TestFixture::new("missing-alias", false, None);
602 let input = fixture.alias_root.join("repo");
603 let err = normalize_project_path_with_policy(&input, &fixture.policy())
604 .expect_err("missing alias must fail");
605 log_normalization_error("reject_missing_alias_for_alias_prefixed_input", &err);
606 assert_eq!(err.kind(), &PathNormalizationErrorKind::AliasMissing);
607 }
608
609 #[cfg(unix)]
610 #[test]
611 fn reject_alias_pointing_to_wrong_target() {
612 let fixture = TestFixture::new("wrong-target", false, None);
613 let other_target = fixture.root.join("other-projects");
614 fs::create_dir_all(&other_target).expect("create alternate target");
615 symlink(&other_target, &fixture.alias_root).expect("create wrong alias");
616 let alias_input = fixture.alias_root.join("repo");
617 fs::create_dir_all(&alias_input).expect("create alias repo path");
618
619 let err = normalize_project_path_with_policy(&alias_input, &fixture.policy())
620 .expect_err("alias wrong target must fail");
621 log_normalization_error("reject_alias_pointing_to_wrong_target", &err);
622 assert_eq!(err.kind(), &PathNormalizationErrorKind::AliasWrongTarget);
623 }
624
625 #[cfg(unix)]
626 #[test]
627 fn reject_alias_path_that_is_not_symlink() {
628 let fixture = TestFixture::new("alias-not-symlink", false, None);
629 fs::create_dir_all(&fixture.alias_root).expect("create alias directory");
630 let alias_input = fixture.alias_root.join("repo");
631 fs::create_dir_all(&alias_input).expect("create alias repo path");
632
633 let err = normalize_project_path_with_policy(&alias_input, &fixture.policy())
634 .expect_err("non-symlink alias must fail");
635 log_normalization_error("reject_alias_path_that_is_not_symlink", &err);
636 assert_eq!(err.kind(), &PathNormalizationErrorKind::AliasNotSymlink);
637 assert!(err.detail().contains("not a symlink"));
638 }
639
640 #[cfg(unix)]
641 #[test]
642 fn normalize_alias_directory_with_symlinked_repo_entry() {
643 let fixture = TestFixture::new("alias-dir-entry", false, None);
644 let user_root = fixture.root.join("users/jemanuel");
645 let canonical_projects = user_root.join("projects");
646 let canonical_project = canonical_projects.join("repo");
647 let alias_projects = fixture.root.join("data/projects");
648 let alias_input = alias_projects.join("repo");
649
650 fs::create_dir_all(&canonical_project).expect("create canonical project");
651 fs::create_dir_all(&alias_projects).expect("create alias directory");
652 symlink(&canonical_project, &alias_input).expect("create per-repo alias symlink");
653
654 let policy = PathTopologyPolicy::new(canonical_projects, alias_projects.clone());
655 let normalized = normalize_project_path_with_policy(&alias_input, &policy)
656 .expect("normalize per-repo alias entry");
657
658 assert!(normalized.used_alias_prefix());
659 assert_eq!(
660 normalized.canonical_path(),
661 canonical_project
662 .canonicalize()
663 .expect("canonicalize canonical project")
664 );
665 assert!(normalized.decision_trace().iter().any(|decision| {
666 matches!(
667 decision,
668 NormalizationDecision::AliasDirectoryEntryVerified { .. }
669 )
670 }));
671 }
672
673 #[cfg(unix)]
674 #[test]
675 fn normalize_alias_directory_entry_to_canonical_symlink_namespace() {
676 let fixture = TestFixture::new("alias-dir-entry-canonical-symlink", false, None);
677 let user_root = fixture.root.join("users/jemanuel");
678 let canonical_projects = user_root.join("projects");
679 let outside_projects = user_root.join("dp");
680 let outside_project = outside_projects.join("asupersync");
681 let canonical_project = canonical_projects.join("asupersync");
682 let alias_projects = fixture.root.join("data/projects");
683 let alias_input = alias_projects.join("asupersync");
684
685 fs::create_dir_all(&outside_project).expect("create outside project target");
686 fs::create_dir_all(&canonical_projects).expect("create canonical projects directory");
687 fs::create_dir_all(&alias_projects).expect("create alias directory");
688 symlink(&outside_project, &canonical_project).expect("create canonical repo symlink");
689 symlink(&canonical_project, &alias_input).expect("create per-repo alias symlink");
690
691 let policy = PathTopologyPolicy::new(canonical_projects, alias_projects);
692 let normalized = normalize_project_path_with_policy(&alias_input, &policy)
693 .expect("normalize alias entry to canonical namespace");
694
695 assert!(normalized.used_alias_prefix());
696 assert_eq!(normalized.canonical_path(), canonical_project);
697 assert_ne!(
698 normalized.canonical_path(),
699 outside_project
700 .canonicalize()
701 .expect("canonicalize outside project")
702 );
703 assert!(normalized.decision_trace().iter().any(|decision| {
704 matches!(
705 decision,
706 NormalizationDecision::AliasDirectoryEntryVerified { .. }
707 )
708 }));
709 }
710
711 #[cfg(unix)]
712 #[test]
713 fn reject_alias_symlink_loop() {
714 let fixture = TestFixture::new("alias-loop", false, None);
715 symlink("dp", &fixture.alias_root).expect("create alias symlink loop");
716 let alias_input = fixture.alias_root.join("repo");
717
718 let err = normalize_project_path_with_policy(&alias_input, &fixture.policy())
719 .expect_err("alias loop must fail");
720 log_normalization_error("reject_alias_symlink_loop", &err);
721 assert_eq!(
722 err.kind(),
723 &PathNormalizationErrorKind::AliasTargetResolveFailed
724 );
725 assert!(
726 err.decision_trace()
727 .iter()
728 .any(|decision| matches!(decision, NormalizationDecision::AliasPrefixDetected(_)))
729 );
730 }
731
732 #[cfg(unix)]
733 #[test]
734 fn reject_permission_denied_during_canonical_resolution() {
735 let fixture = TestFixture::new("permission-denied", false, None);
736 let project = fixture.canonical_root.join("repo");
737 fs::create_dir_all(&project).expect("create project path");
738
739 let original_permissions = fs::metadata(&fixture.canonical_root)
740 .expect("read canonical root metadata")
741 .permissions();
742 let mut denied_permissions = original_permissions.clone();
743 denied_permissions.set_mode(0o000);
744 fs::set_permissions(&fixture.canonical_root, denied_permissions)
745 .expect("lock canonical root permissions");
746
747 let result = normalize_project_path_with_policy(&project, &fixture.policy());
748
749 fs::set_permissions(&fixture.canonical_root, original_permissions)
750 .expect("restore canonical root permissions");
751
752 let err = match result {
753 Ok(_) => {
754 return;
756 }
757 Err(err) => err,
758 };
759 log_normalization_error("reject_permission_denied_during_canonical_resolution", &err);
760 assert!(matches!(
761 err.kind(),
762 PathNormalizationErrorKind::CanonicalRootResolveFailed
763 | PathNormalizationErrorKind::InputResolveFailed
764 ));
765 }
766
767 #[test]
768 fn reject_when_canonical_root_missing() {
769 let fixture = TestFixture::new("missing-root", false, None);
770 let missing_root = fixture.root.join("does-not-exist");
771 let policy = PathTopologyPolicy::new(missing_root.clone(), fixture.alias_root.clone());
772 let outside = fixture.root.join("somewhere");
773 fs::create_dir_all(&outside).expect("create input");
774
775 let err = normalize_project_path_with_policy(&outside, &policy)
776 .expect_err("missing canonical root must fail");
777 log_normalization_error("reject_when_canonical_root_missing", &err);
778 assert_eq!(
779 err.kind(),
780 &PathNormalizationErrorKind::CanonicalRootMissing
781 );
782 assert!(
783 err.detail()
784 .contains(missing_root.to_string_lossy().as_ref())
785 );
786 }
787
788 #[test]
793 fn canonical_root_missing_error_cites_configured_root_not_default() {
794 let fixture = TestFixture::new("missing-custom-root", false, None);
795 let missing_root = fixture.root.join("custom-missing-root");
796 let missing_alias = fixture.root.join("custom-missing-alias");
797 let policy = PathTopologyPolicy::new(missing_root.clone(), missing_alias);
798
799 let probe = fixture.root.join("some-project");
802 let err = normalize_project_path_with_policy(&probe, &policy)
803 .expect_err("normalization must fail when canonical root is missing");
804
805 assert!(
806 matches!(err.kind(), PathNormalizationErrorKind::CanonicalRootMissing),
807 "expected CanonicalRootMissing, got {:?}",
808 err.kind()
809 );
810 let rendered = err.to_string();
811 assert!(
812 rendered.contains(&missing_root.display().to_string()),
813 "error should mention configured canonical root {}: {}",
814 missing_root.display(),
815 rendered
816 );
817 assert!(
818 !rendered.contains("/data/projects"),
819 "error must not leak default /data/projects when a custom \
820 canonical_root is configured. got: {}",
821 rendered
822 );
823 }
824
825 #[cfg(unix)]
826 #[test]
827 fn normalize_direct_path_via_alias_target_when_canonical_root_missing() {
828 let fixture = TestFixture::new("alias-target-root", true, None);
829 let missing_root = fixture.root.join("does-not-exist");
830 let policy = PathTopologyPolicy::new(missing_root, fixture.alias_root.clone());
831 let project = fixture.canonical_root.join("repo");
832 fs::create_dir_all(&project).expect("create project");
833
834 let normalized = normalize_project_path_with_policy(&project, &policy)
835 .expect("normalize path using alias target fallback");
836
837 assert_eq!(
838 normalized.canonical_root(),
839 fixture
840 .canonical_root
841 .canonicalize()
842 .expect("canonicalize alias target root")
843 );
844 assert_eq!(
845 normalized.canonical_path(),
846 project.canonicalize().expect("canonicalize project")
847 );
848 assert!(!normalized.used_alias_prefix());
849 }
850}