wdl_modules/
relative_path.rs1use std::path::Path;
13use std::path::PathBuf;
14use std::str::FromStr;
15
16use serde_with::DeserializeFromStr;
17use serde_with::SerializeDisplay;
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(
50 Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, SerializeDisplay, DeserializeFromStr,
51)]
52pub struct RelativePath(String);
53
54impl RelativePath {
55 pub fn as_str(&self) -> &str {
57 &self.0
58 }
59
60 pub fn as_path(&self) -> &Path {
62 Path::new(&self.0)
63 }
64}
65
66impl AsRef<str> for RelativePath {
67 fn as_ref(&self) -> &str {
68 &self.0
69 }
70}
71
72impl AsRef<Path> for RelativePath {
73 fn as_ref(&self) -> &Path {
74 Path::new(&self.0)
75 }
76}
77
78impl std::fmt::Display for RelativePath {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 self.0.fmt(f)
81 }
82}
83
84impl From<RelativePath> for String {
85 fn from(path: RelativePath) -> Self {
86 path.0
87 }
88}
89
90impl From<RelativePath> for PathBuf {
91 fn from(path: RelativePath) -> Self {
92 path.0.into()
93 }
94}
95
96impl FromStr for RelativePath {
97 type Err = RelativePathError;
98
99 fn from_str(s: &str) -> Result<Self, Self::Err> {
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::new(s))
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<&Path> for RelativePath {
133 type Error = RelativePathError;
134
135 fn try_from(path: &Path) -> Result<Self, Self::Error> {
136 path.to_str().ok_or(RelativePathError::NonUtf8)?.parse()
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn accepts_simple_relative_path() {
146 let p = RelativePath::from_str("foo/bar.wdl").unwrap();
147 assert_eq!(p.as_str(), "foo/bar.wdl");
148 }
149
150 #[test]
151 fn cleans_dot_segments() {
152 let p = RelativePath::from_str("./foo/./bar.wdl").unwrap();
153 assert_eq!(p.as_str(), "foo/bar.wdl");
154 }
155
156 #[test]
157 fn cleans_inner_double_dot() {
158 let p = RelativePath::from_str("foo/../bar.wdl").unwrap();
159 assert_eq!(p.as_str(), "bar.wdl");
160 }
161
162 #[test]
163 fn accepts_names_that_start_with_two_dots() {
164 let file = RelativePath::from_str("..config").unwrap();
165 assert_eq!(file.as_str(), "..config");
166
167 let nested = RelativePath::from_str("..foo/bar.wdl").unwrap();
168 assert_eq!(nested.as_str(), "..foo/bar.wdl");
169 }
170
171 #[test]
172 fn collapses_duplicate_separators() {
173 let p = RelativePath::from_str("foo//bar.wdl").unwrap();
174 assert_eq!(p.as_str(), "foo/bar.wdl");
175 }
176
177 #[test]
178 fn nfc_normalizes_on_construction() {
179 let composed = RelativePath::from_str("caf\u{00E9}.wdl").unwrap();
180 let decomposed = RelativePath::from_str("cafe\u{0301}.wdl").unwrap();
181 assert_eq!(composed, decomposed);
182 assert_eq!(composed.as_str(), "caf\u{00E9}.wdl");
183 }
184
185 #[test]
186 fn rejects_per_path_violations() {
187 let err = RelativePath::from_str("").unwrap_err();
188 assert!(matches!(err, RelativePathError::Empty));
189
190 let err = RelativePath::from_str("has\0null").unwrap_err();
191 assert!(matches!(err, RelativePathError::NullByte(_)));
192
193 let err = RelativePath::from_str("a\\b").unwrap_err();
194 assert!(matches!(err, RelativePathError::Backslash(_)));
195
196 let err = RelativePath::from_str("/abs").unwrap_err();
197 assert!(matches!(err, RelativePathError::Absolute(_)));
198
199 let err = RelativePath::from_str("C:/win").unwrap_err();
200 assert!(matches!(err, RelativePathError::Absolute(_)));
201
202 let err = RelativePath::from_str("c:\\win").unwrap_err();
203 assert!(matches!(err, RelativePathError::Backslash(_)));
204
205 let err = RelativePath::from_str(".").unwrap_err();
206 assert!(matches!(err, RelativePathError::ResolvesToEmpty(_)));
207
208 let err = RelativePath::from_str("..").unwrap_err();
209 assert!(matches!(err, RelativePathError::EscapesRoot(_)));
210
211 let err = RelativePath::from_str("../escape").unwrap_err();
212 assert!(matches!(err, RelativePathError::EscapesRoot(_)));
213
214 let err = RelativePath::from_str("a/..").unwrap_err();
215 assert!(matches!(err, RelativePathError::ResolvesToEmpty(_)));
216 }
217
218 #[test]
219 fn error_includes_path() {
220 let err = RelativePath::from_str("/abs/path").unwrap_err();
221 assert!(
222 err.to_string().contains("/abs/path"),
223 "error should include the path: {err}"
224 );
225 }
226
227 #[test]
228 fn round_trips_via_serde() {
229 let p = RelativePath::from_str("foo/bar.wdl").unwrap();
230 let s = serde_json::to_string(&p).unwrap();
231 assert_eq!(s, "\"foo/bar.wdl\"");
232 let back: RelativePath = serde_json::from_str(&s).unwrap();
233 assert_eq!(back, p);
234 }
235
236 #[test]
237 fn deserialize_normalizes_input() {
238 let p: RelativePath = serde_json::from_str("\"./foo/./bar.wdl\"").unwrap();
239 assert_eq!(p.as_str(), "foo/bar.wdl");
240 }
241
242 #[test]
243 fn deserialize_rejects_invalid() {
244 assert!(serde_json::from_str::<RelativePath>("\"/abs\"").is_err());
245 }
246}