From ddc501243700b05da35c2868ee6fa20e190fcb11 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 16:01:59 +0000 Subject: [PATCH 1/7] feat(docpath): add craft-docpath for checking paths stored in documents --- Cargo.lock | 4 + Cargo.toml | 1 + README.md | 3 + crates/docpath/Cargo.toml | 11 + crates/docpath/src/lib.rs | 453 ++++++++++++++++++++++++++++++++ crates/docpath/tests/docpath.rs | 258 ++++++++++++++++++ 6 files changed, 730 insertions(+) create mode 100644 crates/docpath/Cargo.toml create mode 100644 crates/docpath/src/lib.rs create mode 100644 crates/docpath/tests/docpath.rs diff --git a/Cargo.lock b/Cargo.lock index 8461f6b35..4f666a2cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -597,6 +597,10 @@ dependencies = [ "rayon", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" + [[package]] name = "craft-pdf" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index ed556fc45..b65342a2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ bytemuck = "1" thiserror = "2" craft-atomic = { path = "crates/atomic" } craft-cms = { path = "crates/cms" } +craft-docpath = { path = "crates/docpath" } craft-print = { path = "crates/print" } craft-quit = { path = "crates/quit" } craft-settings = { path = "crates/settings" } diff --git a/README.md b/README.md index a5db4f308..e7b786b09 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = ") -> fmt::Result { + f.write_str(match self { + Self::Network => "the path is on another computer", + Self::Device => "the path names a device, not a file", + Self::Url => "the link is not a file path", + Self::Invalid => "the path is not valid", + }) + } +} + +impl std::error::Error for Refused {} + +impl From for PathKind { + fn from(r: Refused) -> Self { + match r { + Refused::Network => Self::Network, + Refused::Device => Self::Device, + Refused::Url => Self::Url, + Refused::Invalid => Self::Invalid, + } + } +} + +/// Classify a path or URL stored in a document, without a document to resolve it against. +/// +/// Relative paths are [`PathKind::Local`]; a rooted path whose `..` components climb above its +/// drive, share or `/` is [`PathKind::Invalid`]. +pub fn classify(raw: &str) -> PathKind { + match resolve(raw, None) { + Ok(_) => PathKind::Local, + Err(r) => r.into(), + } +} + +/// Whether a path or URL stored in a document refers to a local file (see [`classify`]). +pub fn is_local(raw: &str) -> bool { + classify(raw) == PathKind::Local +} + +/// Turn a path or URL stored in a document into the path to open, or refuse it. +/// +/// `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`]. +/// 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. +/// 3. `.` and `..` are folded lexically; climbing above a drive, share or `/` is [`Refused::Invalid`]. +/// 4. A network path is allowed only when it is on the document's own share (same `\\server\share`, +/// compared ignoring ASCII case). Other network paths, device paths and reserved device names +/// are refused. +/// +/// Windows-form results (drive, share, `\\?\`) use `\` as the separator on every platform; others +/// use the platform separator. +pub fn resolve(raw: &str, document: Option<&Path>) -> Result { + let plain = strip_url(raw)?; + let link = parse(&plain, true)?; + + let doc_str = document.and_then(Path::to_str); + if let (Some(doc), None, Root::Relative) = (document, doc_str, &link.root) { + return join_native(doc, &link.comps); + } + let doc = doc_str.map(|d| parse(d, false)); + let doc_share = match &doc { + Some(Ok(Parsed { root: Root::Share { host, share, .. }, .. })) => Some((host.clone(), share.clone())), + _ => None, + }; + + let (root, comps) = match (link.root, doc) { + (Root::Relative, Some(doc)) => { + let doc = doc?; + (doc.root, folder(doc.comps).chain(link.comps).collect()) + } + (Root::Rooted, Some(Ok(doc))) if doc.root.is_absolute() => (doc.root, link.comps), + (Root::DriveRelative(l), Some(Ok(doc))) if doc.root.drive().is_some_and(|d| d.eq_ignore_ascii_case(&l)) => { + (doc.root, folder(doc.comps).chain(link.comps).collect()) + } + (root, _) => (root, link.comps), + }; + let comps = normalize(&root, comps)?; + + if let Root::Share { host, share, .. } = &root { + let same = doc_share.is_some_and(|(h, s)| h.eq_ignore_ascii_case(host) && s.eq_ignore_ascii_case(share)); + if !same { + return Err(Refused::Network); + } + } + Ok(render(&root, &comps)) +} + +#[derive(Debug)] +enum Root { + /// `a\b` + Relative, + /// `\a` or `/a` + Rooted, + /// `C:a` + DriveRelative(char), + /// `C:\a` + Drive(char), + /// `\\?\C:\a` + VerbatimDrive(char), + /// `\\host\share\a`, or `\\?\UNC\host\share\a` when `verbatim` + Share { host: String, share: String, verbatim: bool }, +} + +impl Root { + fn is_absolute(&self) -> bool { + !matches!(self, Self::Relative | Self::DriveRelative(_)) + } + + fn drive(&self) -> Option { + match self { + Self::Drive(l) | Self::VerbatimDrive(l) => Some(*l), + _ => None, + } + } +} + +struct Parsed { + root: Root, + comps: Vec, +} + +fn is_sep(c: char) -> bool { + c == '/' || c == '\\' +} + +/// The document's folder: its components without the file name. +fn folder(mut comps: Vec) -> impl Iterator { + comps.pop(); + comps.into_iter() +} + +/// 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) { + return Err(Refused::Invalid); + } + let path = match raw.get(..5) { + Some(scheme) if scheme.eq_ignore_ascii_case("file:") => file_url_path(raw.get(5..).unwrap_or_default())?, + _ => raw.to_string(), + }; + if has_scheme(&path) { + // `NUL:` and `COM1:` name devices, not URL schemes. + let device = path.split_once(':').is_some_and(|(scheme, _)| is_dos_device(scheme)); + return Err(if device { Refused::Device } else { Refused::Url }); + } + Ok(path) +} + +/// `scheme:` with a scheme of 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 }; + let mut chars = scheme.chars(); + chars.next().is_some_and(|c| c.is_ascii_alphabetic()) + && scheme.len() >= 2 + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) +} + +/// 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) { + return Err(Refused::Invalid); + } + let mut chars = decoded.chars(); + let path = match (chars.next(), chars.next()) { + (Some(a), Some(b)) if is_sep(a) && is_sep(b) => { + let after = chars.as_str(); + let (host, path) = match after.find(is_sep) { + Some(i) => after.split_at(i), + None => (after, ""), + }; + if !(host.is_empty() || host.eq_ignore_ascii_case("localhost")) { + return Err(Refused::Network); + } + path + } + _ => decoded.as_str(), + }; + // `/C:/x` and `/C|/x` name the drive path `C:/x`. + let mut chars = path.chars(); + if let (Some(s), Some(l), Some(':' | '|')) = (chars.next(), chars.next(), chars.next()) + && is_sep(s) + && l.is_ascii_alphabetic() + && chars.clone().next().is_none_or(is_sep) + { + return Ok(format!("{l}:{}", chars.as_str())); + } + 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; + } + } + String::from_utf8(out).map_err(|_| Refused::Invalid) +} + +/// 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. +fn parse(s: &str, check: bool) -> Result { + let mut chars = s.chars(); + let first = chars.next(); + let second = chars.next(); + let rest = chars.as_str(); + match (first, second) { + (Some(a), Some(b)) if is_sep(a) && is_sep(b) => parse_double(s, check), + (Some(a), _) if is_sep(a) => { + let after = s.get(1..).unwrap_or_default(); + if let Some(r) = after.strip_prefix("??") + && r.chars().next().is_none_or(is_sep) + { + return Err(Refused::Device); + } + Ok(Parsed { root: Root::Rooted, comps: comps(after, check)? }) + } + (Some(l), Some(':')) if l.is_ascii_alphabetic() => { + let mut r = rest.chars(); + match r.next() { + Some(c) if is_sep(c) => Ok(Parsed { root: Root::Drive(l), comps: comps(r.as_str(), check)? }), + _ => Ok(Parsed { root: Root::DriveRelative(l), comps: comps(rest, check)? }), + } + } + _ => Ok(Parsed { root: Root::Relative, comps: comps(s, check)? }), + } +} + +/// A path starting with two separators: a share, or a `\\?\`, `\\.\` or `//?/` prefix. +fn parse_double(s: &str, check: bool) -> Result { + let t = s.get(2..).unwrap_or_default(); + let (first, after) = t.split_once(is_sep).unwrap_or((t, "")); + if first != "?" && first != "." { + return parse_share(t, false, check); + } + if let Some(v) = s.strip_prefix(r"\\?\") { + return parse_verbatim(v, check); + } + // `\\.\`, and `\\?\` spelled with any `/`, which Windows normalises into the device namespace. + let (next, rest) = after.split_once(is_sep).unwrap_or((after, "")); + if next.eq_ignore_ascii_case("UNC") { + return parse_share(rest, false, check); + } + Err(Refused::Device) +} + +/// `\\?\` paths: only `\`, and no normalisation, so `.`, `..` and `/` are not usable in them. +fn parse_verbatim(v: &str, check: bool) -> Result { + let (first, rest) = match v.split_once('\\') { + Some((f, r)) => (f, Some(r)), + None => (v, None), + }; + let mut drive = first.chars(); + match (drive.next(), drive.next(), drive.next(), rest) { + // `\\?\C:` without a separator is the volume, not its root folder. + (Some(l), Some(':'), None, Some(rest)) if l.is_ascii_alphabetic() => { + Ok(Parsed { root: Root::VerbatimDrive(l), comps: verbatim_comps(rest, check)? }) + } + _ if first.eq_ignore_ascii_case("UNC") && rest.is_some() => parse_share(rest.unwrap_or_default(), true, check), + _ => Err(Refused::Device), + } +} + +/// `host\share\rest`, the part after the leading separators (or `\\?\UNC\`). +fn parse_share(t: &str, verbatim: bool, check: bool) -> Result { + let split = |s: &str| -> (String, String) { + let pair = if verbatim { s.split_once('\\') } else { s.split_once(is_sep) }; + let (a, b) = pair.unwrap_or((s, "")); + (a.to_string(), b.to_string()) + }; + let (host, rest) = split(t); + let (share, rest) = split(&rest); + if host.is_empty() || share.is_empty() { + return Err(Refused::Network); + } + let comps = if verbatim { verbatim_comps(&rest, check)? } else { comps(&rest, check)? }; + Ok(Parsed { root: Root::Share { host, share, verbatim }, comps }) +} + +fn comps(s: &str, check: bool) -> Result, Refused> { + s.split(is_sep) + .filter(|c| !c.is_empty()) + .map(|c| { + if check { + 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 == ' ') { + return Err(Refused::Invalid); + } + } + Ok(c.to_string()) + }) + .collect() +} + +fn verbatim_comps(s: &str, check: bool) -> Result, Refused> { + let parts: Vec<&str> = s.split('\\').collect(); + let last = parts.len().saturating_sub(1); + let mut out = Vec::with_capacity(parts.len()); + for (i, c) in parts.into_iter().enumerate() { + if c.is_empty() && i == last { + continue; + } + if c.is_empty() || c == "." || c == ".." || c.contains('/') { + return Err(Refused::Invalid); + } + if check && is_dos_device(c) { + return Err(Refused::Device); + } + out.push(c.to_string()); + } + Ok(out) +} + +/// 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(' '); + if ["CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"].iter().any(|n| stem.eq_ignore_ascii_case(n)) { + return true; + } + let Some(head) = stem.get(..3) else { return false }; + let mut tail = stem.get(3..).unwrap_or_default().chars(); + (head.eq_ignore_ascii_case("COM") || head.eq_ignore_ascii_case("LPT")) + && matches!((tail.next(), tail.next()), (Some('0'..='9' | '¹' | '²' | '³'), None)) +} + +/// Fold `.` and `..`. A rooted path may not climb above its root; a relative one keeps leading `..`. +fn normalize(root: &Root, comps: Vec) -> Result, Refused> { + let mut out: Vec = Vec::with_capacity(comps.len()); + for c in comps { + match c.as_str() { + "." => {} + ".." => match out.last() { + Some(last) if last != ".." => { + out.pop(); + } + _ if root.is_absolute() => return Err(Refused::Invalid), + _ => out.push(c), + }, + _ => out.push(c), + } + } + Ok(out) +} + +fn render(root: &Root, comps: &[String]) -> PathBuf { + let (prefix, sep) = match root { + Root::Relative if comps.is_empty() => (".".to_string(), MAIN_SEPARATOR), + Root::Relative => (String::new(), MAIN_SEPARATOR), + Root::Rooted => (MAIN_SEPARATOR.to_string(), MAIN_SEPARATOR), + Root::DriveRelative(l) => (format!("{l}:"), '\\'), + Root::Drive(l) => (format!("{l}:\\"), '\\'), + Root::VerbatimDrive(l) => (format!(r"\\?\{l}:\"), '\\'), + Root::Share { host, share, verbatim: false } => (format!(r"\\{host}\{share}\"), '\\'), + Root::Share { host, share, verbatim: true } => (format!(r"\\?\UNC\{host}\{share}\"), '\\'), + }; + PathBuf::from(prefix + &comps.join(&sep.to_string())) +} + +/// A relative link next to a document whose path is not UTF-8 (possible on Unix only). +fn join_native(doc: &Path, comps: &[String]) -> Result { + let mut p = doc.parent().map(Path::to_path_buf).unwrap_or_default(); + for c in comps { + match c.as_str() { + "." => {} + ".." => { + if !p.pop() { + return Err(Refused::Invalid); + } + } + _ => p.push(c), + } + } + Ok(p) +} diff --git a/crates/docpath/tests/docpath.rs b/crates/docpath/tests/docpath.rs new file mode 100644 index 000000000..000f46cfe --- /dev/null +++ b/crates/docpath/tests/docpath.rs @@ -0,0 +1,258 @@ +use std::path::Path; + +use craft_docpath::{PathKind, Refused, classify, is_local, resolve}; + +fn check(kind: PathKind, cases: &[&str]) { + for raw in cases { + assert_eq!(classify(raw), kind, "{raw:?}"); + assert_eq!(is_local(raw), kind == PathKind::Local, "{raw:?}"); + } +} + +#[test] +fn local_paths() { + check( + PathKind::Local, + &[ + r"C:\x", + "C:/x", + r"c:\Users\me\Pictures\a.png", + r"\\?\C:\x", + r"\\?\C:\", + r"\\?\D:\long\folder\file.txt.", + "/home/u/x", + "/", + "img/a.png", + r"img\a.png", + "./a.png", + "../shared/a.png", + "C:x", + "C:", + r"\x", + r"C:\x:stream", + "console.txt", + "nul_report.txt", + "com10.txt", + "lpt.txt", + "auxiliary/a.png", + "file:///C:/x/a.png", + "file:///home/u/a.png", + "file://localhost/C:/x", + "FILE://LOCALHOST/home/u/x", + "file:/C|/x", + "file:///C:/My%20Pictures/a.png", + "file:img/a.png", + ], + ); +} + +#[test] +fn network_paths() { + check( + PathKind::Network, + &[ + r"\\h\s", + r"\\h\s\x.png", + "//h/s/x.png", + r"\/h\s", + r"/\h\s", + r"\\h", + r"\\", + r"\\\h\s", + r"\\?\UNC\h\s\x", + r"\\?\unc\h\s", + "//?/UNC/h/s/x", + r"\\.\UNC\h\s\x", + r"\\h@SSL\s\x", + r"\\h@80\s\x", + r"\\h@SSL@443\DavWWWRoot\x", + "file://h/s/x.png", + "file:////h/s/x.png", + r"file:\\h\s\x.png", + "file:%5C%5Ch%5Cs%5Cx.png", + "file:///%5C%5Ch/s/x.png", + "file://%5C%5Ch/s", + "file:%2F%2Fh/s/x", + ], + ); +} + +#[test] +fn device_paths() { + check( + PathKind::Device, + &[ + r"\\.\C:", + r"\\.\PhysicalDrive0", + r"\\.\pipe\x", + "//./pipe/x", + r"\\.", + r"\??\C:\x", + "/??/C:/x", + r"\??", + r"\\?", + r"\\?\", + r"\\?\C:", + r"\\?\C:x", + r"\\?\GLOBALROOT\Device\HarddiskVolume1\x", + r"\\?\Volume{12345678-1234-1234-1234-123456789abc}\x", + "//?/C:/x", + r"\/?\C:\x", + r"/\?\C:\x", + r"\\?/C:\x", + "CON", + "con.txt", + "Nul", + "nul.tar.gz", + "PRN...", + "aux .png", + "COM1", + "com9.log", + "COM¹", + "LPT1", + "lpt9.txt", + "CONIN$", + "conout$", + "NUL:", + r"C:\docs\nul", + "img/CON/a.png", + r"\\?\C:\docs\con.txt", + "file:///C:/nul", + ], + ); +} + +#[test] +fn url_links() { + check( + PathKind::Url, + &[ + "http://h/x.png", + "https://h/x.png", + "HTTPS://h/x.png", + "ms-word:ofe|u|https://h/x.docx", + "smb://h/s/x", + "webdav://h/x", + "mailto:a@b", + "svn+ssh://h/x", + "ab:c", + "file:http://h/x", + ], + ); +} + +#[test] +fn invalid_paths() { + check( + PathKind::Invalid, + &[ + "", + "a\0b", + "a\nb", + "a\tb", + "a\u{7f}", + "a\u{85}b", + "file:", + "file:///C:/a%00b", + "file:///C:/a%0Ab", + "file:///C:/%FF", + r"C:\..\x", + r"C:\a\..\..\x", + "/..", + r"\\?\C:\a\..\b", + r"\\?\C:\a\.\b", + r"\\?\C:\a/b", + r"\\?\C:\a\\b", + r"C:\a\.. \b", + r"C:\a\...\b", + "a/. /b", + ], + ); +} + +fn slashed(p: &Path) -> String { + p.to_string_lossy().replace('\\', "/") +} + +#[test] +fn resolve_joins_relative_paths_to_the_document_folder() { + let doc = Path::new(r"C:\docs\report\a.doc"); + let ok = |raw: &str| slashed(&resolve(raw, Some(doc)).unwrap()); + assert_eq!(resolve(r"img\x.png", Some(doc)).unwrap(), Path::new(r"C:\docs\report\img\x.png")); + assert_eq!(ok("img/x.png"), "C:/docs/report/img/x.png"); + assert_eq!(ok("./x.png"), "C:/docs/report/x.png"); + assert_eq!(ok("../shared/x.png"), "C:/docs/shared/x.png"); + assert_eq!(ok(r"\x.png"), "C:/x.png"); + assert_eq!(ok("C:x.png"), "C:/docs/report/x.png"); + assert_eq!(ok("D:x.png"), "D:x.png"); + assert_eq!(ok(r"D:\x.png"), "D:/x.png"); + assert_eq!(resolve(r"..\..\..\x.png", Some(doc)), Err(Refused::Invalid)); + + let unix = Path::new("/home/u/docs/a.doc"); + assert_eq!(slashed(&resolve("img/x.png", Some(unix)).unwrap()), "/home/u/docs/img/x.png"); + assert_eq!(slashed(&resolve(r"img\x.png", Some(unix)).unwrap()), "/home/u/docs/img/x.png"); + assert_eq!(slashed(&resolve("../x.png", Some(unix)).unwrap()), "/home/u/x.png"); + + 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!(slashed(&resolve("img/x.png", None).unwrap()), "img/x.png"); + assert_eq!(slashed(&resolve("../x.png", None).unwrap()), "../x.png"); +} + +#[test] +fn resolve_allows_only_the_document_share() { + let doc = Path::new(r"\\Server\Share\docs\a.doc"); + assert_eq!(resolve(r"img\x.png", Some(doc)).unwrap(), Path::new(r"\\Server\Share\docs\img\x.png")); + assert_eq!(resolve(r"\x.png", Some(doc)).unwrap(), Path::new(r"\\Server\Share\x.png")); + assert_eq!(resolve(r"\\server\share\img\x.png", Some(doc)).unwrap(), Path::new(r"\\server\share\img\x.png")); + assert!(resolve("//SERVER/SHARE/img/x.png", Some(doc)).is_ok()); + assert!(resolve(r"\\?\UNC\server\share\x.png", Some(doc)).is_ok()); + assert!(resolve("file://localhost//server/share/x.png", Some(doc)).is_ok()); + + assert_eq!(resolve(r"\\other\share\x.png", Some(doc)), Err(Refused::Network)); + assert_eq!(resolve(r"\\server\other\x.png", Some(doc)), Err(Refused::Network)); + assert_eq!(resolve(r"\\server\share\..\other\x.png", Some(doc)), Err(Refused::Invalid)); + assert_eq!(resolve(r"..\..\..\x.png", Some(doc)), Err(Refused::Invalid)); + assert_eq!(resolve("file://server/share/x.png", Some(doc)), Err(Refused::Network)); + assert_eq!(resolve(r"\\server\share\nul", Some(doc)), Err(Refused::Device)); + + let verbatim = Path::new(r"\\?\UNC\server\share\docs\a.doc"); + assert_eq!(resolve("img/x.png", Some(verbatim)).unwrap(), Path::new(r"\\?\UNC\server\share\docs\img\x.png")); + assert!(resolve(r"\\server\share\x.png", Some(verbatim)).is_ok()); + + let local = Path::new(r"C:\docs\a.doc"); + assert_eq!(resolve(r"\\server\share\x.png", Some(local)), Err(Refused::Network)); + assert_eq!(resolve(r"\\server\share\x.png", None), Err(Refused::Network)); +} + +#[test] +fn resolve_file_urls() { + let doc = Path::new(r"C:\docs\a.doc"); + let ok = |raw: &str| slashed(&resolve(raw, Some(doc)).unwrap()); + assert_eq!(ok("file:///D:/My%20Pictures/x.png"), "D:/My Pictures/x.png"); + 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!(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)); + assert_eq!(resolve("file:///%5C%5Chost/s/x.png", Some(doc)), Err(Refused::Network)); + assert_eq!(resolve("file:////./pipe/x", Some(doc)), Err(Refused::Device)); + assert_eq!(resolve("https://host/x.png", Some(doc)), Err(Refused::Url)); +} + +#[test] +fn resolve_refuses_device_documents_and_paths() { + let doc = Path::new(r"\\?\GLOBALROOT\Device\X\a.doc"); + assert_eq!(resolve("x.png", Some(doc)), Err(Refused::Device)); + assert_eq!(resolve(r"C:\x.png", Some(doc)).unwrap(), Path::new(r"C:\x.png")); + assert_eq!(resolve(r"\\.\pipe\x", Some(Path::new(r"C:\a.doc"))), Err(Refused::Device)); +} + +#[test] +fn refusals_have_messages() { + for r in [Refused::Network, Refused::Device, Refused::Url, Refused::Invalid] { + assert!(!r.to_string().is_empty()); + } +} From 12281d07fedff5c955bb68344fb287aa5d87971e Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 16:14:16 +0000 Subject: [PATCH 2/7] 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 a108d2649..30d1b9083 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 000f46cfe..541103ae0 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)); From d7772fbb39037fc73a79e971aa44b0bf455b74cf Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 17 Oct 2026 03:35:36 +0000 Subject: [PATCH 3/7] 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. --- crates/docpath/src/lib.rs | 48 ++++++++++++++------------------- crates/docpath/tests/docpath.rs | 37 ++++++++++++++++++++----- 2 files changed, 50 insertions(+), 35 deletions(-) diff --git a/crates/docpath/src/lib.rs b/crates/docpath/src/lib.rs index 30d1b9083..f95f83c82 100644 --- a/crates/docpath/src/lib.rs +++ b/crates/docpath/src/lib.rs @@ -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 { let mut chars = s.chars(); let first = chars.next(); @@ -423,7 +419,7 @@ fn comps(s: &str, check: bool) -> Result, 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, 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, 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. diff --git a/crates/docpath/tests/docpath.rs b/crates/docpath/tests/docpath.rs index 541103ae0..a6a28ec3b 100644 --- a/crates/docpath/tests/docpath.rs +++ b/crates/docpath/tests/docpath.rs @@ -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"); From 0d0e5efaacb39325faa22eb4bf13218e4af03073 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 17 Oct 2026 03:36:22 +0000 Subject: [PATCH 4/7] feat(docpath): add resolve_absolute resolve_absolute refuses a result that is not absolute on the running platform, such as a relative link in a document that has no path yet. It is the function to call before opening a file. --- crates/docpath/src/lib.rs | 28 ++++++++++++++++++++++------ crates/docpath/tests/docpath.rs | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/crates/docpath/src/lib.rs b/crates/docpath/src/lib.rs index f95f83c82..006563bc0 100644 --- a/crates/docpath/src/lib.rs +++ b/crates/docpath/src/lib.rs @@ -17,14 +17,16 @@ //! //! - [`classify`] sorts a stored path into a [`PathKind`]. //! - [`is_local`] is `classify(raw) == PathKind::Local`. -//! - [`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. +//! - [`resolve_absolute`] turns a stored path into the absolute path to open, relative to the +//! document, or a [`Refused`] that says why it may not be opened. Use it to open files. +//! - [`resolve`] is the same without the absolute check, for callers that only display or compare +//! the result. //! //! # 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. +//! drive-relative result depends on the process working directory, which can be a network share; +//! [`resolve_absolute`] refuses such a result. //! - 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. @@ -35,12 +37,13 @@ //! //! ``` //! use std::path::Path; -//! use craft_docpath::{resolve, Refused}; +//! use craft_docpath::{resolve, resolve_absolute, Refused}; //! //! let doc = Path::new(r"\\server\share\docs\report.doc"); //! assert!(resolve(r"img\logo.png", Some(doc)).is_ok()); //! assert!(resolve(r"\\server\share\img\logo.png", Some(doc)).is_ok()); //! assert_eq!(resolve(r"\\other\share\logo.png", Some(doc)), Err(Refused::Network)); +//! assert_eq!(resolve_absolute("img/logo.png", None), Err(Refused::Invalid)); //! ``` use std::fmt; @@ -117,7 +120,20 @@ pub fn is_local(raw: &str) -> bool { classify(raw) == PathKind::Local } -/// Turn a path or URL stored in a document into the path to open, or refuse it. +/// Turn a path or URL stored in a document into the absolute path to open, or refuse it. This is +/// the function to call before opening, reading or writing the file. +/// +/// It is [`resolve`] followed by a check that the result is absolute on the running platform +/// ([`Path::is_absolute`]); a result that is not, such as a relative link in a document that has +/// no path yet, is refused as [`Refused::Invalid`]. On Unix, Windows-form results (`C:\x`, +/// `\\server\share\x`) are not absolute and are refused too. +pub fn resolve_absolute(raw: &str, document: Option<&Path>) -> Result { + let path = resolve(raw, document)?; + if path.is_absolute() { Ok(path) } else { Err(Refused::Invalid) } +} + +/// Turn a path or URL stored in a document into the path to open, or refuse it. The result may be +/// relative; to open a file, use [`resolve_absolute`]. /// /// `document` is the path of the document the link came from, if it has one. The steps: /// diff --git a/crates/docpath/tests/docpath.rs b/crates/docpath/tests/docpath.rs index a6a28ec3b..03cc6281b 100644 --- a/crates/docpath/tests/docpath.rs +++ b/crates/docpath/tests/docpath.rs @@ -1,6 +1,6 @@ use std::path::Path; -use craft_docpath::{PathKind, Refused, classify, is_local, resolve}; +use craft_docpath::{PathKind, Refused, classify, is_local, resolve, resolve_absolute}; fn check(kind: PathKind, cases: &[&str]) { for raw in cases { @@ -321,6 +321,37 @@ fn resolve_refuses_device_documents_and_paths() { assert_eq!(resolve(r"\\.\pipe\x", Some(Path::new(r"C:\a.doc"))), Err(Refused::Device)); } +#[test] +fn resolve_absolute_refuses_results_that_are_not_absolute() { + for raw in ["img/x.png", "../x.png", "x.png", "file:img/x.png"] { + assert!(resolve(raw, None).is_ok(), "{raw:?}"); + assert_eq!(resolve_absolute(raw, None), Err(Refused::Invalid), "{raw:?}"); + } + assert_eq!(resolve_absolute(r"\\h\s\x.png", None), Err(Refused::Network)); + assert_eq!(resolve_absolute("https://h/x.png", None), Err(Refused::Url)); + + let unix = Path::new("/home/u/docs/a.doc"); + let ok = |raw: &str, doc: Option<&Path>| slashed(&resolve_absolute(raw, doc).unwrap()); + assert_eq!(ok("img/x.png", Some(unix)), "/home/u/docs/img/x.png"); + assert_eq!(ok("../x.png", Some(unix)), "/home/u/x.png"); + assert_eq!(ok("file:///home/u/x.png", None), "/home/u/x.png"); + + let win = Path::new(r"C:\docs\a.doc"); + if cfg!(windows) { + assert_eq!(ok("img/x.png", Some(win)), "C:/docs/img/x.png"); + assert_eq!(ok(r"C:\x.png", None), "C:/x.png"); + assert_eq!(ok("file:///C:/x.png", None), "C:/x.png"); + assert_eq!(ok(r"\\?\C:\x.png", None), "//?/C:/x.png"); + assert_eq!(resolve_absolute(r"\x.png", None), Err(Refused::Invalid)); + assert_eq!(resolve_absolute("C:x.png", None), Err(Refused::Invalid)); + assert_eq!(resolve_absolute("D:x.png", Some(win)), Err(Refused::Invalid)); + } else { + assert_eq!(ok("/x.png", None), "/x.png"); + assert_eq!(resolve_absolute("img/x.png", Some(win)), Err(Refused::Invalid)); + assert_eq!(resolve_absolute(r"C:\x.png", None), Err(Refused::Invalid)); + } +} + #[test] fn refusals_have_messages() { for r in [Refused::Network, Refused::Device, Refused::Url, Refused::Invalid] { From c04cc16109f6d5a4f5833bf2911f8d71e7628052 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 17 Oct 2026 04:14:51 +0000 Subject: [PATCH 5/7] fix(docpath): colons in names are refused only on Windows --- crates/docpath/src/lib.rs | 120 +++++++++++++++++++++++++++----- crates/docpath/tests/docpath.rs | 90 +++++++++++++++++------- 2 files changed, 170 insertions(+), 40 deletions(-) diff --git a/crates/docpath/src/lib.rs b/crates/docpath/src/lib.rs index 006563bc0..1c5c41a71 100644 --- a/crates/docpath/src/lib.rs +++ b/crates/docpath/src/lib.rs @@ -8,12 +8,9 @@ //! from. Paths to other computers, device paths and non-file URLs are refused, so opening a document //! never makes the app contact a server or device the user did not choose. //! -//! 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. 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. +//! The check is lexical: it does no I/O and, apart from colons, 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. //! //! - [`classify`] sorts a stored path into a [`PathKind`]. //! - [`is_local`] is `classify(raw) == PathKind::Local`. @@ -22,6 +19,38 @@ //! - [`resolve`] is the same without the absolute check, for callers that only display or compare //! the result. //! +//! # Colons +//! +//! A colon after a drive letter at the start (`C:\`, `C:/`, `C:x`, `\\?\C:\`, `file:///C:/`) names +//! a drive on every platform. Other colons depend on the platform the check runs on: +//! +//! | Stored path | Windows | macOS and Linux | +//! |---|---|---| +//! | URL-shaped (see below): `https://h/x`, `mailto:a@b`, `jar:…`, `view-source:…` | [`PathKind::Url`] | [`PathKind::Url`] | +//! | Starts with any other scheme and a colon: `chapter1:intro.png`, `notes:v2.pdf` | [`PathKind::Url`] | allowed | +//! | A colon inside any other name: `2024:05/a.jpg`, `/Users/a/2024:05/x.jpg` | [`PathKind::Invalid`] | allowed | +//! +//! Windows reads a colon inside a name as an alternate data stream (`a.png:x` is the stream `x` of +//! `a.png`) and hands `name:…` to URL protocol handlers, so it refuses both. On macOS and Linux a +//! colon is an ordinary character: Finder shows a `/` in a file or folder name as `:` in the POSIX +//! path, so a Lightroom folder named `2024/05` is stored as `2024:05`. +//! +//! A stored path is URL-shaped when it starts with an RFC 3986 scheme of two or more characters and +//! a colon, and either +//! +//! - its first component (the text before the first `/` or `\`) ends with a colon, as in +//! `https://h`, `http:\\h`, `shell:` or the nested `view-source:http://h` and +//! `jar:file:///x.jar!/a`, or +//! - the scheme, or one of its `+`-separated parts (`svn+ssh`), compared ignoring case, starts with +//! `ms-` (the prefix of Windows app protocols) or is one of `about`, `blob`, `data`, `file`, +//! `ftp`, `ftps`, `http`, `https`, `jar`, `javascript`, `mailto`, `news`, `nntp`, `search-ms`, +//! `sftp`, `shell`, `smb`, `sms`, `ssh`, `tel`, `vbscript`, `view-source`, `webdav`, `ws` or `wss`. +//! +//! Names of files and folders rarely end their first component with a colon or start with one of +//! these scheme names, so names such as `Shoot: Day 1`, `2024:05` and `notes:v2.pdf` stay usable, +//! while links that browsers or the desktop would open as URLs are refused. A folder whose name +//! starts with such a word and a slash (`Data/2024`, stored as `Data:2024`) is refused too. +//! //! # Caller contract //! //! - Pass the document whenever there is one. Without it, a relative, root-relative or @@ -58,12 +87,13 @@ 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:`: anything that starts with a URL scheme of two or more characters - /// and a colon, such as `https://…`, `mailto:`, `jar:…` or `chapter1:intro.png`. + /// A URL other than `file:`, such as `https://…`, `mailto:`, `jar:…` or `view-source:…`. On + /// Windows, also anything else that starts with a URL scheme of two or more characters and a + /// colon, such as `chapter1:intro.png`. See [Colons](crate#colons). Url, - /// 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 `..`. + /// Not a usable path: empty, containing control characters or lookalike separators, a name made + /// of dots, or climbing above its root with `..`. On Windows, also a colon inside a name + /// (`img/fig:1.png`, `C:\x:stream`); see [Colons](crate#colons). Invalid, } @@ -242,12 +272,64 @@ fn strip_url(raw: &str) -> Result { }; if has_scheme(&path) { // `NUL:` and `COM1:` name devices, not URL schemes. - let device = path.split_once(':').is_some_and(|(scheme, _)| is_dos_device(scheme)); - return Err(if device { Refused::Device } else { Refused::Url }); + if path.split_once(':').is_some_and(|(scheme, _)| is_dos_device(scheme)) { + return Err(Refused::Device); + } + if COLON_IN_NAME_REFUSED || is_url_shaped(&path) { + return Err(Refused::Url); + } } Ok(path) } +/// Windows reads a colon inside a name as an alternate data stream and `name:…` as a URL for a +/// protocol handler; macOS and Linux allow colons in names (see [Colons](crate#colons)). +const COLON_IN_NAME_REFUSED: bool = cfg!(windows); + +/// URL schemes refused on every platform even when the rest does not look like a URL, compared +/// ignoring ASCII case. Any scheme starting with `ms-` is refused too. The module docs list them. +const URL_SCHEMES: &[&str] = &[ + "about", + "blob", + "data", + "file", + "ftp", + "ftps", + "http", + "https", + "jar", + "javascript", + "mailto", + "news", + "nntp", + "search-ms", + "sftp", + "shell", + "smb", + "sms", + "ssh", + "tel", + "vbscript", + "view-source", + "webdav", + "ws", + "wss", +]; + +/// A stored path that starts with a scheme ([`has_scheme`]) and reads as a URL on every platform: +/// its first component ends with a colon (`https://h`, `http:\\h`, `shell:`, +/// `view-source:http://h`), or the scheme, or one of its `+`-separated parts, is in +/// [`URL_SCHEMES`] or starts with `ms-`. +fn is_url_shaped(s: &str) -> bool { + let Some((scheme, _)) = s.split_once(':') else { return false }; + let first = s.split(is_sep).next().unwrap_or_default(); + first.ends_with(':') + || scheme.split('+').any(|part| { + let part = part.to_ascii_lowercase(); + part.starts_with("ms-") || URL_SCHEMES.contains(&part.as_str()) + }) +} + /// 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 { @@ -348,7 +430,8 @@ 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, colons); the document's own path is not checked. +/// (reserved device names, dot-and-space names, and colons on Windows); the document's own path is +/// not checked. fn parse(s: &str, check: bool) -> Result { let mut chars = s.chars(); let first = chars.next(); @@ -435,7 +518,7 @@ fn comps(s: &str, check: bool) -> Result, Refused> { if is_dos_device(c) { return Err(Refused::Device); } - if is_dot_name(c) || c.contains(':') { + if is_dot_name(c) || has_refused_colon(c) { return Err(Refused::Invalid); } } @@ -458,7 +541,7 @@ 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) || c.contains(':')) { + if check && (is_dot_name(c) || has_refused_colon(c)) { return Err(Refused::Invalid); } out.push(c.to_string()); @@ -466,6 +549,11 @@ fn verbatim_comps(s: &str, check: bool) -> Result, Refused> { Ok(out) } +/// A colon inside a name, which only Windows refuses (see [Colons](crate#colons)). +fn has_refused_colon(comp: &str) -> bool { + COLON_IN_NAME_REFUSED && comp.contains(':') +} + /// 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 { diff --git a/crates/docpath/tests/docpath.rs b/crates/docpath/tests/docpath.rs index 03cc6281b..db9cc1f58 100644 --- a/crates/docpath/tests/docpath.rs +++ b/crates/docpath/tests/docpath.rs @@ -157,16 +157,65 @@ fn url_links() { "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", + "view-source:x", + "JAR:x.jar!/a.png", "file:jar:x", - "a1+b-c.d:x", + "mailto:a", + "Data:2024/a.png", + "ms-settings:x", + "MS-Word:x", + "svn+ssh:h/x", + "git+https:h/x", + "x-app:https://h/x", + "x-app:ofe|u|https://h/x", + "http:/h/x", + "shell:", + "ab:", + "ab:/x", ], ); } +/// Starts with a scheme and a colon but does not look like a URL. +const SCHEME_LIKE_NAMES: &[&str] = + &["name:stream", "chapter1:intro.png", "x.png:stream", "ab:c", "a1+b-c.d:x", "Shoot: Day 1/a.jpg", "notes:v2.pdf", "Photos:2024/a.jpg"]; + +/// A colon inside a name other than a leading drive. +const COLON_NAMES: &[&str] = &[ + "..:", + ".: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", + "2024:05/a.jpg", + "Photos/2024:05/a.jpg", + "/Users/a/2024:05/x.jpg", + "file:///Users/a/2024:05/x.jpg", +]; + +#[test] +fn colons_in_names_are_refused_only_on_windows() { + if cfg!(windows) { + check(PathKind::Url, SCHEME_LIKE_NAMES); + check(PathKind::Invalid, COLON_NAMES); + } else { + check(PathKind::Local, SCHEME_LIKE_NAMES); + check(PathKind::Local, COLON_NAMES); + } +} + #[test] fn invalid_paths() { check( @@ -202,23 +251,6 @@ fn invalid_paths() { "a\u{2215}b", "a\u{29F5}b", "file:///C:/a%EF%BC%BCb", - "..:", - ".: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", ], ); } @@ -254,8 +286,14 @@ 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!(resolve("chapter1:intro.png", Some(doc)), Err(Refused::Url)); - assert_eq!(resolve("img/fig:1.png", Some(doc)), Err(Refused::Invalid)); + assert_eq!(resolve("jar:x.jar!/a.png", Some(doc)), Err(Refused::Url)); + if cfg!(windows) { + assert_eq!(resolve("chapter1:intro.png", Some(doc)), Err(Refused::Url)); + assert_eq!(resolve("img/fig:1.png", Some(doc)), Err(Refused::Invalid)); + } else { + assert_eq!(ok("chapter1:intro.png"), "C:/docs/report/chapter1:intro.png"); + assert_eq!(ok("img/fig:1.png"), "C:/docs/report/img/fig:1.png"); + } // 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"); @@ -347,6 +385,10 @@ fn resolve_absolute_refuses_results_that_are_not_absolute() { assert_eq!(resolve_absolute("D:x.png", Some(win)), Err(Refused::Invalid)); } else { assert_eq!(ok("/x.png", None), "/x.png"); + let catalog = Some(Path::new("/Users/a/Pictures/Lightroom/cat.lrcat")); + assert_eq!(ok("../2024:05/a.jpg", catalog), "/Users/a/Pictures/2024:05/a.jpg"); + assert_eq!(ok("/Users/a/2024:05/x.jpg", None), "/Users/a/2024:05/x.jpg"); + assert_eq!(resolve_absolute("view-source:x", catalog), Err(Refused::Url)); assert_eq!(resolve_absolute("img/x.png", Some(win)), Err(Refused::Invalid)); assert_eq!(resolve_absolute(r"C:\x.png", None), Err(Refused::Invalid)); } From 5542a9466dbb30415cfce0c208f7aeb94844de24 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 10:20:41 -0600 Subject: [PATCH 6/7] ci(docpath): run clippy and tests for craft-docpath on all three platforms The per-crate workflows name their crates with -p, so the new crate had no job and its tests, including the Windows-only colon and stream assertions, never ran in CI. --- .github/workflows/docpath.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/docpath.yml diff --git a/.github/workflows/docpath.yml b/.github/workflows/docpath.yml new file mode 100644 index 000000000..557f00f52 --- /dev/null +++ b/.github/workflows/docpath.yml @@ -0,0 +1,29 @@ +name: Document paths + +on: + workflow_dispatch: + push: + branches: [main] + paths: ['crates/docpath/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/docpath.yml'] + pull_request: + paths: ['crates/docpath/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/docpath.yml'] + +permissions: + contents: read + +jobs: + docpath: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - run: cargo fmt --check + - run: cargo clippy -p craft-docpath --all-targets --locked -- -D warnings + - run: cargo test -p craft-docpath --locked -- --nocapture From a92010200c1ab51a25154201d4128562823f34de Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 11:10:18 -0600 Subject: [PATCH 7/7] test(docpath): assert Unix-shaped results only on Unix resolve_absolute_refuses_results_that_are_not_absolute resolved links against a document at /home/u/docs/a.doc outside its cfg!(windows) branch. On Windows that path is rooted but has no drive, so it is not absolute, resolve_absolute refuses the result as documented, and the unwrap panicked. The Windows arm already covers the same three cases with a drive. Found by the new docpath CI job on its first run. --- crates/docpath/tests/docpath.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/docpath/tests/docpath.rs b/crates/docpath/tests/docpath.rs index db9cc1f58..40f2098bc 100644 --- a/crates/docpath/tests/docpath.rs +++ b/crates/docpath/tests/docpath.rs @@ -370,10 +370,6 @@ fn resolve_absolute_refuses_results_that_are_not_absolute() { let unix = Path::new("/home/u/docs/a.doc"); let ok = |raw: &str, doc: Option<&Path>| slashed(&resolve_absolute(raw, doc).unwrap()); - assert_eq!(ok("img/x.png", Some(unix)), "/home/u/docs/img/x.png"); - assert_eq!(ok("../x.png", Some(unix)), "/home/u/x.png"); - assert_eq!(ok("file:///home/u/x.png", None), "/home/u/x.png"); - let win = Path::new(r"C:\docs\a.doc"); if cfg!(windows) { assert_eq!(ok("img/x.png", Some(win)), "C:/docs/img/x.png"); @@ -384,6 +380,11 @@ fn resolve_absolute_refuses_results_that_are_not_absolute() { assert_eq!(resolve_absolute("C:x.png", None), Err(Refused::Invalid)); assert_eq!(resolve_absolute("D:x.png", Some(win)), Err(Refused::Invalid)); } else { + // A Unix document path is rooted but not absolute on Windows, where `resolve_absolute` + // rightly refuses the result; the Windows arm above covers the same cases with a drive. + assert_eq!(ok("img/x.png", Some(unix)), "/home/u/docs/img/x.png"); + assert_eq!(ok("../x.png", Some(unix)), "/home/u/x.png"); + assert_eq!(ok("file:///home/u/x.png", None), "/home/u/x.png"); assert_eq!(ok("/x.png", None), "/x.png"); let catalog = Some(Path::new("/Users/a/Pictures/Lightroom/cat.lrcat")); assert_eq!(ok("../2024:05/a.jpg", catalog), "/Users/a/Pictures/2024:05/a.jpg");