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 diff --git a/Cargo.lock b/Cargo.lock index 11d1d5eb2..676a52204 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -605,6 +605,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 52fe9cac0..336fd37b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,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 86c6fa206..4b428ee82 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,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 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: +/// +/// 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. +/// 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(is_bad_char) { + 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. + 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 { + c.is_control() || matches!(c, '\u{FF3C}' | '\u{FF0F}' | '\u{2215}' | '\u{29F5}') +} + +/// 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 { + 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(is_bad_char) { + 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, ""), + }; + // `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(), + }; + // `/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()) +} + +/// 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), + } +} + +/// Split a plain path into its root and components. `check` applies the component rules +/// (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(); + 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); + } + if is_dot_name(c) || has_refused_colon(c) { + 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); + } + if check && (is_dot_name(c) || has_refused_colon(c)) { + return Err(Refused::Invalid); + } + out.push(c.to_string()); + } + 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 { + !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. +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..40f2098bc --- /dev/null +++ b/crates/docpath/tests/docpath.rs @@ -0,0 +1,403 @@ +use std::path::Path; + +use craft_docpath::{PathKind, Refused, classify, is_local, resolve, resolve_absolute}; + +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", + "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", + "file://C:/x", + "file://c|/x", + r"file:\\C:\x", + "file:///C:/caf%E9.png", + "file:///C:/%FF", + r"C:\\host\share", + r"./\\host\share\x", + "D:/x", + "z:", + "file:///D:", + r"\\?\Z:\x", + ], + ); +} + +#[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", + "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", + "jar:file:///C:/x.jar!/a.png", + "view-source:http://h/x", + "view-source:x", + "JAR:x.jar!/a.png", + "file:jar: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( + PathKind::Invalid, + &[ + "", + "a\0b", + "a\nb", + "a\tb", + "a\u{7f}", + "a\u{85}b", + "file:", + "file:///C:/a%00b", + "file:///C:/a%0Ab", + 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", + "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", + ], + ); +} + +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!(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!(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"); + + 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!(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)); + 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 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()); + 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 { + // 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"); + 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)); + } +} + +#[test] +fn refusals_have_messages() { + for r in [Refused::Network, Refused::Device, Refused::Url, Refused::Invalid] { + assert!(!r.to_string().is_empty()); + } +}