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/// Validate that a path is safe as a repository-relative path.
13pub fn validate_repo_path(value: &str) -> Result<()> {
14    if value.is_empty() {
15        return Err(PrikkError::InvalidName(
16            "repository path must not be empty".to_string(),
17        ));
18    }
19    if value.starts_with('/') {
20        return Err(PrikkError::InvalidName(
21            "absolute paths are not allowed".to_string(),
22        ));
23    }
24    if value.contains('\\') {
25        return Err(PrikkError::InvalidName(
26            "backslashes are not allowed in repository paths".to_string(),
27        ));
28    }
29    if value.contains(':') {
30        return Err(PrikkError::InvalidName(
31            "colon characters are not allowed in repository paths".to_string(),
32        ));
33    }
34    if !value.is_ascii() {
35        return Err(PrikkError::InvalidName(
36            "non-ASCII paths are deferred until Unicode NFC normalization is implemented"
37                .to_string(),
38        ));
39    }
40    if value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) {
41        return Err(PrikkError::InvalidName(
42            "control characters are not allowed in repository paths".to_string(),
43        ));
44    }
45    for (index, component) in value.split('/').enumerate() {
46        if index == 0 && component.eq_ignore_ascii_case(".prikk") {
47            return Err(PrikkError::InvalidName(
48                "repository paths must not target the .prikk metadata directory".to_string(),
49            ));
50        }
51        validate_component(component)?;
52    }
53    Ok(())
54}
55
56fn validate_component(component: &str) -> Result<()> {
57    if component.is_empty() {
58        return Err(PrikkError::InvalidName(
59            "empty path components are not allowed".to_string(),
60        ));
61    }
62    if component == "." || component == ".." {
63        return Err(PrikkError::InvalidName(
64            "dot path components are not allowed".to_string(),
65        ));
66    }
67    if component.ends_with(' ') || component.ends_with('.') {
68        return Err(PrikkError::InvalidName(
69            "path components must not end with space or dot".to_string(),
70        ));
71    }
72    if is_windows_reserved_name(component) {
73        return Err(PrikkError::InvalidName(format!(
74            "Windows reserved path component is not allowed: {component}"
75        )));
76    }
77    Ok(())
78}
79
80fn is_windows_reserved_name(component: &str) -> bool {
81    let base = component
82        .split('.')
83        .next()
84        .unwrap_or(component)
85        .to_ascii_uppercase();
86    matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL")
87        || matches!(
88            base.as_str(),
89            "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9"
90        )
91        || matches!(
92            base.as_str(),
93            "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9"
94        )
95}