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    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/// Successful normalization result with deterministic canonical path.
139#[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    /// Canonical path for this project root.
149    pub fn canonical_path(&self) -> &Path {
150        &self.canonical_path
151    }
152
153    /// Canonical project root used for containment checks.
154    pub fn canonical_root(&self) -> &Path {
155        &self.canonical_root
156    }
157
158    /// Whether the input path used the alias prefix (for example `/dp`).
159    pub fn used_alias_prefix(&self) -> bool {
160        self.used_alias_prefix
161    }
162
163    /// Structured decision trace for diagnostics.
164    pub fn decision_trace(&self) -> &[NormalizationDecision] {
165        &self.decisions
166    }
167}
168
169/// Error class for path normalization failures.
170#[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/// Normalization error with structured trace context.
202#[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    /// Error category.
226    pub fn kind(&self) -> &PathNormalizationErrorKind {
227        &self.kind
228    }
229
230    /// Human-readable detail about the failure.
231    pub fn detail(&self) -> &str {
232        &self.detail
233    }
234
235    /// Structured decision trace for diagnostics.
236    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
255/// Normalize a project path using the default `/data/projects` + `/dp` policy.
256pub fn normalize_project_path(
257    path: &Path,
258) -> Result<NormalizedProjectPath, PathNormalizationError> {
259    normalize_project_path_with_policy(path, &PathTopologyPolicy::default())
260}
261
262/// Normalize a project path with explicit topology policy.
263pub 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    // A policy with identical roots has no alias namespace. Resolve its paths
284    // normally, including parent components, then enforce canonical containment.
285    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    // With one namespace there is no alias mapping to validate. Resolve the
398    // actual path (including parent components and symlinks), then let the
399    // caller enforce containment against the canonical root.
400    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            // Canonicalize the temp root (yw6mn): on macOS `/var` is a symlink
552            // to `/private/var`, and `temp_dir()` returns the `/var` spelling.
553            // Expected values are built by joining onto this root while the
554            // code under test canonicalizes, so an un-canonicalized root makes
555            // every comparison differ by a prefix that is the same directory.
556            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                // Root users can bypass mode bits; treat as a non-actionable skip.
994                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    /// Regression test for GitHub #9: when a custom canonical root is
1028    /// configured and does not exist (and there is no alias symlink to
1029    /// fall back through), the error must reference the *configured*
1030    /// root — not the compiled-in `/data/projects` default.
1031    #[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        // Any absolute path we attempt to normalize under this policy must
1039        // fail with a message that names the configured canonical root.
1040        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        // The fixture itself may live under the default root (e.g. a
1057        // checkout at /data/projects/...), so a bare substring check
1058        // would false-fire on the legitimate fixture paths carried by
1059        // the rendered input/detail. Strip them first; any leftover
1060        // citation of the compiled-in default is a real leak.
1061        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}