Skip to content

Commit 9826984

Browse files
tweak(path-util): addendum to #4482 (#4486)
* tweak(path-util): addendum to #4482 These changes improve on those introduced in #4482 in two ways: - The serialization logic for `SafeRelativeUtf8UnixPathBuf` now more closely mirrors the deserialization checks, reducing the chance that a generated path will fail to deserialize. While unlikely in practice, catching such theoretical cases earlier improves the experience for users and developers. - After deeper testing on a clean Windows 10 VM, I found that reserved device names can have both an extension and an alternate data stream appended, not just one or the other. These changes handle that case more gracefully. * chore: fix typos, add tests * fix(path-util): extend `SafeRelativeUtf8UnixPathBuf` contract to allow `.` components While quite useless, they were accepted by previous app versions, the `.mrpack` specification does not forbid them, and they do not pose security issues, so accept them for backwards compatibility.
1 parent ab6e9dd commit 9826984

File tree

2 files changed

+79
-43
lines changed

2 files changed

+79
-43
lines changed

packages/path-util/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ edition.workspace = true
44

55
[dependencies]
66
typed-path.workspace = true
7-
serde = { workspace = true, features = ["derive"] }
7+
serde.workspace = true
88
derive_more = { workspace = true, features = ["display", "deref"] }
99
itertools.workspace = true
1010

packages/path-util/src/lib.rs

Lines changed: 78 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ use serde::{
33
Deserialize, Deserializer, Serialize, Serializer,
44
de::value::StringDeserializer,
55
};
6-
use typed_path::{Utf8Component, Utf8TypedPathBuf, Utf8UnixPathBuf};
6+
use typed_path::{
7+
Utf8Component, Utf8TypedPathBuf, Utf8UnixComponent, Utf8UnixPathBuf,
8+
};
79

810
#[derive(
911
Eq, PartialEq, Hash, Debug, Clone, derive_more::Display, derive_more::Deref,
@@ -25,53 +27,24 @@ impl<'de> Deserialize<'de> for SafeRelativeUtf8UnixPathBuf {
2527
));
2628
};
2729

28-
// At this point, we may have a pseudo-Unix path like `my\directory`, which we should reject
29-
// to guarantee consistent cross-platform behavior when interpreting component separators
30-
if path.as_str().contains('\\') {
31-
return Err(serde::de::Error::custom(
32-
"File path must not contain backslashes",
33-
));
34-
}
35-
3630
let mut path_components = path.components().peekable();
3731

3832
if path_components.peek().is_none() {
3933
return Err(serde::de::Error::custom("File path cannot be empty"));
4034
}
4135

