1use prikk_error::{PrikkError, Result};
11
12pub 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}