Skip to main content

rch_common/
path_topology.rs

1//! Canonical path topology utilities for host/worker project mapping.
2//!
3//! This module normalizes project roots to a single canonical namespace so
4//! equivalent aliases (for example `/dp` and `/data/projects`) map to one
5//! deterministic identity. It also emits structured decision traces to aid
6//! troubleshooting when path normalization fails.
7
8use std::fmt;
9use std::path::{Path, PathBuf};
10
11/// Canonical root used for project identity and transfer safety checks.
12pub const DEFAULT_CANONICAL_PROJECT_ROOT: &str = "/data/projects";
13
14/// Alias root expected to point at [`DEFAULT_CANONICAL_PROJECT_ROOT`].
15pub const DEFAULT_ALIAS_PROJECT_ROOT: &str = "/dp";
16
17/// Policy describing canonical and alias roots used for normalization.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct PathTopologyPolicy {
20    canonical_root: PathBuf,
21    alias_root: PathBuf,
22}
23
24impl PathTopologyPolicy {
25    /// Create a policy with explicit canonical and alias roots.
26    pub fn new(canonical_root: PathBuf, alias_root: PathBuf) -> Self {
27        Self {
28            canonical_root,
29            alias_root,
30        }
31    }
32
33    /// Canonical root path.
34    pub fn canonical_root(&self) -> &Path {
35        &self.canonical_root
36    }
37
38    /// Alias root path.
39    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/// Structured trace for normalization decisions.
54#[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/// Successful normalization result with deterministic canonical path.
123#[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    /// Canonical path for this project root.
133    pub fn canonical_path(&self) -> &Path {
134        &self.canonical_path
135    }
136
137    /// Canonical project root used for containment checks.
138    pub fn canonical_root(&self) -> &Path {
139        &self.canonical_root
140    }
141
142    /// Whether the input path used the alias prefix (for example `/dp`).
143    pub fn used_alias_prefix(&self) -> bool {
144        self.used_alias_prefix
145    }
146
147    /// Structured decision trace for diagnostics.
148    pub fn decision_trace(&self) -> &[NormalizationDecision] {
149        &self.decisions
150    }
151}
152
153/// Error class for path normalization failures.
154#[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/// Normalization error with structured trace context.
186#[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    /// Error category.
210    pub fn kind(&self) -> &PathNormalizationErrorKind {
211        &self.kind
212    }
213
214    /// Human-readable detail about the failure.
215    pub fn detail(&self) -> &str {
216        &self.detail
217    }
218
219    /// Structured decision trace for diagnostics.
220    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
239/// Normalize a project path using the default `/data/projects` + `/dp` policy.
240pub fn normalize_project_path(
241    path: &Path,
242) -> Result<NormalizedProjectPath, PathNormalizationError> {
243    normalize_project_path_with_policy(path, &PathTopologyPolicy::default())
244}
245
246/// Normalize a project path with explicit topology policy.
247pub 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                // Root users can bypass mode bits; treat as a non-actionable skip.
755                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    /// Regression test for GitHub #9: when a custom canonical root is
789    /// configured and does not exist (and there is no alias symlink to
790    /// fall back through), the error must reference the *configured*
791    /// root — not the compiled-in `/data/projects` default.
792    #[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        // Any absolute path we attempt to normalize under this policy must
800        // fail with a message that names the configured canonical root.
801        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}