fix(docpath): accept drive hosts, legacy escapes and colon names; refuse lookalike separators

This commit is contained in:
aiongg
2026-10-16 16:14:16 +00:00
parent ddc5012437
commit 12281d07fe
2 changed files with 152 additions and 30 deletions
+102 -28
View File
@@ -17,6 +17,19 @@
//! - [`resolve`] turns a stored path into the path to open, relative to the document, or a [`Refused`]
//! that says why it may not be opened.
//!
//! # Caller contract
//!
//! - Pass the document whenever there is one. Without it, a relative, root-relative or
//! drive-relative result depends on the process working directory, which can be a network share,
//! so refuse any result that is not absolute.
//! - Hand the result to the filesystem through Rust `std` or wide (`W`) Windows APIs, and do not
//! percent-decode or normalise it again: an ANSI API or a second decoding pass can turn the
//! checked path into a different one.
//! - On Windows, when the document was opened from a mapped drive, pass its UNC path instead if the
//! app can get it (`WNetGetConnectionW`), so links on the same share resolve as that share.
//! - A lexical check cannot see mapped drives, `subst` drives, symlinks or junctions: a local-looking
//! path can still lead to another computer through them.
//!
//! ```
//! use std::path::Path;
//! use craft_docpath::{resolve, Refused};
@@ -39,9 +52,11 @@ pub enum PathKind {
Network,
/// A device or object-manager path: `\\.\…`, `\??\…`, `\\?\GLOBALROOT\…`, or a reserved name such as `NUL` or `COM1`.
Device,
/// A URL with a scheme other than `file:`, such as `https:` or `ms-word:`.
/// 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.
Url,
/// Not a usable path: empty, containing control characters, or climbing above its root with `..`.
/// Not a usable path: empty, containing control characters or lookalike separators, a name made
/// of dots, or climbing above its root with `..`.
Invalid,
}
@@ -102,8 +117,10 @@ pub fn is_local(raw: &str) -> bool {
///
/// `document` is the path of the document the link came from, if it has one. The steps:
///
/// 1. A `file:` URL is percent-decoded and its scheme removed. Only an empty host or `localhost`
/// is local; `file://host/…` is refused as [`Refused::Network`].
/// 1. A `file:` URL is percent-decoded (as Windows-1252 where the escapes are not UTF-8) and its
/// scheme removed. Only an empty host, `localhost` or a drive (`file://C:/x`) is local;
/// `file://host/…` is refused as [`Refused::Network`], and a URL with no path as
/// [`Refused::Invalid`].
/// 2. A relative path is joined to the document's folder, a root-relative path (`\x`) to the
/// document's drive or share, and a drive-relative path (`C:x`) to the document's folder when the
/// document is on that drive. Without a document they are returned unresolved.
@@ -196,7 +213,7 @@ fn folder(mut comps: Vec<String>) -> impl Iterator<Item = String> {
/// Reject control characters and non-file URLs; reduce a `file:` URL to its path.
fn strip_url(raw: &str) -> Result<String, Refused> {
if raw.is_empty() || raw.chars().any(char::is_control) {
if raw.is_empty() || raw.chars().any(is_bad_char) {
return Err(Refused::Invalid);
}
let path = match raw.get(..5) {
@@ -211,20 +228,35 @@ fn strip_url(raw: &str) -> Result<String, Refused> {
Ok(path)
}
/// `scheme:` with a scheme of two or more characters, so a drive letter (`C:`) is not one.
/// Control characters, and separator lookalikes that some code page best-fit mappings turn into
/// `\` or `/` when a path reaches an ANSI API.
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.
fn has_scheme(s: &str) -> bool {
let Some((scheme, _)) = s.split_once(':') else { return false };
const OPAQUE: [&str; 8] = ["mailto", "data", "javascript", "vbscript", "search-ms", "shell", "tel", "news"];
let Some((scheme, rest)) = s.split_once(':') else { return false };
let mut chars = scheme.chars();
chars.next().is_some_and(|c| c.is_ascii_alphabetic())
let valid = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
&& scheme.len() >= 2
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
&& 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())
}
/// The path of a `file:` URL (the part after `file:`).
fn file_url_path(rest: &str) -> Result<String, Refused> {
let rest = rest.split(['?', '#']).next().unwrap_or_default();
let decoded = percent_decode(rest)?;
if decoded.is_empty() || decoded.chars().any(char::is_control) {
let decoded = percent_decode(rest);
if decoded.is_empty() || decoded.chars().any(is_bad_char) {
return Err(Refused::Invalid);
}
let mut chars = decoded.chars();
@@ -235,9 +267,19 @@ fn file_url_path(rest: &str) -> Result<String, Refused> {
Some(i) => after.split_at(i),
None => (after, ""),
};
// `file://C:/x` and `file://C|/x` name the drive path `C:/x`.
let mut h = host.chars();
if let (Some(l), Some(':' | '|'), None) = (h.next(), h.next(), h.next())
&& l.is_ascii_alphabetic()
{
return Ok(format!("{l}:{path}"));
}
if !(host.is_empty() || host.eq_ignore_ascii_case("localhost")) {
return Err(Refused::Network);
}
if path.is_empty() {
return Err(Refused::Invalid);
}
path
}
_ => decoded.as_str(),
@@ -254,23 +296,43 @@ fn file_url_path(rest: &str) -> Result<String, Refused> {
Ok(path.to_string())
}
fn percent_decode(s: &str) -> Result<String, Refused> {
let hex = |c: Option<u8>| c.and_then(|c| (c as char).to_digit(16)).and_then(|d| u8::try_from(d).ok());
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while let Some(&c) = bytes.get(i) {
if c == b'%'
&& let (Some(h), Some(l)) = (hex(bytes.get(i + 1).copied()), hex(bytes.get(i + 2).copied()))
{
out.push((h << 4) | l);
i += 3;
} else {
out.push(c);
i += 1;
/// Percent-decode a `file:` URL path. A run of escapes that is not UTF-8 is read as Windows-1252,
/// which older Windows software used in file URLs; it maps 0x2F and 0x5C to `/` and `\` as UTF-8 does.
fn percent_decode(s: &str) -> String {
let hex = |c: Option<&u8>| c.and_then(|&c| (c as char).to_digit(16)).and_then(|d| u8::try_from(d).ok());
let mut out = String::with_capacity(s.len());
let mut run: Vec<u8> = Vec::new();
let mut rest = s;
loop {
let bytes = rest.as_bytes();
if let (Some(b'%'), Some(h), Some(l)) = (bytes.first(), hex(bytes.get(1)), hex(bytes.get(2))) {
run.push((h << 4) | l);
rest = rest.get(3..).unwrap_or_default();
continue;
}
match std::str::from_utf8(&run) {
Ok(text) => out.push_str(text),
Err(_) => out.extend(run.iter().map(|&b| windows_1252(b))),
}
run.clear();
let mut chars = rest.chars();
let Some(c) = chars.next() else { break };
out.push(c);
rest = chars.as_str();
}
out
}
/// Bytes 0x81, 0x8D, 0x8F, 0x90 and 0x9D are unassigned and map to C1 controls, which are refused.
fn windows_1252(b: u8) -> char {
const HIGH: [char; 32] = [
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
'˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ',
];
match b {
0x80..=0x9F => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or(char::REPLACEMENT_CHARACTER),
_ => char::from(b),
}
String::from_utf8(out).map_err(|_| Refused::Invalid)
}
/// Split a plain path into its root and components. `check` applies the component rules
@@ -361,8 +423,7 @@ fn comps(s: &str, check: bool) -> Result<Vec<String>, Refused> {
if is_dos_device(c) {
return Err(Refused::Device);
}
// Windows trims trailing dots and spaces, so `.. ` or `...` could act as `..`.
if c != "." && c != ".." && c.chars().all(|ch| ch == '.' || ch == ' ') {
if is_dot_name(c) {
return Err(Refused::Invalid);
}
}
@@ -385,11 +446,24 @@ 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) {
return Err(Refused::Invalid);
}
out.push(c.to_string());
}
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 `..`.
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 != ".."))
}
/// A reserved DOS device name, with or without an extension, stream or trailing dots and spaces.
fn is_dos_device(comp: &str) -> bool {
let stem = comp.split(['.', ':']).next().unwrap_or_default().trim_end_matches(' ');
+50 -2
View File
@@ -42,6 +42,18 @@ fn local_paths() {
"file:/C|/x",
"file:///C:/My%20Pictures/a.png",
"file:img/a.png",
"file://C:/x",
"file://c|/x",
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",
],
);
}
@@ -135,8 +147,16 @@ fn url_links() {
"webdav://h/x",
"mailto:a@b",
"svn+ssh://h/x",
"ab:c",
"file:http://h/x",
r"http:\\h\x",
"data:image/png;base64,AAAA",
"javascript:alert(1)",
"VBScript:x",
"ms-excel:ofe|u|https://h/x.xlsx",
"search-ms:query=x&crumb=location:\\\\h\\s",
"shell:startup",
"tel:123",
"news:comp.lang.rust",
],
);
}
@@ -155,7 +175,6 @@ fn invalid_paths() {
"file:",
"file:///C:/a%00b",
"file:///C:/a%0Ab",
"file:///C:/%FF",
r"C:\..\x",
r"C:\a\..\..\x",
"/..",
@@ -166,6 +185,21 @@ fn invalid_paths() {
r"C:\a\.. \b",
r"C:\a\...\b",
"a/. /b",
"file://",
"file://localhost",
"file://?x",
"file:#x",
"file:///C:/%81",
"a\u{FF3C}b",
"\\\u{FF3C}h\\s",
"a\u{FF0F}b",
"a\u{2215}b",
"a\u{29F5}b",
"file:///C:/a%EF%BC%BCb",
"..:",
".:x",
r"C:\a\..:x",
r"\\?\C:\a\..:x",
],
);
}
@@ -196,6 +230,13 @@ fn resolve_joins_relative_paths_to_the_document_folder() {
let verbatim = Path::new(r"\\?\C:\docs\a.doc");
assert_eq!(resolve("img/../x.png", Some(verbatim)).unwrap(), Path::new(r"\\?\C:\docs\x.png"));
assert_eq!(ok(r"C:..\x.png"), "C:/docs/x.png");
assert_eq!(ok(r"C:..\..\x.png"), "C:/x.png");
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!(slashed(&resolve("img/x.png", None).unwrap()), "img/x.png");
assert_eq!(slashed(&resolve("../x.png", None).unwrap()), "../x.png");
}
@@ -234,6 +275,13 @@ fn resolve_file_urls() {
assert_eq!(ok("file:///D:/a%23b.png#frag"), "D:/a#b.png");
assert_eq!(ok("file:img/x.png"), "C:/docs/img/x.png");
assert_eq!(slashed(&resolve("file:///home/u/x.png", None).unwrap()), "/home/u/x.png");
assert_eq!(ok("file://C:/x.png"), "C:/x.png");
assert_eq!(ok("file://d|/x.png"), "d:/x.png");
assert_eq!(ok("file:///C:/caf%E9.png"), "C:/café.png");
assert_eq!(ok("file:///C:/caf%C3%A9.png"), "C:/café.png");
assert_eq!(ok("file:///C:/caf%E9/na%C3%AFve%80.png"), "C:/café/naïve€.png");
assert_eq!(resolve("file://localhost", Some(doc)), Err(Refused::Invalid));
assert_eq!(resolve("file://", Some(doc)), Err(Refused::Invalid));
assert_eq!(resolve("file://host/s/x.png", Some(doc)), Err(Refused::Network));
assert_eq!(resolve("file:%5C%5Chost%5Cs%5Cx.png", Some(doc)), Err(Refused::Network));