42-
// All components should be normal: a file or directory name, not `/`, `.`, or `..`
43-
if path_components.any(|component| !component.is_normal()) {
44-
return Err(serde::de::Error::custom(
45-
"File path cannot contain any special component or prefix",
46-
));
47-
}
48-
49-
if path_components.any(|component| {
50-
let file_name = component.as_str().to_ascii_uppercase();
51-
52-
// Windows reserves some special DOS device names in every directory, which may be optionally
53-
// followed by an extension or alternate data stream name and be case insensitive. Trying to
54-
// write, read, or delete these files is usually not that useful even for malware, since they
55-
// mostly refer to console and printer devices, but it's best to avoid them entirely anyway.
56-
// References:
57-
// https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions
58-
// https://devblogs.microsoft.com/oldnewthing/20031022-00/?p=42073
59-
// https://github.com/wine-mirror/wine/blob/01269452e0fbb1f081d506bd64996590a553e2b9/dlls/ntdll/path.c#L66
60-
const RESERVED_WINDOWS_DEVICE_NAMES: &[&str] = &[
61-
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4",
62-
"COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³",
63-
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
64-
"LPT9", "LPT¹", "LPT²", "LPT³", "CONIN$", "CONOUT$",
65-
];
66-
67-
RESERVED_WINDOWS_DEVICE_NAMES.iter().any(|name| {
68-
file_name == *name
69-
|| file_name.starts_with(&format!("{name}."))
70-
|| file_name.starts_with(&format!("{name}:"))
71-
})
36+
// All components should be normal: a file or directory name, not `/`, or `..`,
37+
// and not refer to any reserved Windows device name. Also, at this point we may have
38+
// a pseudo-Unix path like `my\directory`, which we should reject by filtering out
39+
// backslashes to guarantee consistent cross-platform behavior when interpreting component
40+
// separators
41+
if !path_components.all(|component| {
42+
(component.is_normal() || component.is_current())
43+
&& !component.as_str().contains('\\')
44+
&& !is_reserved_windows_device_name(&component)
7245
}) {
7346
return Err(serde::de::Error::custom(
74-
"File path contains a reserved Windows device name",
47+
"File path cannot contain any special component, prefix, reserved Windows device name, or backslashes",
7548
));
7649
}
7750

@@ -90,9 +63,13 @@ impl Serialize for SafeRelativeUtf8UnixPathBuf {
9063
return Err(serde::ser::Error::custom("File path cannot be empty"));
9164
}
9265

93-
if path_components.any(|component| !component.is_normal()) {
66+
if !path_components.all(|component| {
67+
(component.is_normal() || component.is_current())
68+
&& !component.as_str().contains('\\')
69+
&& !is_reserved_windows_device_name(&component)
70+
}) {
9471
return Err(serde::ser::Error::custom(
95-
"File path cannot contain any special component or prefix",
72+
"File path cannot contain any special component, prefix, reserved Windows device name, or backslashes",
9673
));
9774
}
9875

@@ -110,3 +87,62 @@ impl TryFrom<String> for SafeRelativeUtf8UnixPathBuf {
11087
Self::deserialize(StringDeserializer::new(s))
11188
}
11289
}
90+
91+
fn is_reserved_windows_device_name(component: &Utf8UnixComponent) -> bool {
92+
let file_name = component.as_str().to_ascii_uppercase();
93+
94+
// Windows reserves some special DOS device names in every directory, which may be optionally
95+
// followed by an extension or alternate data stream name and be case insensitive. Trying to
96+
// write, read, or delete these files is usually not that useful even for malware, since they
97+
// mostly refer to console and printer devices, but it's best to avoid them entirely anyway.
98+
// References:
99+
// https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions
100+
// https://devblogs.microsoft.com/oldnewthing/20031022-00/?p=42073
101+
// https://github.com/wine-mirror/wine/blob/01269452e0fbb1f081d506bd64996590a553e2b9/dlls/ntdll/path.c#L66
102+
const RESERVED_WINDOWS_DEVICE_NAMES: &[&str] = &[
103+
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5",
104+
"COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³", "LPT1", "LPT2",
105+
"LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²",
106+
"LPT³", "CONIN$", "CONOUT$",
107+
];
108+
109+
RESERVED_WINDOWS_DEVICE_NAMES.iter().any(|name| {
110+
file_name.starts_with(name)
111+
&& (file_name[name.len()..].is_empty()
112+
|| file_name[name.len()..].starts_with('.')
113+
|| file_name[name.len()..].starts_with(':'))
114+
})
115+
}
116+
117+
#[test]
118+
fn safe_relative_path_deserialization_contract() {
119+
let valid_paths = [
120+
"file.txt",
121+
"directory/file.txt",
122+
"my-directory/file.name.with.dots.tar.gz",
123+
"my_directory/123_456-789.file",
124+
"./my/file.txt",
125+
"my/./file.txt",
126+
];
127+
for path in valid_paths {
128+
SafeRelativeUtf8UnixPathBuf::try_from(path.to_string())
129+
.expect("Path should be considered valid");
130+
}
131+
132+
let invalid_paths = [
133+
"", // Empty path
134+
"/absolute/file.txt", // Absolute path
135+
"C:/absolute/file.txt", // Absolute path with common Windows prefix
136+
"//server/share/file.txt", // Absolute path with Windows UNC prefix
137+
"directory/../file.txt", // Path with `..` component
138+
"CON.txt", // Reserved Windows device name
139+
"NUL/file.txt", // Reserved Windows device name "directory"
140+
"COM1.txt:ads", // Reserved Windows device name with ADS name
141+
"file\\name.txt", // Backslash in file name
142+
"my\\directory/file.txt", // Backslash in directory name
143+
];
144+
for path in invalid_paths {
145+
SafeRelativeUtf8UnixPathBuf::try_from(path.to_string())
146+
.expect_err("Path should be considered invalid");
147+
}
148+
}

0 commit comments

Comments
 (0)