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()); + } +}