Merge pull request #2153 from ljkx/fix/automation-network-paths-1094

M8: automation roots refuse a path on another computer before resolving it (#1094)
This commit is contained in:
BFlat authored and GitHub committed 2026-11-12 09:28:06 +07:00
commit a1c2836bdb
2 files changed
+463 -22

No files matched your search

+186 -22
View File
@@ -12,6 +12,11 @@
//! existing folder is resolved and the rest must be plain names. The comparison is by whole
//! components (`/work2` is not inside `/work`), and the I/O uses the resolved path, so what was
//! checked is what is opened.
//!
//! On Windows, resolving a path opens it, and opening a path on another computer contacts that
//! computer and may send it the signed-in user's credentials. So on Windows, before anything is
//! resolved, a path on another computer whose share no root is on, a device path, or one reached
//! through a link that leads to them is refused by how it is spelled (#1094).
#![allow(clippy::disallowed_methods)]
use std::cell::RefCell;
@@ -19,6 +24,8 @@ use std::ffi::{OsStr, OsString};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use craft_docpath::Refused;
use crate::{EngineError, Result};
/// What a command does with a file.
@@ -58,6 +65,19 @@ impl Roots {
Access::Read => ([self.read.as_slice(), self.write.as_slice()].concat(), "read"),
Access::Write => (self.write.clone(), "written"),
};
if cfg!(windows) {
match refused_unresolved(path, &roots) {
Some(Unresolved::Spelled(Refused::Network)) => {
return Err(format!(
"automation path rejected: {asked} is on another computer, and no folder that may be {what} is on its share"
));
}
Some(Unresolved::Spelled(_)) => return Err(format!("automation path rejected: {asked} names a device, not a file")),
// Where a link leads is only told by the usual message: it would say what is there.
Some(Unresolved::Linked) => return Err(outside(asked, &roots, what)),
None => {}
}
}
match resolve(path) {
Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved),
Ok(_) => {}
@@ -65,11 +85,7 @@ impl Roots {
Err(why) if inside_as_far_as_it_exists(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
Err(_) => {}
}
if roots.is_empty() {
return Err(format!("automation path rejected: no folder may be {what} (no root for it was given): {asked}"));
}
let shown: Vec<String> = roots.iter().map(|r| shown(r)).collect();
Err(format!("automation path rejected: {asked} is outside the folders that may be {what}: {}", shown.join(", ")))
Err(outside(asked, &roots, what))
}
/// The folders as JSON, resolved: `{read: [every folder that may be read], write: [the write roots]}`.
@@ -79,23 +95,165 @@ impl Roots {
}
}
/// Why `asked` is refused: it is outside every one of `roots`, the folders that may be `what`
/// ("read" or "written"). The message lists those folders, or says that no folder may be `what`
/// when `roots` is empty.
fn outside(asked: std::path::Display<'_>, roots: &[PathBuf], what: &str) -> String {
if roots.is_empty() {
return format!("automation path rejected: no folder may be {what} (no root for it was given): {asked}");
}
let shown: Vec<String> = roots.iter().map(|r| shown(r)).collect();
format!("automation path rejected: {asked} is outside the folders that may be {what}: {}", shown.join(", "))
}
/// Why a path is refused before it is resolved (#1094).
#[derive(Debug, PartialEq, Eq)]
enum Unresolved {
/// Its spelling leads to another computer ([`Refused::Network`]) or a device ([`Refused::Device`]).
Spelled(Refused),
/// A link on the way leads there, or the way there can't be looked at: a link or folder on it
/// can't be read, it has more than [`MAX_LINKS`] links, or the path can't be made absolute.
Linked,
}
/// How many links are followed on the way to a path before it is given up on, as on Linux.
const MAX_LINKS: u8 = 40;
/// Why `path` is refused before anything on the way to it is opened, if it is: resolving it would
/// open a path on another computer, which on Windows contacts that computer and may send it the
/// signed-in user's credentials, or open a device (#1094). That is the case when the path as
/// given or made absolute is refused by its spelling ([`refused_spelling`]), or when a link on
/// the way, or on the way on from where one leads, leads to such a path ([`link_refused`]). A path
/// on the share of one of `roots` goes on to the usual check. A path that can't be made absolute
/// is refused as well, rather than left to what comes next.
fn refused_unresolved(path: &Path, roots: &[PathBuf]) -> Option<Unresolved> {
// Made absolute without looking at the disk (on Windows `GetFullPathNameW`, which also turns a
// bare device name such as `NUL` into `\\.\NUL`, as opening it would). Like opening, it takes
// `..` by name, so `link\..\x` is `x` and never goes through the link.
let Ok(abs) = std::path::absolute(path) else { return Some(Unresolved::Linked) };
if let Some(why) = [path, abs.as_path()].into_iter().find_map(|p| refused_spelling(&p.to_string_lossy(), roots)) {
return Some(Unresolved::Spelled(why));
}
link_refused(&abs, roots, MAX_LINKS, &on_disk).then_some(Unresolved::Linked)
}
/// What is at a path, looked at without following a link there.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Entry {
/// Nothing ([`nothing_there`]): resolving the path meets the same there and goes no further.
Missing,
/// Something that can't be looked at (no permission, for one). It may be a link that resolving
/// the path would still follow, so the path is refused.
Unreadable,
/// A file or a folder.
Plain,
/// A link, with where it leads (`None` when that can't be read).
Link(Option<PathBuf>),
}
/// What is at `p` on disk (see [`Entry`]), looked at with [`std::fs::symlink_metadata`], which
/// doesn't follow a link there; a link's target is read with [`std::fs::read_link`].
fn on_disk(p: &Path) -> Entry {
match std::fs::symlink_metadata(p) {
Ok(m) if m.is_symlink() => Entry::Link(std::fs::read_link(p).ok()),
Ok(_) => Entry::Plain,
Err(e) if nothing_there(&e) => Entry::Missing,
Err(_) => Entry::Unreadable,
}
}
/// Whether looking at a path failed with `e` because nothing is there: no such name, a file where
/// a folder was expected, or a name no file can have. Any other failure says nothing about what is
/// there.
fn nothing_there(e: &std::io::Error) -> bool {
use std::io::ErrorKind::{InvalidFilename, NotADirectory, NotFound};
matches!(e.kind(), NotFound | NotADirectory | InvalidFilename)
}
/// Why path `p` is refused by its spelling alone, if it is (see [`craft_docpath::resolve`], which
/// reads both `/` and `\` as separators and compares names ignoring case):
/// - on another computer (`\\server\share`, `//server/share`, `\\?\UNC\…`, `\\.\UNC\…`, or a
/// server without a share) and on no root's share;
/// - a device path (`\\.\pipe\…`, `\\?\GLOBALROOT\…`, `\\.\C:\…`), other than a local volume's
/// `\\?\Volume{…}\` or `\\.\Volume{…}\`, which a folder can be mounted as;
/// - or with a device name (`CON`, `NUL`, `COM1.png`, `lpt1 `…) as any of its names: each folder on
/// the way is opened in turn while the path is resolved, and the device behind a name such as
/// `LPT1` can be a printer on another computer.
fn refused_spelling(p: &str, roots: &[PathBuf]) -> Option<Refused> {
match craft_docpath::resolve(p, None) {
// With a root passed as the document path (what a link would be relative to), a path on
// that root's share is allowed.
Err(Refused::Network) if !roots.iter().any(|r| craft_docpath::resolve(p, Some(r)) != Err(Refused::Network)) => {
return Some(Refused::Network);
}
Err(Refused::Device) if !is_volume(p) => return Some(Refused::Device),
_ => {}
}
// `craft_docpath` stops at the first name it refuses, which may not be the device's.
p.split(['\\', '/']).any(device_name).then_some(Refused::Device)
}
/// Whether `p` is in a local volume by its `\\?\Volume{…}` (or `\\.\Volume{…}`) name.
fn is_volume(p: &str) -> bool {
let mut names = p.split(['\\', '/']);
let (Some(""), Some(""), Some("?" | "."), Some(volume)) = (names.next(), names.next(), names.next(), names.next()) else {
return false;
};
volume.get(..7).is_some_and(|v| v.eq_ignore_ascii_case("Volume{")) && volume.ends_with('}')
}
/// Whether a link on the way to `abs` (an absolute path) leads to a path [`refused_spelling`]
/// refuses, following at most `links` links: one that leads to another, or on through further
/// links, is followed too, as resolving the path would. A link that can't be read, a name on the
/// way that can't be looked at ([`Entry::Unreadable`]), or more links than that, are refused as
/// well. Each name on the way is looked at with `look` ([`on_disk`]), which doesn't follow a
/// link: where a link leads is looked at only once its spelling is known not to be refused.
fn link_refused(abs: &Path, roots: &[PathBuf], links: u8, look: &dyn Fn(&Path) -> Entry) -> bool {
let mut at = PathBuf::new();
let mut rest = abs.components();
while let Some(c) = rest.next() {
at.push(c);
if !matches!(c, Component::Normal(_)) {
continue;
}
let target = match look(&at) {
Entry::Plain => continue,
// Nothing is below what isn't there, so no link either.
Entry::Missing => return false,
Entry::Unreadable => return true,
Entry::Link(target) => target,
};
let (Some(links), Some(target)) = (links.checked_sub(1), target) else { return true };
// A relative target is relative to the link's folder; an absolute one replaces it.
let next = lexical(&at.parent().unwrap_or(&at).join(target).join(rest.as_path()));
return refused_spelling(&next.to_string_lossy(), roots).is_some() || link_refused(&next, roots, links, look);
}
false
}
/// `p` with its `.` and `..` taken by name, as Windows takes them.
fn lexical(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
c => out.push(c),
}
}
out
}
/// Whether `path` lies in one of `roots` once made absolute with its `.` and `..` taken by name and
/// its longest leading part that exists resolved, so links on the way are followed as the roots'
/// were (`/tmp/work/x` is inside root `/private/tmp/work`, a link out of a root leads out of it).
fn inside_as_far_as_it_exists(path: &Path, roots: &[PathBuf]) -> bool {
let Ok(abs) = std::path::absolute(path) else { return false };
let mut lexical = PathBuf::new();
for c in abs.components() {
match c {
Component::ParentDir => {
lexical.pop();
}
Component::CurDir => {}
c => lexical.push(c),
}
}
let folded = lexical(&abs);
let mut rest: Vec<&OsStr> = Vec::new();
let mut at = lexical.as_path();
let mut at = folded.as_path();
let resolved = loop {
if let Ok(mut base) = std::fs::canonicalize(at) {
base.extend(rest.iter().rev());
@@ -169,18 +327,24 @@ fn resolve(path: &Path) -> std::result::Result<PathBuf, String> {
fn plain_name(name: &OsStr) -> std::result::Result<(), String> {
if cfg!(windows) {
let n = name.to_string_lossy();
let stem = n.split('.').next().unwrap_or_default().trim_end().to_ascii_uppercase();
let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$")
|| ((stem.starts_with("COM") || stem.starts_with("LPT"))
&& stem.chars().count() == 4
&& stem.chars().last().is_some_and(|c| c.is_ascii_digit() || matches!(c, '¹' | '²' | '³')));
if device || n.contains(':') {
if device_name(&n) || n.contains(':') {
return Err(format!("`{n}` is a device or stream name, not a file"));
}
}
Ok(())
}
/// Whether Windows opens a device for file or folder name `n`: `CON`, `PRN`, `AUX`, `NUL`,
/// `CONIN$`, `CONOUT$`, `COM1`…`COM9` and `LPT1`…`LPT9` (also with `¹`, `²` or `³`), in any case,
/// and with anything after a dot or a colon or trailing spaces (`nul.txt`, `COM1 .png`, `con:x`).
fn device_name(n: &str) -> bool {
let stem = n.split(['.', ':']).next().unwrap_or_default().trim_end().to_ascii_uppercase();
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$")
|| ((stem.starts_with("COM") || stem.starts_with("LPT"))
&& stem.chars().count() == 4
&& stem.chars().last().is_some_and(|c| c.is_ascii_digit() || matches!(c, '¹' | '²' | '³')))
}
thread_local! {
static ROOTS: RefCell<Option<Arc<Roots>>> = const { RefCell::new(None) };
}
@@ -191,6 +191,283 @@ fn windows_case_devices_and_streams() {
assert!(!e.contains(r"\\?\"), "{e}");
}
/// How each spelling of a path on another computer or of a device is told apart from a local path
/// (#1094), on every system: only Windows checks them, but they are spelled the same everywhere.
#[test]
fn spellings_of_other_computers_and_devices_are_refused() {
use super::refused_spelling;
use craft_docpath::Refused::{Device, Network};
let local = [PathBuf::from(r"C:\work")];
// On `\\192.0.2.1\share`.
let on_the_share = [
r"\\192.0.2.1\share\x.png",
"//192.0.2.1/share/x.png",
r"\/192.0.2.1/share\x.png",
r"\\?\UNC\192.0.2.1\share\x.png",
r"\\?\unc\192.0.2.1\share\x.png",
"//?/UNC/192.0.2.1/share/x.png",
r"\\.\UNC\192.0.2.1\share\x.png",
];
let elsewhere = [
// A server without a share is contacted too.
r"\\192.0.2.1",
r"\\192.0.2.1\",
r"\\SERVER.\share \x.png",
];
let network = [&on_the_share[..], &elsewhere[..]].concat();
let device = [
r"\\.\pipe\figcraft",
r"\\?\GLOBALROOT\Device\Mup\192.0.2.1\share\x.png",
r"\\.\C:\work\x.png",
r"\\.\COM1",
r"\??\UNC\192.0.2.1\share\x.png",
// A device name anywhere: each folder on the way is opened as the path is resolved.
r"C:\work\COM1",
r"C:\work\nul.txt",
r"C:\work\Com1 .png",
r"C:\work\lpt1.\x.png",
r"C:\work\LPT³\x.png",
r"C:\work\conout$",
r"C:\work\aux:stream",
// After a name `craft_docpath` refuses for another reason.
r"C:\work\a:b\prn.png",
];
let fine = [
r"C:\work\x.png",
r"\\?\C:\work\x.png",
r"C:\work\console.png",
r"C:\work\com10.png",
r"C:\work\lpt.png",
r"C:\work\nul-ish.txt",
r"\\?\Volume{0b3b1c63-0000-0000-0000-100000000000}\x.png",
r"\\.\Volume{0b3b1c63-0000-0000-0000-100000000000}\x.png",
"/home/u/work/x.png",
"x.png",
r"..\x.png",
];
for (paths, want) in [(&network[..], Some(Network)), (&device[..], Some(Device)), (&fine[..], None)] {
for p in paths {
assert_eq!(refused_spelling(p, &[]), want, "{p}");
assert_eq!(refused_spelling(p, &local), want, "{p} with a local root");
}
}
// A root on a share lets the paths on that share through, in any case and spelling, and only them:
// every spelling above of a path on `\\192.0.2.1\share`, but not the server without a share
// or another server.
for root in [r"\\?\UNC\192.0.2.1\share", r"\\192.0.2.1\share\work"] {
let on_share = [PathBuf::from(root)];
for p in on_the_share {
assert_eq!(refused_spelling(p, &on_share), None, "{p} with root {root}");
}
for p in elsewhere {
assert_eq!(refused_spelling(p, &on_share), Some(Network), "{p} with root {root}");
}
}
let shared = [PathBuf::from(r"\\?\UNC\Server\Share\work")];
for p in [r"\\server\SHARE\work\x.png", r"\\?\UNC\SERVER\share\elsewhere\x.png", "//Server/Share/work/x.png"] {
assert_eq!(refused_spelling(p, &shared), None, "{p}");
}
for p in [r"\\server\other\x.png", r"\\other\share\x.png", r"\\server", r"\\server.\share\x.png"] {
assert_eq!(refused_spelling(p, &shared), Some(Network), "{p}");
}
assert_eq!(refused_spelling(r"\\server\share\work\COM1", &shared), Some(Device));
}
/// The links on the way to a path are followed by their spelling before anything they lead to is
/// opened (#1094). The links are made up here, so this runs where tests may not make links.
#[test]
fn links_on_the_way_are_followed_by_their_spelling_first() {
use super::{Entry, MAX_LINKS, link_refused};
use std::cell::RefCell;
use std::collections::HashMap;
let base = std::env::temp_dir().join("figcraft-links");
let at = |rel: &str| base.join(rel);
let mut links: HashMap<PathBuf, Option<PathBuf>> = HashMap::from([
// Relative to the link's folder, to a device name.
(at("root/dev"), Some(PathBuf::from("../outside/nul"))),
// To another link.
(at("root/hop"), Some(at("root/dev"))),
// Within the root.
(at("root/alias"), Some(PathBuf::from("sub"))),
(at("root/loop"), Some(at("root/loop"))),
(at("root/unreadable"), None),
// Below a name that isn't there: never looked at.
(at("missing/dev"), Some(PathBuf::from("nul"))),
]);
if cfg!(windows) {
links.insert(at("root/remote"), Some(PathBuf::from(r"\\192.0.2.1\share")));
links.insert(at("root/far"), Some(at("root/remote/work")));
}
// Chains of `n` links, `root/<n>-1` to `root/<n>-2` and so on, the last one to `root/sub`.
for n in [MAX_LINKS - 1, MAX_LINKS, MAX_LINKS + 1] {
for i in 1..=n {
let next = if i == n { at("root/sub") } else { at(&format!("root/{n}-{}", i + 1)) };
links.insert(at(&format!("root/{n}-{i}")), Some(next));
}
}
let plain = [at("root"), at("root/sub"), at("outside")];
// What can't be looked at (no permission, say) may be a link, so it isn't taken for nothing.
let unreadable = at("root/locked");
let looked = RefCell::new(Vec::new());
let look = |p: &Path| {
looked.borrow_mut().push(p.to_path_buf());
match links.get(p) {
Some(target) => Entry::Link(target.clone()),
None if base.starts_with(p) || plain.iter().any(|q| q == p) => Entry::Plain,
None if p == unreadable => Entry::Unreadable,
None => Entry::Missing,
}
};
let refused = |rel: &str, roots: &[PathBuf]| link_refused(&at(rel), roots, MAX_LINKS, &look);
for rel in ["root/dev/x.png", "root/hop/x.png", "root/loop/x.png", "root/unreadable/x.png", "root/locked/x.png", "root/locked"] {
assert!(refused(rel, &[]), "{rel}");
}
for rel in ["root/alias/x.png", "root/sub/x.png", "root/x.png", "missing/dev/x.png"] {
assert!(!refused(rel, &[]), "{rel}");
}
// As many links as are followed lead on; one more is refused.
assert!(!refused(&format!("root/{}-1/x.png", MAX_LINKS - 1), &[]));
assert!(!refused(&format!("root/{MAX_LINKS}-1/x.png"), &[]));
assert!(refused(&format!("root/{}-1/x.png", MAX_LINKS + 1), &[]));
// A refused target isn't looked at, nor is anything below what isn't there.
let looked = looked.take();
assert!(!looked.contains(&at("outside/nul")) && !looked.contains(&at("missing/dev")), "{looked:?}");
if cfg!(windows) {
for rel in ["root/remote/x.png", "root/far/x.png"] {
assert!(refused(rel, &[]), "{rel}");
// Unless a root is on that share.
assert!(!refused(rel, &[PathBuf::from(r"\\?\UNC\192.0.2.1\share")]), "{rel}");
}
}
}
/// A path that can't be looked at is refused rather than taken for one with nothing there (#1094):
/// only a failure that says nothing is there lets the walk stop, and a path that can't even be
/// made absolute isn't left to the checks after it.
#[test]
fn what_cannot_be_looked_at_is_refused() {
use super::{Unresolved, nothing_there, refused_unresolved};
use std::io::{Error, ErrorKind};
for kind in [ErrorKind::NotFound, ErrorKind::NotADirectory, ErrorKind::InvalidFilename] {
assert!(nothing_there(&Error::from(kind)), "{kind:?}");
}
for kind in [ErrorKind::PermissionDenied, ErrorKind::ResourceBusy, ErrorKind::Interrupted, ErrorKind::Other] {
assert!(!nothing_there(&Error::from(kind)), "{kind:?}");
}
assert_eq!(refused_unresolved(Path::new(""), &[]), Some(Unresolved::Linked));
}
/// On Windows a path is classified as Windows opens it (#1094): with `..` taken by name before
/// anything is opened (so a link followed by `..` is never gone through), and with names that
/// aren't Unicode (a lone surrogate) still read for the parts that are spelled in ASCII.
#[cfg(windows)]
#[test]
fn paths_are_classified_as_windows_opens_them() {
use super::{Unresolved, refused_unresolved};
use craft_docpath::Refused::{Device, Network};
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;
assert_eq!(std::path::absolute(r"C:\work\remote\..\x.png").unwrap(), PathBuf::from(r"C:\work\x.png"));
// `before`, a lone surrogate, then `after`.
let odd = |before: &str, after: &str| {
let wide: Vec<u16> = before.encode_utf16().chain([0xD800]).chain(after.encode_utf16()).collect();
PathBuf::from(OsString::from_wide(&wide))
};
assert_eq!(refused_unresolved(&odd(r"\\192.0.2.1\share\", ".png"), &[]), Some(Unresolved::Spelled(Network)));
assert_eq!(refused_unresolved(&odd(r"C:\figcraft-no-such-folder\", r"\nul"), &[]), Some(Unresolved::Spelled(Device)));
assert_eq!(refused_unresolved(&odd(r"C:\figcraft-no-such-folder\", ".png"), &[]), None);
// A NUL can't be passed to Windows at all, so the path can't be made absolute.
assert_eq!(refused_unresolved(Path::new("C:\\work\\a\0b.png"), &[]), Some(Unresolved::Linked));
}
/// `..` after a link is taken by name, by the check and by Windows when it opens the path, so
/// `root\j\..\in.txt` is `root\in.txt` and the link `j` is never gone through (#1094). Shown with
/// a junction, a link any account can make: `j` leads out of the root to `outside`, and beside
/// `outside` is another `in.txt`, which is what `..` would reach through the link.
#[cfg(windows)]
#[test]
fn a_parent_after_a_link_is_taken_by_name() {
use super::{Entry, on_disk};
let f = Fixture::new("parent");
let r = f.rw();
std::fs::write(f.0.join("in.txt"), b"beside").unwrap();
let made = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]).arg(f.0.join(r"root\j")).arg(f.0.join("outside")).output();
assert!(made.is_ok_and(|o| o.status.success()), "a junction can be made");
// The junction is a link to the check, and what is through it is outside.
assert!(matches!(on_disk(&f.0.join(r"root\j")), Entry::Link(Some(_))));
assert!(r.check(Path::new(&f.p(r"root\j\secret.txt")), Access::Read).is_err());
let through = f.p(r"root\j\..\in.txt");
assert_eq!(r.check(Path::new(&through), Access::Read), r.check(Path::new(&f.p(r"root\in.txt")), Access::Read));
assert!(r.check(Path::new(&through), Access::Read).is_ok());
assert_eq!(std::fs::read(&through).unwrap(), b"in", "Windows opens the file in the root");
}
/// A path on another computer and a device path are refused before anything is opened: resolving
/// them made Windows contact the server, which may send it the signed-in user's credentials
/// (#1094). Each is refused with a message that only the check before resolving gives: had the
/// path been resolved, 192.0.2.1 (an address for documentation, where nothing answers) would have
/// been contacted and the message would be the one for a path that can't be resolved.
#[cfg(windows)]
#[test]
fn other_computers_and_devices_are_refused_before_anything_is_opened() {
let f = Fixture::new("network");
let r = f.rw();
let read_only = Roots::new(&[f.p("root")], &[]).unwrap();
let refused = |roots: &Roots, p: &str, access: Access| roots.check(Path::new(p), access).expect_err(p);
for p in
[r"\\192.0.2.1\share\x.png", "//192.0.2.1/share/x.png", r"\\?\UNC\192.0.2.1\share\x.png", r"\\.\UNC\192.0.2.1\share\x.png", r"\\192.0.2.1"]
{
for access in [Access::Read, Access::Write] {
let e = refused(&r, p, access);
assert!(e.contains("is on another computer, and no folder that may be"), "{p}: {e}");
}
let e = refused(&read_only, p, Access::Write);
assert!(e.ends_with("is on another computer, and no folder that may be written is on its share"), "{p}: {e}");
}
for p in [r"\\.\pipe\figcraft-roots", r"\\?\GLOBALROOT\Device\Mup\192.0.2.1\share\x.png", &f.p(r"root\COM1\x.png"), "nul"] {
for access in [Access::Read, Access::Write] {
let e = refused(&r, p, access);
assert!(e.ends_with("names a device, not a file"), "{p}: {e}");
}
}
// Local paths, in either spelling, are checked as before.
let inside = std::fs::canonicalize(f.p("root")).unwrap().join("in.txt");
assert!(r.check(&inside, Access::Read).is_ok());
assert!(r.check(Path::new(&f.p(r"root\in.txt")), Access::Read).is_ok());
}
/// A link in a root to another computer is refused before it is followed (#1094), also through
/// another link, and with the message any path outside gets: it doesn't tell where the link
/// leads. Had the link been followed, the message would be the one for a path that can't be
/// resolved. This needs real links, which Windows lets only some accounts make: elsewhere it
/// says so and checks nothing (`links_on_the_way_are_followed_by_their_spelling_first` covers the
/// same walk with made-up links on every system).
#[cfg(windows)]
#[test]
fn links_to_other_computers_are_refused_before_they_are_followed() {
let f = Fixture::new("network-links");
let r = f.rw();
if !symlink_dir(Path::new(r"\\192.0.2.1\share"), &f.0.join("root/remote")) || !symlink_dir(&f.0.join("root/remote/work"), &f.0.join("root/hop")) {
eprintln!("skipped: this system doesn't let tests make symbolic links");
return;
}
// Relative to the link's folder.
assert!(symlink_dir(Path::new(r"..\remote"), &f.0.join("root/sub/up")));
assert!(symlink_file(Path::new(r"\\.\pipe\figcraft-roots"), &f.0.join("root/pipe")));
let standard = |p: &str| format!("automation path rejected: {p} is outside the folders that may be");
for p in [f.p("root/remote/x.png"), f.p("root/hop/x.png"), f.p("root/sub/up/x.png"), f.p("root/pipe")] {
for access in [Access::Read, Access::Write] {
let e = r.check(Path::new(&p), access).expect_err(&p);
assert!(e.starts_with(&standard(&p)), "{p}: {e}");
}
}
// `..` after the link is taken by name, so the link isn't gone through.
assert!(r.check(Path::new(&f.p(r"root\remote\..\in.txt")), Access::Read).is_ok());
// A link to a folder in the root still leads there.
assert!(symlink_dir(&f.0.join("root/sub"), &f.0.join("root/alias")));
assert!(r.check(Path::new(&f.p("root/alias/x.png")), Access::Write).is_ok());
}
#[test]
fn a_scope_confines_the_thread_and_ends_with_it() {
let f = Fixture::new("scope");