Merge pull request #169 from skylartaylor/docpath-crate

Add craft-docpath
This commit is contained in:
skylar taylor-barrick
2026-10-17 12:00:19 -06:00
committed by GitHub
7 changed files with 1074 additions and 0 deletions
+29
View File
@@ -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
Generated
+4
View File
@@ -605,6 +605,10 @@ dependencies = [
"rayon",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
[[package]]
name = "craft-pdf"
version = "0.1.0"
+1
View File
@@ -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" }
+3
View File
@@ -30,6 +30,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
- [`craft-cms`](crates/cms/README.md): pure-Rust ICC v2/v4 colour management: profile parsing
and writing, transforms, rendering intents, black point compensation, soft proofing, gamut
checks, 3D LUTs and built-in profiles (sRGB, Display P3, synthetic coated CMYK and others).
- [`craft-docpath`](crates/docpath/src/lib.rs): decides whether a path or `file:` URL stored in a
document may be opened. Paths stored in documents are followed only when they are local or on the
document's own network share; other network paths, device paths and non-file URLs are refused.
- [`craft-print`](crates/print/README.md): PDF submission to CUPS and Windows, printer discovery,
duplex/copies/colour and driver preferences. PDF generation stays in each app.
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "craft-docpath"
description = "Decide whether a file path or file URL stored in a document refers to a local file."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
+623
View File
@@ -0,0 +1,623 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
//! Decide whether a file path or file URL stored in a document may be opened, read or written.
//!
//! Paths stored in documents (linked images, templates, data sources, media) are only followed when
//! they refer to a local file, or to a file on the same network share the document itself was opened
//! 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, 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`.
//! - [`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.
//!
//! # 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
//! 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.
//! - 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, 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;
use std::path::{MAIN_SEPARATOR, Path, PathBuf};
/// What a stored path refers to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PathKind {
/// A file on this computer: absolute, drive-relative, root-relative or relative.
Local,
/// A file on another computer: `\\host\share`, `//host/share`, `\\?\UNC\…`, `file://host/…`.
Network,
/// A device or object-manager path: `\\.\…`, `\??\…`, `\\?\GLOBALROOT\…`, or a reserved name such as `NUL` or `COM1`.
Device,
/// 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 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,
}
/// Why [`resolve`] refused a stored path.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Refused {
/// The path is on another computer, or on a network share other than the document's.
Network,
/// The path names a device rather than a file.
Device,
/// The link is a URL, not a file path.
Url,
/// The path is not usable.
Invalid,
}
impl fmt::Display for Refused {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> 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<Refused> 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<PathBuf, Refused> {
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<PathBuf, Refused> {
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<char> {
match self {
Self::Drive(l) | Self::VerbatimDrive(l) => Some(*l),
_ => None,
}
}
}
struct Parsed {
root: Root,
comps: Vec<String>,
}
fn is_sep(c: char) -> bool {
c == '/' || c == '\\'
}
/// The document's folder: its components without the file name.
fn folder(mut comps: Vec<String>) -> impl Iterator<Item = String> {
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<String, Refused> {
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<String, Refused> {
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<u8> = Vec::new();
let mut rest = s;
loop {
let bytes = rest.as_bytes();
if let (Some(b'%'), Some(h), Some(l)) = (bytes.first(), hex(bytes.get(1)), hex(bytes.get(2))) {
run.push((h << 4) | l);
rest = rest.get(3..).unwrap_or_default();
continue;
}
match std::str::from_utf8(&run) {
Ok(text) => out.push_str(text),
Err(_) => out.extend(run.iter().map(|&b| windows_1252(b))),
}
run.clear();
let mut chars = rest.chars();
let Some(c) = chars.next() else { break };
out.push(c);
rest = chars.as_str();
}
out
}
/// Bytes 0x81, 0x8D, 0x8F, 0x90 and 0x9D are unassigned and map to C1 controls, which are refused.
fn windows_1252(b: u8) -> char {
const HIGH: [char; 32] = [
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
'˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ',
];
match b {
0x80..=0x9F => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or(char::REPLACEMENT_CHARACTER),
_ => char::from(b),
}
}
/// 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<Parsed, Refused> {
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<Parsed, Refused> {
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<Parsed, Refused> {
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<Parsed, Refused> {
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<Vec<String>, 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<Vec<String>, 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<String>) -> Result<Vec<String>, Refused> {
let mut out: Vec<String> = 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<PathBuf, Refused> {
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)
}
+403
View File
@@ -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());
}
}