wdl_modules/
relative_path.rs1use std::path::Path;
13use std::path::PathBuf;
14use std::str::FromStr;
15
16use serde::Deserialize;
17use serde::Serialize;
18use thiserror::Error;
19use unicode_normalization::UnicodeNormalization;
20
21#[derive(Clone, Debug, Eq, Error, PartialEq)]
23pub enum RelativePathError {
24 #[error("path is not encoded as UTF-8")]
26 NonUtf8,
27 #[error("path is empty")]
29 Empty,
30 #[error("path `{0}` contains a null byte")]
32 NullByte(String),
33 #[error("path `{0}` contains Windows path separators (e.g. `\\`)")]
35 Backslash(String),
36 #[error("path `{0}` cannot be absolute")]
38 Absolute(String),
39 #[error("path `{0}` resolves to empty")]
41 ResolvesToEmpty(String),
42 #[error("path `{0}` escapes the module root directory")]
44 EscapesRoot(String),
45}
46
47#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
50#[serde(into = "String", try_from = "String")]
51pub struct RelativePath(String);
52
53impl RelativePath {
54 pub fn as_str(&self) -> &str {
56 &self.0
57 }
58
59 pub fn as_path(&self) -> &Path {
61 Path::new(&self.0)
62 }
63}
64
65impl AsRef<str> for RelativePath {
66 fn as_ref(&self) -> &str {
67 &self.0
68 }
69}
70
71impl AsRef<Path> for RelativePath {
72 fn as_ref(&self) -> &Path {
73 Path::new(&self.0)
74 }
75}
76
77impl std::fmt::Display for RelativePath {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 std::fmt::Display::fmt(&self.0, f)
80 }
81}
82
83impl From<RelativePath> for String {
84 fn from(path: RelativePath) -> Self {
85 path.0
86 }
87}
88
89impl From<RelativePath> for PathBuf {
90 fn from(path: RelativePath) -> Self {
91 path.0.into()
92 }
93}
94
95impl TryFrom<&Path> for RelativePath {
96 type Error = RelativePathError;
97
98 fn try_from(path: &Path) -> Result<Self, Self::Error> {
99 let s = path.to_str().ok_or(RelativePathError::NonUtf8)?;
100 if s.is_empty() {
101 return Err(RelativePathError::Empty);
102 }
103 if s.contains('\0') {
104 return Err(RelativePathError::NullByte(s.replace('\0', "\\0")));
105 }
106 if s.contains('\\') {
107 return Err(RelativePathError::Backslash(s.to_string()));
108 }
109 if s.starts_with('/') || crate::starts_with_windows_drive(s) {
110 return Err(RelativePathError::Absolute(s.to_string()));
111 }
112 let cleaned = path_clean::clean(path)
113 .into_os_string()
114 .into_string()
115 .map_err(|_| RelativePathError::NonUtf8)?;
116 let cleaned = cleaned.replace('\\', "/");
122 if cleaned.is_empty() || cleaned == "." {
123 return Err(RelativePathError::ResolvesToEmpty(s.to_string()));
124 }
125 if cleaned == ".." || cleaned.starts_with("../") {
126 return Err(RelativePathError::EscapesRoot(s.to_string()));
127 }
128 Ok(Self(cleaned.nfc().collect()))
129 }
130}
131
132impl TryFrom<PathBuf> for RelativePath {
133 type Error = RelativePathError;
134
135 fn try_from(path: PathBuf) -> Result<Self, Self::Error> {
136 Self::try_from(path.as_path())
137 }
138}
139
140impl TryFrom<String> for RelativePath {
141 type Error = RelativePathError;
142
143 fn try_from(s: String) -> Result<Self, Self::Error> {
144 Self::try_from(Path::new(&s))
145 }
146}
147
148impl FromStr for RelativePath {
149 type Err = RelativePathError;
150
151 fn from_str(s: &str) -> Result<Self, Self::Err> {
152 Self::try_from(Path::new(s))
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn accepts_simple_relative_path() {
162 let p = RelativePath::from_str("foo/bar.wdl").unwrap();
163 assert_eq!(p.as_str(), "foo/bar.wdl");
164 }
165
166 #[test]
167 fn cleans_dot_segments() {
168 let p = RelativePath::from_str("./foo/./bar.wdl").unwrap();
169 assert_eq!(p.as_str(), "foo/bar.wdl");
170 }
171
172 #[test]
173 fn cleans_inner_double_dot() {
174 let p = RelativePath::from_str("foo/../bar.wdl").unwrap();
175 assert_eq!(p.as_str(), "bar.wdl");
176 }
177
178 #[test]
179 fn accepts_names_that_start_with_two_dots() {
180 let file = RelativePath::from_str("..config").unwrap();
181 assert_eq!(file.as_str(), "..config");
182
183 let nested = RelativePath::from_str("..foo/bar.wdl").unwrap();
184 assert_eq!(nested.as_str(), "..foo/bar.wdl");
185 }
186
187 #[test]
188 fn collapses_duplicate_separators() {
189 let p = RelativePath::from_str("foo//bar.wdl").unwrap();
190 assert_eq!(p.as_str(), "foo/bar.wdl");
191 }
192
193 #[test]
194 fn nfc_normalizes_on_construction() {
195 let composed = RelativePath::from_str("caf\u{00E9}.wdl").unwrap();
196 let decomposed = RelativePath::from_str("cafe\u{0301}.wdl").unwrap();
197 assert_eq!(composed, decomposed);
198 assert_eq!(composed.as_str(), "caf\u{00E9}.wdl");
199 }
200
201 #[test]
202 fn rejects_per_path_violations() {
203 let err = RelativePath::from_str("").unwrap_err();
204 assert!(matches!(err, RelativePathError::Empty));
205
206 let err = RelativePath::from_str("has\0null").unwrap_err();
207 assert!(matches!(err, RelativePathError::NullByte(_)));
208
209 let err = RelativePath::from_str("a\\b").unwrap_err();
210 assert!(matches!(err, RelativePathError::Backslash(_)));
211
212 let err = RelativePath::from_str("/abs").unwrap_err();
213 assert!(matches!(err, RelativePathError::Absolute(_)));
214
215 let err = RelativePath::from_str("C:/win").unwrap_err();
216 assert!(matches!(err, RelativePathError::Absolute(_)));
217
218 let err = RelativePath::from_str("c:\\win").unwrap_err();
219 assert!(matches!(err, RelativePathError::Backslash(_)));
220
221 let err = RelativePath::from_str(".").unwrap_err();
222 assert!(matches!(err, RelativePathError::ResolvesToEmpty(_)));
223
224 let err = RelativePath::from_str("..").unwrap_err();
225 assert!(matches!(err, RelativePathError::EscapesRoot(_)));
226
227 let err = RelativePath::from_str("../escape").unwrap_err();
228 assert!(matches!(err, RelativePathError::EscapesRoot(_)));
229
230 let err = RelativePath::from_str("a/..").unwrap_err();
231 assert!(matches!(err, RelativePathError::ResolvesToEmpty(_)));
232 }
233
234 #[test]
235 fn error_includes_path() {
236 let err = RelativePath::from_str("/abs/path").unwrap_err();
237 assert!(
238 err.to_string().contains("/abs/path"),
239 "error should include the path: {err}"
240 );
241 }
242
243 #[test]
244 fn round_trips_via_serde() {
245 let p = RelativePath::from_str("foo/bar.wdl").unwrap();
246 let s = serde_json::to_string(&p).unwrap();
247 assert_eq!(s, "\"foo/bar.wdl\"");
248 let back: RelativePath = serde_json::from_str(&s).unwrap();
249 assert_eq!(back, p);
250 }
251
252 #[test]
253 fn deserialize_normalizes_input() {
254 let p: RelativePath = serde_json::from_str("\"./foo/./bar.wdl\"").unwrap();
255 assert_eq!(p.as_str(), "foo/bar.wdl");
256 }
257
258 #[test]
259 fn deserialize_rejects_invalid() {
260 assert!(serde_json::from_str::<RelativePath>("\"/abs\"").is_err());
261 }
262}