From 12281d07fedff5c955bb68344fb287aa5d87971e Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 16:14:16 +0000 Subject: [PATCH] fix(docpath): accept drive hosts, legacy escapes and colon names; refuse lookalike separators --- crates/docpath/src/lib.rs | 130 +++++++++++++++++++++++++------- crates/docpath/tests/docpath.rs | 52 ++++++++++++- 2 files changed, 152 insertions(+), 30 deletions(-) diff --git a/crates/docpath/src/lib.rs b/crates/docpath/src/lib.rs index a108d264..30d1b908 100644 --- a/crates/docpath/src/lib.rs +++ b/crates/docpath/src/lib.rs @@ -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) -> impl Iterator { /// Reject control characters and non-file URLs; reduce a `file:` URL to its path. fn strip_url(raw: &str) -> Result { - 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 { 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 { 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 { 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 { Ok(path.to_string()) } -fn percent_decode(s: &str) -> Result { - let hex = |c: Option| 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 = 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, 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, 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(' '); diff --git a/crates/docpath/tests/docpath.rs b/crates/docpath/tests/docpath.rs index 000f46cf..541103ae 100644 --- a/crates/docpath/tests/docpath.rs +++ b/crates/docpath/tests/docpath.rs @@ -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));