1use camino::{Utf8Path, Utf8PathBuf};
4use derive_more::{AsRef, Display, Into};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use unicode_normalization::UnicodeNormalization;
8
9use crate::errors::PathError;
10
11const RESERVED: [&str; 3] = [".git", "git~1", ".devset"];
15
16const UNPORTABLE: [char; 8] = ['\\', ':', '*', '?', '"', '<', '>', '|'];
18
19const IGNORABLE: [char; 15] = [
21 '\u{200C}', '\u{200D}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}',
22 '\u{202E}', '\u{206A}', '\u{206B}', '\u{206C}', '\u{206D}', '\u{206E}', '\u{FEFF}',
23];
24
25#[derive(
31 Clone,
32 PartialEq,
33 Eq,
34 PartialOrd,
35 Ord,
36 Hash,
37 AsRef,
38 Display,
39 derive_more::Debug,
40 Into,
41 Serialize,
42 Deserialize,
43 JsonSchema,
44)]
45#[as_ref(str)]
46#[debug("{_0:?}")]
47#[into(String)]
48#[serde(try_from = "String", into = "String")]
49#[schemars(inline)]
50pub struct RelPath(Box<str>);
51
52impl RelPath {
53 pub fn new(path: &str) -> Result<Self, PathError> {
58 Self::try_from(String::from(path))
59 }
60
61 #[must_use]
63 pub fn as_str(&self) -> &str {
64 &self.0
65 }
66
67 #[must_use]
69 pub fn under(&self, root: &Utf8Path) -> Utf8PathBuf {
70 let mut path = root.to_path_buf();
71 path.extend(self.0.split('/'));
72 path
73 }
74
75 pub(crate) fn file_name(&self) -> &str {
77 self.0.rsplit_once('/').map_or(&self.0, |(_, name)| name)
78 }
79
80 pub(crate) fn extension(&self) -> Option<String> {
82 let (_, extension) = self.file_name().rsplit_once('.')?;
83 Some(extension.to_ascii_lowercase())
84 }
85
86 #[must_use]
88 pub fn fold(&self) -> String {
89 fold(&self.0)
90 }
91}
92
93fn fold(s: &str) -> String {
95 s.nfc().flat_map(char::to_lowercase).filter(|c| !IGNORABLE.contains(c)).collect()
96}
97
98fn check(path: &str) -> Result<(), &'static str> {
100 if path.is_empty() {
101 return Err("empty");
102 }
103 if path.starts_with('/') {
104 return Err("absolute");
105 }
106 for part in path.split('/') {
107 if part.is_empty() || part == "." || part == ".." {
108 return Err("empty, `.` or `..` component");
109 }
110 if part.ends_with(['.', ' ']) {
111 return Err("component ends in `.` or a space");
112 }
113 if part.chars().any(|c| c.is_control() || UNPORTABLE.contains(&c)) {
114 return Err("unportable character");
115 }
116 if RESERVED.contains(&fold(part).as_str()) {
117 return Err("reserved component");
118 }
119 }
120 Ok(())
121}
122
123impl TryFrom<String> for RelPath {
124 type Error = PathError;
125
126 fn try_from(path: String) -> Result<Self, PathError> {
127 match check(&path) {
128 Ok(()) => Ok(Self(path.into_boxed_str())),
129 Err(rule) => Err(PathError { path, rule }),
130 }
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::RelPath;
137
138 #[test]
139 fn accepts() {
140 for path in ["a", ".github/workflows/ci.yml", ".gitignore", "a.b/c-d_e", "devset/x"] {
141 assert!(RelPath::new(path).is_ok(), "{path} should be accepted");
142 }
143 }
144
145 #[test]
146 fn rejects() {
147 for path in [
148 "",
149 "/a",
150 "a//b",
151 "a/",
152 "./a",
153 "a/../b",
154 "a.",
155 "a ",
156 "a\\b",
157 "c:x",
158 "a\nb",
159 ".git",
160 "x/.GIT/config",
161 "x/.g\u{200C}it",
162 "GIT~1/x",
163 ".devset/state.toml",
164 "x/.DevSet",
165 ] {
166 assert!(RelPath::new(path).is_err(), "{path:?} should be rejected");
167 }
168 }
169
170 #[test]
171 fn fold_collides_case_and_normalization() {
172 let fold = |s: &str| RelPath::new(s).map(|p| p.fold()).ok();
173 assert_eq!(fold("README.md"), fold("readme.md"), "case must fold");
174 assert_eq!(fold("caf\u{E9}"), fold("cafe\u{301}"), "NFC and NFD must fold together");
175 }
176}