1use std::ffi::OsStr;
4use std::fmt;
5use std::path::{Component, Path, PathBuf};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum UnsafePathError {
14 ParentDir,
17 Absolute,
20 ReservedName,
23 EscapesRoot,
26}
27
28impl fmt::Display for UnsafePathError {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 let msg = match self {
31 Self::ParentDir => "path contains a parent-directory (`..`) component",
32 Self::Absolute => "path is absolute but only a relative path is allowed",
33 Self::ReservedName => "path contains a reserved device name",
34 Self::EscapesRoot => "path escapes the permitted root directory",
35 };
36 f.write_str(msg)
37 }
38}
39
40impl std::error::Error for UnsafePathError {}
41
42impl From<UnsafePathError> for std::io::Error {
43 fn from(err: UnsafePathError) -> Self {
44 Self::new(std::io::ErrorKind::InvalidInput, err)
45 }
46}
47
48pub fn sanitize_path(path: impl AsRef<Path>) -> Result<PathBuf, UnsafePathError> {
65 sanitize(path.as_ref(), false)
66}
67
68pub fn sanitize_relative_path(path: impl AsRef<Path>) -> Result<PathBuf, UnsafePathError> {
74 sanitize(path.as_ref(), true)
75}
76
77fn sanitize(path: &Path, relative_only: bool) -> Result<PathBuf, UnsafePathError> {
78 let mut out = PathBuf::new();
79 for component in path.components() {
80 match component {
81 Component::Prefix(_) | Component::RootDir => {
82 if relative_only {
83 return Err(UnsafePathError::Absolute);
84 }
85 out.push(component.as_os_str());
86 }
87 Component::CurDir => {}
88 Component::ParentDir => return Err(UnsafePathError::ParentDir),
89 Component::Normal(name) => {
90 check_normal_component(name)?;
91 out.push(name);
92 }
93 }
94 }
95 Ok(out)
96}
97
98fn check_normal_component(name: &OsStr) -> Result<(), UnsafePathError> {
100 if !Path::new(name)
104 .components()
105 .all(|c| matches!(c, Component::Normal(_)))
106 {
107 return Err(UnsafePathError::Absolute);
108 }
109
110 #[cfg(windows)]
113 if is_reserved_device_name(name) {
114 return Err(UnsafePathError::ReservedName);
115 }
116
117 Ok(())
118}
119
120#[must_use]
123pub fn is_reserved_device_name(name: impl AsRef<OsStr>) -> bool {
124 let name = name.as_ref();
125 #[cfg(windows)]
126 {
127 use std::os::windows::ffi::OsStrExt;
128 is_reserved_dos_name(|| name.encode_wide())
129 }
130 #[cfg(not(windows))]
131 {
132 let name = name.to_string_lossy();
133 is_reserved_dos_name(|| name.encode_utf16())
134 }
135}
136
137fn is_reserved_dos_name<F, I>(mut get_iter: F) -> bool
140where
141 F: FnMut() -> I,
142 I: Iterator<Item = u16>,
143{
144 const CON: [u16; 3] = [b'C' as u16, b'O' as u16, b'N' as u16];
145 const PRN: [u16; 3] = [b'P' as u16, b'R' as u16, b'N' as u16];
146 const AUX: [u16; 3] = [b'A' as u16, b'U' as u16, b'X' as u16];
147 const NUL: [u16; 3] = [b'N' as u16, b'U' as u16, b'L' as u16];
148 const CONIN: [u16; 6] = [
149 b'C' as u16,
150 b'O' as u16,
151 b'N' as u16,
152 b'I' as u16,
153 b'N' as u16,
154 b'$' as u16,
155 ];
156 const CONOUT: [u16; 7] = [
157 b'C' as u16,
158 b'O' as u16,
159 b'N' as u16,
160 b'O' as u16,
161 b'U' as u16,
162 b'T' as u16,
163 b'$' as u16,
164 ];
165
166 const COM: [u16; 3] = [b'C' as u16, b'O' as u16, b'M' as u16];
167 const LPT: [u16; 3] = [b'L' as u16, b'P' as u16, b'T' as u16];
168
169 const ZERO: u16 = b'0' as u16;
170 const NINE: u16 = b'9' as u16;
171 const SUPERSCRIPT_ONE: u16 = 0x00B9;
172 const SUPERSCRIPT_TWO: u16 = 0x00B2;
173 const SUPERSCRIPT_THREE: u16 = 0x00B3;
174
175 fn is_whitespace(c: u16) -> bool {
176 c <= 0x7F && ((c as u8).is_ascii_whitespace() || c == 0x000B)
177 }
178
179 let trimmed_len = get_iter()
180 .enumerate()
181 .take_while(|&(_idx, c)| c != b'.' as u16 && c != b':' as u16)
182 .filter(|&(_idx, c)| !is_whitespace(c))
183 .last()
184 .map(|(idx, _)| idx + 1)
185 .unwrap_or(0);
186
187 if trimmed_len > 7 {
188 return false;
189 }
190
191 let mut buf = [0u16; 7];
192 get_iter()
193 .take(trimmed_len)
194 .enumerate()
195 .for_each(|(i, c)| buf[i] = c);
196
197 for b in &mut buf {
198 if *b <= 0x7F {
199 *b = (*b as u8).to_ascii_uppercase() as u16;
200 }
201 if *b == SUPERSCRIPT_ONE {
202 *b = b'1' as u16;
203 }
204 if *b == SUPERSCRIPT_TWO {
205 *b = b'2' as u16;
206 }
207 if *b == SUPERSCRIPT_THREE {
208 *b = b'3' as u16;
209 }
210 }
211 let name = &buf[..trimmed_len];
212
213 if name == CON || name == PRN || name == AUX || name == NUL || name == CONIN || name == CONOUT {
214 return true;
215 }
216
217 if name.len() == 4 {
218 let prefix = &name[..3];
219 let suffix = name[3];
220
221 if (prefix == COM || prefix == LPT) && matches!(suffix, ZERO..=NINE) {
222 return true;
223 }
224 }
225
226 false
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 fn clean(path: &str) -> Result<PathBuf, UnsafePathError> {
234 sanitize_path(path)
235 }
236
237 #[test]
238 fn accepts_plain_relative_paths() {
239 assert_eq!(clean("a/b/c").unwrap(), PathBuf::from("a/b/c"));
240 assert_eq!(clean("foo.txt").unwrap(), PathBuf::from("foo.txt"));
241 assert_eq!(
242 clean("dir/file.bin").unwrap(),
243 PathBuf::from("dir/file.bin")
244 );
245 }
246
247 #[test]
248 fn drops_current_dir_components() {
249 assert_eq!(clean("./a/./b").unwrap(), PathBuf::from("a/b"));
250 assert_eq!(clean("a/./b/c").unwrap(), PathBuf::from("a/b/c"));
251 }
252
253 #[test]
254 fn rejects_parent_dir_traversal() {
255 for payload in [
256 "..",
257 "../",
258 "../etc/passwd",
259 "../../../../etc/passwd",
260 "foo/../bar",
261 "foo/../../bar",
262 "a/b/../../../c",
263 "dir/..",
264 ] {
265 assert_eq!(
266 clean(payload),
267 Err(UnsafePathError::ParentDir),
268 "expected `{payload}` to be rejected as traversal",
269 );
270 }
271 }
272
273 #[test]
274 fn preserves_absolute_paths_without_a_root() {
275 #[cfg(unix)]
276 {
277 assert_eq!(clean("/etc/hosts").unwrap(), PathBuf::from("/etc/hosts"));
278 assert_eq!(
279 clean("/var/./www/index.html").unwrap(),
280 PathBuf::from("/var/www/index.html"),
281 );
282 assert_eq!(clean("/a/../b"), Err(UnsafePathError::ParentDir));
283 }
284 }
285
286 #[test]
287 fn rejects_absolute_paths_when_relative_only() {
288 #[cfg(unix)]
289 {
290 assert_eq!(
291 sanitize_relative_path("/etc/passwd"),
292 Err(UnsafePathError::Absolute),
293 );
294 assert_eq!(sanitize_relative_path("/"), Err(UnsafePathError::Absolute),);
295 }
296 assert_eq!(sanitize_relative_path("a/b").unwrap(), PathBuf::from("a/b"));
297 assert_eq!(
298 sanitize_relative_path("../a"),
299 Err(UnsafePathError::ParentDir),
300 );
301 }
302
303 #[test]
304 fn error_converts_to_invalid_input_io_error() {
305 let io_err: std::io::Error = UnsafePathError::ParentDir.into();
306 assert_eq!(io_err.kind(), std::io::ErrorKind::InvalidInput);
307 }
308
309 fn is_reserved(name: &str) -> bool {
310 is_reserved_device_name(name)
311 }
312
313 #[test]
314 fn detects_reserved_dos_names() {
315 for name in [
316 "CON", "con", "Con", "PRN", "AUX", "NUL", "nul", "COM1", "COM9", "com1", "LPT1",
317 "LPT9", "CONIN$", "CONOUT$", "CON.txt", "NUL.log", "com1.dat", "LPT3.bin", "CON ",
318 "CON.", "CON:", "COM0", "LPT0",
319 ] {
320 assert!(is_reserved(name), "expected `{name}` to be reserved");
321 }
322 assert!(is_reserved("COM\u{00B9}"));
323 assert!(is_reserved("COM\u{00B2}"));
324 assert!(is_reserved("COM\u{00B3}"));
325 }
326
327 #[test]
328 fn allows_non_reserved_names() {
329 for name in [
330 "CONSOLE",
331 "COM",
332 "COM10",
333 "LPT",
334 "LPT10",
335 "NULL",
336 "file.txt",
337 "console.log",
338 "communication",
339 "prnt",
340 "auxiliary",
341 ] {
342 assert!(!is_reserved(name), "expected `{name}` to be allowed");
343 }
344 }
345}