Skip to main content

prikk_object/
path.rs

1//! Repository-relative lexical path validation.
2//!
3//! Moved here from `prikk-replay` (DC-54): this is pure lexical grammar with no dependency on
4//! `RepoPath` or lifecycle state, and object-envelope encoders need to call it without creating a
5//! `prikk-object -> prikk-replay` dependency cycle (`prikk-replay` already depends on
6//! `prikk-object`). `prikk-replay::RepoPath::parse` calls this function and re-exports it, so
7//! every existing caller of `prikk_replay::validate_repo_path` / `prikk_store::validate_repo_path`
8//! keeps compiling unchanged.
9
10use prikk_error::{PrikkError, Result};
11
12/// The single case-folding definition every case-insensitive-collision check across repository
13/// paths, branch ref names, tag ref names, and maintainer trust key ids folds through (DC-72 design
14/// ruling: "one folding definition, four call sites" — `rfcs/accepted/DC-72-PATH-SAFETY-CONFORMANCE.md`
15/// §3.5). `prikk-object` is the lowest crate every surface's crate already depends on, directly or
16/// transitively, so this is the one place a fifth surface's collision check would also reach.
17///
18/// Deliberately ASCII-only, not Unicode normalization: an NFC-composed and NFD-decomposed spelling
19/// of the same visible name are different byte sequences and are not folded together here. Recorded
20/// as a known limitation, not an oversight, at `docs/src/reference/path-safety.md`.
21#[must_use]
22pub fn ascii_fold(name: &str) -> String {
23    name.to_ascii_lowercase()
24}
25
26/// Validate that a path is safe as a repository-relative path.
27pub fn validate_repo_path(value: &str) -> Result<()> {
28    if value.is_empty() {
29        return Err(PrikkError::InvalidName(
30            "repository path must not be empty".to_string(),
31        ));
32    }
33    if value.starts_with('/') {
34        return Err(PrikkError::InvalidName(
35            "absolute paths are not allowed".to_string(),
36        ));
37    }
38    if value.contains('\\') {
39        return Err(PrikkError::InvalidName(
40            "backslashes are not allowed in repository paths".to_string(),
41        ));
42    }
43    if value.contains(':') {
44        return Err(PrikkError::InvalidName(
45            "colon characters are not allowed in repository paths".to_string(),
46        ));
47    }
48    if !value.is_ascii() {
49        return Err(PrikkError::InvalidName(
50            "non-ASCII paths are deferred until Unicode NFC normalization is implemented"
51                .to_string(),
52        ));
53    }
54    if value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) {
55        return Err(PrikkError::InvalidName(
56            "control characters are not allowed in repository paths".to_string(),
57        ));
58    }
59    for (index, component) in value.split('/').enumerate() {
60        if index == 0 && component.eq_ignore_ascii_case(".prikk") {
61            return Err(PrikkError::InvalidName(
62                "repository paths must not target the .prikk metadata directory".to_string(),
63            ));
64        }
65        validate_component(component)?;
66    }
67    Ok(())
68}
69
70fn validate_component(component: &str) -> Result<()> {
71    if component.is_empty() {
72        return Err(PrikkError::InvalidName(
73            "empty path components are not allowed".to_string(),
74        ));
75    }
76    if component == "." || component == ".." {
77        return Err(PrikkError::InvalidName(
78            "dot path components are not allowed".to_string(),
79        ));
80    }
81    if component.ends_with(' ') || component.ends_with('.') {
82        return Err(PrikkError::InvalidName(
83            "path components must not end with space or dot".to_string(),
84        ));
85    }
86    if is_windows_reserved_name(component) {
87        return Err(PrikkError::InvalidName(format!(
88            "Windows reserved path component is not allowed: {component}"
89        )));
90    }
91    Ok(())
92}
93
94/// Whether a path component's basename (before the first `.`) is a Windows-reserved device name
95/// (`CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9`), checked case-insensitively and
96/// regardless of host OS. Exposed for other storage surfaces that build a literal filesystem path
97/// component from user-supplied text outside the `RepoPath` grammar (DC-72).
98#[must_use]
99pub fn is_windows_reserved_name(component: &str) -> bool {
100    let base = component
101        .split('.')
102        .next()
103        .unwrap_or(component)
104        .to_ascii_uppercase();
105    matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL")
106        || matches!(
107            base.as_str(),
108            "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9"
109        )
110        || matches!(
111            base.as_str(),
112            "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9"
113        )
114}