fix(docpath): refuse colons inside path components

A stored path that starts with a URL scheme and a colon is a URL; a
colon in any other name is invalid. Only a leading drive letter keeps
its colon.
This commit is contained in:
aiongg
2026-10-17 03:35:36 +00:00
parent 12281d07fe
commit d7772fbb39
2 changed files with 50 additions and 35 deletions
+20 -28
View File
@@ -10,7 +10,10 @@
//!
//! The check is lexical: it does no I/O and gives the same answer on every platform. Windows path
//! forms (`C:\`, `\\server\share`, `\\?\`, `\\.\`, reserved names such as `NUL`) are recognised on
//! all platforms, and both `/` and `\` count as separators.
//! all platforms, and both `/` and `\` count as separators. A colon is allowed only after a drive
//! letter at the start (`C:\`, `C:/`, `C:x`, `\\?\C:\`, `file:///C:/`): a stored path that starts
//! with a scheme and a colon is a URL, and a colon inside any other name (which Windows reads as an
//! alternate data stream) makes the path invalid.
//!
//! - [`classify`] sorts a stored path into a [`PathKind`].
//! - [`is_local`] is `classify(raw) == PathKind::Local`.
@@ -52,11 +55,12 @@ pub enum PathKind {
Network,
/// A device or object-manager path: `\\.\…`, `\??\…`, `\\?\GLOBALROOT\…`, or a reserved name such as `NUL` or `COM1`.
Device,
/// A URL other than `file:`: `scheme://…`, or a scheme without a hierarchy such as `mailto:`,
/// `data:` or `ms-word:`. A relative name with a colon (`chapter1:intro.png`) is a path.
/// A URL other than `file:`: anything that starts with a URL scheme of two or more characters
/// and a colon, such as `https://…`, `mailto:`, `jar:…` or `chapter1:intro.png`.
Url,
/// Not a usable path: empty, containing control characters or lookalike separators, a name made
/// of dots, or climbing above its root with `..`.
/// Not a usable path: empty, containing control characters or lookalike separators, a colon
/// inside a name (`img/fig:1.png`, `C:\x:stream`), a name made of dots, or climbing above its
/// root with `..`.
Invalid,
}
@@ -234,22 +238,14 @@ fn is_bad_char(c: char) -> bool {
c.is_control() || matches!(c, '\u{FF3C}' | '\u{FF0F}' | '\u{2215}' | '\u{29F5}')
}
/// A URL scheme: `name://` (or with `\`), or `name:` for a scheme without a hierarchy. The scheme
/// has two or more characters, so a drive letter (`C:`) is not one.
/// Starts with a URL scheme (RFC 3986: a letter, then letters, digits, `+`, `-` or `.`) and a
/// colon. The scheme has two or more characters, so a drive letter (`C:`) is not one.
fn has_scheme(s: &str) -> bool {
const OPAQUE: [&str; 8] = ["mailto", "data", "javascript", "vbscript", "search-ms", "shell", "tel", "news"];
let Some((scheme, rest)) = s.split_once(':') else { return false };
let Some((scheme, _)) = s.split_once(':') else { return false };
let mut chars = scheme.chars();
let valid = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
chars.next().is_some_and(|c| c.is_ascii_alphabetic())
&& scheme.len() >= 2
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
if !valid {
return false;
}
let mut rest = rest.chars();
let hierarchical = matches!((rest.next(), rest.next()), (Some(a), Some(b)) if is_sep(a) && is_sep(b));
let scheme = scheme.to_ascii_lowercase();
hierarchical || scheme.starts_with("ms-") || OPAQUE.contains(&scheme.as_str())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
/// The path of a `file:` URL (the part after `file:`).
@@ -336,7 +332,7 @@ fn windows_1252(b: u8) -> char {
}
/// Split a plain path into its root and components. `check` applies the component rules
/// (reserved device names, dot-and-space names); the document's own path is not checked.
/// (reserved device names, dot-and-space names, colons); the document's own path is not checked.
fn parse(s: &str, check: bool) -> Result<Parsed, Refused> {
let mut chars = s.chars();
let first = chars.next();
@@ -423,7 +419,7 @@ fn comps(s: &str, check: bool) -> Result<Vec<String>, Refused> {
if is_dos_device(c) {
return Err(Refused::Device);
}
if is_dot_name(c) {
if is_dot_name(c) || c.contains(':') {
return Err(Refused::Invalid);
}
}
@@ -446,7 +442,7 @@ fn verbatim_comps(s: &str, check: bool) -> Result<Vec<String>, Refused> {
if check && is_dos_device(c) {
return Err(Refused::Device);
}
if check && is_dot_name(c) {
if check && (is_dot_name(c) || c.contains(':')) {
return Err(Refused::Invalid);
}
out.push(c.to_string());
@@ -454,14 +450,10 @@ fn verbatim_comps(s: &str, check: bool) -> Result<Vec<String>, Refused> {
Ok(out)
}
/// A name of dots and spaces other than `.` and `..`, or `.`/`..` with a stream (`..:x`). Windows
/// trims trailing dots and spaces, so `.. ` or `...` could act as `..`.
/// A name of dots and spaces other than `.` and `..`. Windows trims trailing dots and spaces, so
/// `.. ` or `...` could act as `..`.
fn is_dot_name(comp: &str) -> bool {
let (stem, stream) = match comp.split_once(':') {
Some((stem, _)) => (stem, true),
None => (comp, false),
};
!stem.is_empty() && stem.chars().all(|ch| ch == '.' || ch == ' ') && (stream || (stem != "." && stem != ".."))
!comp.is_empty() && comp != "." && comp != ".." && comp.chars().all(|ch| ch == '.' || ch == ' ')
}
/// A reserved DOS device name, with or without an extension, stream or trailing dots and spaces.
+30 -7
View File
@@ -29,7 +29,6 @@ fn local_paths() {
"C:x",
"C:",
r"\x",
r"C:\x:stream",
"console.txt",
"nul_report.txt",
"com10.txt",
@@ -47,13 +46,12 @@ fn local_paths() {
r"file:\\C:\x",
"file:///C:/caf%E9.png",
"file:///C:/%FF",
"chapter1:intro.png",
"notes:v2.pdf",
"ab:c",
"img/fig:1.png",
r"C:\\host\share",
r"./\\host\share\x",
r"C:\a\.b:c",
"D:/x",
"z:",
"file:///D:",
r"\\?\Z:\x",
],
);
}
@@ -157,6 +155,14 @@ fn url_links() {
"shell:startup",
"tel:123",
"news:comp.lang.rust",
"jar:file:///C:/x.jar!/a.png",
"view-source:http://h/x",
"name:stream",
"chapter1:intro.png",
"x.png:stream",
"ab:c",
"file:jar:x",
"a1+b-c.d:x",
],
);
}
@@ -200,6 +206,19 @@ fn invalid_paths() {
".:x",
r"C:\a\..:x",
r"\\?\C:\a\..:x",
"a/b:c.png",
"img/fig:1.png",
r"C:\x:stream",
r"C:\a\.b:c",
"C:x:y",
"C::x",
"/home/u/a:b",
"file:///C:/a:b",
"file:///C:/a%3Ab",
r"\\?\C:\a:b",
":x",
"1a:b",
"./ab:c",
],
);
}
@@ -235,7 +254,11 @@ fn resolve_joins_relative_paths_to_the_document_folder() {
assert_eq!(resolve(r"C:..\..\x.png", Some(Path::new(r"C:\docs\a.doc"))), Err(Refused::Invalid));
assert_eq!(ok(r"./\\host\share\x.png"), "C:/docs/report/host/share/x.png");
assert_eq!(ok(r"C:\\host\share"), "C:/host/share");
assert_eq!(ok("chapter1:intro.png"), "C:/docs/report/chapter1:intro.png");
assert_eq!(resolve("chapter1:intro.png", Some(doc)), Err(Refused::Url));
assert_eq!(resolve("img/fig:1.png", Some(doc)), Err(Refused::Invalid));
// The document's own name may contain colons.
let colon_doc = Path::new("/home/u/a:b/c:d.doc");
assert_eq!(slashed(&resolve("x.png", Some(colon_doc)).unwrap()), "/home/u/a:b/x.png");
assert_eq!(slashed(&resolve("img/x.png", None).unwrap()), "img/x.png");
assert_eq!(slashed(&resolve("../x.png", None).unwrap()), "../x.png");