automation: finish writes below a Windows UNC root

cap-std's Windows `rename`, `remove_file` and `create_dir` are path-based:
they append the request to the handle's own path, read back without its
`\\?\` prefix, and call `std::fs` (sunfishcode/cap-std#348). Below a drive
root that still resolves; below a UNC root (`\\wsl.localhost\...`, any
network share) it becomes the relative `UNC\wsl.localhost\...` and fails
with ERROR_PATH_NOT_FOUND. `AuthorizedWorkspace::write` then left only its
`.pcsave.tmp` behind, `export_sequence` could not create a new directory,
and `ExportSequence::cleanup` could not remove its own frames.

Opening files and directories and `stat` are handle-relative and work
below any root, so everything is built on them. Files are removed through
the held directory handle (`remove_file_in`: `Dir::remove_file`, then on
the UNC `NotFound` an open relative to the same handle with
FILE_FLAG_DELETE_ON_CLOSE | FILE_FLAG_OPEN_REPARSE_POINT). The two steps
that have no handle-relative form, creating a directory and renaming the
finished temporary file into place, run on the canonical root path
(trusted launch configuration, never the request) below directories that
were each verified a plain directory through the handle of their parent
and are held open for the duration (`held_directory_chain`,
`pinned_directory_chain`). cap-std opens directories without
FILE_SHARE_DELETE, so a held ancestor cannot be renamed away or replaced
by a link while the ambient step runs. The rename is therefore the same
temp-file + rename replace as on every other root: a failed save keeps the
original, and no temporary file is stranded. Drive roots and Unix are
unchanged.

The regression tests use the temp drive's administrative share
(`\\localhost\C$\...`) and skip on a runner that does not expose it; the
pinned-ancestor test shows a held chain refusing the swap with
ERROR_SHARING_VIOLATION. The security model notes document the two
ambient operations.

Fixes #10317

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Tiago CentaandClaude Fable 5.1 committed 2026-11-07 00:52:56 -03:00
1 parent f68e227026
commit 0a5fc8a4ce
3 files changed
+496 -14

No files matched your search

+31
View File
@@ -11,6 +11,7 @@ Automation requests are untrusted input with potential effects beyond the active
| MCP capability scopes | **Partial:** filesystem read/write capabilities only; general tool scopes are absent |
| Allowed read/write roots | **Implemented:** separate launch-time roots, with absent authority failing closed |
| Symlink-safe capability filesystem | **Implemented:** relative operations use held directory capabilities and reject link escapes |
| Windows UNC write roots | **Implemented:** handle-relative deletes; two ambient operations on the canonical root path (`create_dir`, `rename`), each below ancestors pinned by held handles; see below |
| Request-byte and JSON-depth limits | **Partial:** 1 MiB request-line limit on TCP and headless JSON-lines stdio (MCP stdio input is not byte-capped); no explicit JSON-depth policy |
| Batch-step limit | **Implemented:** 256 steps for headless and MCP batches |
| Reply-size limit | **Implemented:** 8 MiB encoded JSON/MCP result; aggregate batch reply budget |
@@ -41,3 +42,33 @@ Filesystem handles and a defensive command-path policy are implemented; explicit
capabilities, session-memory/command-duration budgets, and audit events remain. Preview and
reply ceilings do not bound all document operations or desktop screenshot capture. Private token handling, process
isolation, and least-privileged execution are still practical containment measures.
## Windows UNC write roots
`cap-std` 4's Windows `rename`, `remove_file` and `create_dir` are not handle-relative: they
append the request to the handle's own path, read back without its `\\?\` prefix, which below a
UNC root (`\\wsl.localhost\...`, any network share) fails with `ERROR_PATH_NOT_FOUND`
(sunfishcode/cap-std#348). Below such a root, judged by the canonical root path's prefix and
never by a request, the automation workspace keeps every rule above and completes these steps
as follows:
- **Deletes** (a stranded temporary file, the frames of a cancelled export) open the file
relative to the held directory handle with `FILE_FLAG_DELETE_ON_CLOSE` and
`FILE_FLAG_OPEN_REPARSE_POINT`, so a link is deleted rather than its target. Nothing is deleted
by path.
- **Directory creation** and the **rename of a finished temporary file into place** are the only
two operations that touch the filesystem by path. Both run on the canonical verbatim root path
(trusted launch configuration) only after every directory component on the way has been
opened through the handle of its parent, verified a plain directory (not a link, not a file),
and kept open. `cap-std` opens directories without `FILE_SHARE_DELETE`. This protects held
ancestors from Windows-side rename or deletion only when the provider enforces share modes
(`ERROR_SHARING_VIOLATION`). It does not protect against direct Linux-side changes in WSL. The
UNC regression test uses an SMB administrative share, not `\\wsl.localhost`.
The rename replaces the entry at the target without following it;
a directory at the target is refused.
Saves below a UNC root are therefore as atomic as everywhere else (temporary file, flush,
rename). No file contents are written or read through an ambient path; directory creation and
rename use ambient paths. Regression tests
run against the loopback administrative share of the test drive and are skipped where none is
available.
+212 -14
View File
@@ -78,10 +78,16 @@ impl AuthorizedWorkspace {
///
/// Replacing a file carries its extended attributes — macOS Finder tags, Linux `user.*` —
/// onto the file that takes its place (#7095), through the two open descriptors rather than
/// their paths, so nothing here reaches outside the write root to finish a write. The rest
/// of what [`photocraft_format::atomic_write`] preserves does not follow: its permission
/// step is tracked in #4963, and the Windows `ReplaceFileW` swap is path-based, which this
/// path may not use.
/// their paths. The rest of what [`photocraft_format::atomic_write`] preserves does not
/// follow: its permission step is tracked in #4963, and the Windows `ReplaceFileW` swap is
/// path-based, which this path may not use.
///
/// Nothing here reaches outside the write root. Below a Windows UNC root
/// (`\\wsl.localhost\...`, any network share) the capability's own rename and remove cannot
/// reach their target at all (#10317, see [`is_unc_root`]), so there the temporary file is
/// renamed into place on the canonical root path while every directory on the way is pinned
/// by a handle opened through the root ([`rename_in`]): the same atomic replace, never
/// outside the root, and never a stranded temporary file.
///
/// The parent directory must already exist. `cap-std` performs path
/// resolution and file creation relative to the held directory handle, so
@@ -139,7 +145,7 @@ impl AuthorizedWorkspace {
// Falls back to a plain fsync on shares that refuse a full flush (#1336).
photocraft_format::atomic::sync_file(&file)
});
let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative)));
let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || rename_in(root, &tmp, &relative)));
if let Err(e) = renamed {
return Err(file_error("write", path, e));
}
@@ -156,7 +162,8 @@ impl AuthorizedWorkspace {
}
}
/// Removes a temporary file on drop unless `armed` was cleared after its rename.
/// Removes a temporary file on drop unless `armed` was cleared after its rename. The removal
/// goes through the held directory handle ([`remove_file_in`]), so it works below every root.
struct TempRemoval<'a> {
dir: &'a Dir,
path: &'a Path,
@@ -166,7 +173,7 @@ struct TempRemoval<'a> {
impl Drop for TempRemoval<'_> {
fn drop(&mut self) {
if self.armed {
let _ = self.dir.remove_file(self.path);
let _ = remove_file_in(self.dir, self.path);
}
}
}
@@ -236,6 +243,183 @@ fn open_root(path: Option<&Path>, authority: &str) -> Result<Option<RootCapabili
Ok(Some(RootCapability { dir: Arc::new(dir), path: absolute, configured_path }))
}
/// `cap-std`'s Windows `rename`, `remove_file` and `create_dir` do not run relative to the held
/// handle: they append the request to the handle's own path and call `std::fs`
/// (sunfishcode/cap-std#348), and that path is read back without its `\\?\` prefix. Below a
/// drive root the result still resolves; below a UNC root (`\\?\UNC\wsl.localhost\...`, any
/// network share) it becomes the relative `UNC\wsl.localhost\...` and each of those steps
/// fails with `ERROR_PATH_NOT_FOUND`, so a save left only its temporary file behind (#10317).
/// Opening a file or directory and `stat` are handle-relative and work below any root, and
/// everything here is built on them: files are removed through handles ([`remove_file_in`]),
/// and the two steps that have no handle-relative form, making a directory and renaming the
/// finished temporary file into place, run on the canonical root path below directories pinned
/// by held handles ([`create_dir_all_unc`], [`rename_unc`]).
///
/// Whether the root is such a UNC root, judged by the canonical root path's prefix: trusted
/// launch configuration, never the request.
#[cfg(windows)]
fn is_unc_root(root: &RootCapability) -> bool {
matches!(root.path.components().next(), Some(Component::Prefix(p)) if matches!(p.kind(), Prefix::VerbatimUNC(..) | Prefix::UNC(..)))
}
/// Rename `from` to `to`, both relative to the write root, replacing whatever file is at `to`.
/// Through the capability wherever that works; below a Windows UNC root its `rename` reports
/// both names missing (see [`is_unc_root`]), so there the rename is done by [`rename_unc`].
fn rename_in(root: &RootCapability, from: &Path, to: &Path) -> std::io::Result<()> {
let renamed = root.dir.rename(from, &root.dir, to);
#[cfg(windows)]
if renamed.as_ref().is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound) && is_unc_root(root) {
return rename_unc(root, from, to);
}
renamed
}
/// Rename `from` to `to` below a UNC root with `std::fs::rename` on the canonical root path:
/// the second of the two ambient steps, the other being [`create_dir_all_unc`]'s `create_dir`,
/// and guarded the same way. The directory of each name is first opened component by component
/// through the handle of its parent ([`pinned_directory_chain`]), every component verified a
/// plain directory and then held open for the duration, so the path the rename resolves names
/// exactly the directories the capability verified: a link anywhere on the way is refused, an
/// absent directory is not created, and a held one cannot be renamed away or replaced while the
/// rename runs. The rename itself replaces the entry at `to` without following it: a link there
/// is replaced, never written through, and a directory there is refused.
#[cfg(windows)]
fn rename_unc(root: &RootCapability, from: &Path, to: &Path) -> std::io::Result<()> {
let parent = |name: &Path| name.parent().map(Path::to_path_buf).unwrap_or_default();
let _from_chain = pinned_directory_chain(root, &parent(from))?;
let _to_chain = pinned_directory_chain(root, &parent(to))?;
std::fs::rename(below_root(root, from), below_root(root, to))
}
/// `relative` below the canonical root path, one component at a time, so the result keeps the
/// root's verbatim form (`\\?\UNC\...`): a `/` in a request would not separate there.
#[cfg(windows)]
fn below_root(root: &RootCapability, relative: &Path) -> PathBuf {
let mut path = root.path.clone();
path.extend(relative.components());
path
}
/// Remove the file `name` relative to `dir`. On Windows the capability's own `remove_file`
/// reports every file below a UNC root missing (see [`is_unc_root`]); then the file is deleted
/// through a handle opened relative to `dir` instead ([`delete_on_close`]).
fn remove_file_in(dir: &Dir, name: &Path) -> std::io::Result<()> {
let removed = dir.remove_file(name);
#[cfg(windows)]
if removed.as_ref().is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound) {
return delete_on_close(dir, name);
}
removed
}
/// Delete `name` by opening it relative to `dir` with `FILE_FLAG_DELETE_ON_CLOSE` and dropping
/// the handle. The open resolves against the held directory like every other open, so it
/// cannot reach outside it. `FILE_FLAG_OPEN_REPARSE_POINT` makes a link at `name` the thing
/// deleted rather than its target, as `remove_file` does; a directory is refused and a missing
/// name reports `NotFound`, both as `remove_file` would.
#[cfg(windows)]
fn delete_on_close(dir: &Dir, name: &Path) -> std::io::Result<()> {
use cap_std::fs::OpenOptionsExt;
const FILE_FLAG_DELETE_ON_CLOSE: u32 = 0x0400_0000;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
let mut options = OpenOptions::new();
options.read(true).custom_flags(FILE_FLAG_DELETE_ON_CLOSE | FILE_FLAG_OPEN_REPARSE_POINT);
dir.open_with(name, &options).map(drop)
}
/// Make `relative` a directory below the write root, with its missing ancestors. Through the
/// capability wherever that works; below a Windows UNC root its `create_dir_all` reports
/// `NotFound` whether or not the directory exists (see [`is_unc_root`]), so there an existing
/// directory is accepted by opening it through the handle and a missing one is made by
/// [`create_dir_all_unc`].
fn ensure_export_dir(root: &RootCapability, relative: &Path) -> std::io::Result<()> {
let created = root.dir.create_dir_all(relative);
#[cfg(windows)]
if created.as_ref().is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound) && is_unc_root(root) {
return match root.dir.open_dir(relative) {
Ok(_) => Ok(()),
Err(_) => create_dir_all_unc(root, relative),
};
}
created
}
/// Directory creation is one of the two steps `cap-std` has no handle-relative form of on
/// Windows (it never opens with `FILE_DIRECTORY_FILE`; the other is the rename of
/// [`rename_unc`]), so below a UNC root an absent component is made with `std::fs::create_dir`
/// on the canonical root path — trusted launch configuration, never the request. The walk
/// ([`held_directory_chain`]) descends one component at a time and keeps a handle open on every
/// ancestor it has verified through its parent's handle. `cap-std` opens directories without
/// `FILE_SHARE_DELETE` precisely so that they cannot be renamed or deleted underneath its
/// sandboxed path lookups, and the held chain pins the ancestors here the same way: an ancestor
/// cannot be swapped for a link while the walk is below it, so the one ambient `create_dir` can
/// only add a new, empty directory inside the directory its held parent handle names. Something
/// that appears under the component's name between the check and `create_dir` makes
/// `create_dir` fail with `AlreadyExists`, which is reported, never resolved. No file is ever
/// written or removed ambiently, and the one ambient rename is guarded by the same held chain.
#[cfg(windows)]
fn create_dir_all_unc(root: &RootCapability, relative: &Path) -> std::io::Result<()> {
held_directory_chain(root, relative).map(drop)
}
/// Open each component of `relative` through the handle of its parent, creating an absent one
/// first as described at [`create_dir_all_unc`]. The returned handles, outermost first, keep
/// every directory on the way pinned for as long as they are held.
#[cfg(windows)]
fn held_directory_chain(root: &RootCapability, relative: &Path) -> std::io::Result<Vec<Dir>> {
directory_chain(root, relative, open_or_create_plain_dir)
}
/// [`held_directory_chain`] for directories that must already exist: an absent component is
/// `NotFound`, and nothing is created. This is what pins the way for [`rename_unc`].
#[cfg(windows)]
fn pinned_directory_chain(root: &RootCapability, relative: &Path) -> std::io::Result<Vec<Dir>> {
directory_chain(root, relative, |parent, name, ancestor, _| open_plain_dir(parent, name, ancestor))
}
/// The walk shared by [`held_directory_chain`] and [`pinned_directory_chain`]: `open` receives
/// the parent's handle, the component's name, its path relative to the root and its path below
/// the canonical root, in that order.
#[cfg(windows)]
fn directory_chain(root: &RootCapability, relative: &Path, open: fn(&Dir, &Path, &Path, &Path) -> std::io::Result<Dir>) -> std::io::Result<Vec<Dir>> {
let mut chain: Vec<Dir> = Vec::new();
let mut ancestor = PathBuf::new();
for component in relative.components() {
ancestor.push(component);
let parent = chain.last().unwrap_or(&root.dir);
let dir = open(parent, Path::new(component.as_os_str()), &ancestor, &root.path.join(&ancestor))?;
chain.push(dir);
}
Ok(chain)
}
/// Open `name`, one component of `parent`, when it is a plain directory; when it is absent,
/// create it at `ambient` (its path below the canonical root, which `parent` is a handle to)
/// and open what is there now. `ancestor` names the component relative to the root in errors.
#[cfg(windows)]
fn open_or_create_plain_dir(parent: &Dir, name: &Path, ancestor: &Path, ambient: &Path) -> std::io::Result<Dir> {
match open_plain_dir(parent, name, ancestor) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
std::fs::create_dir(ambient)?;
open_plain_dir(parent, name, ancestor)
}
opened => opened,
}
}
/// Open `name` in `parent` only if it is a plain directory — not a link, which ambient
/// resolution would follow out of the root, and not a file.
#[cfg(windows)]
fn open_plain_dir(parent: &Dir, name: &Path, ancestor: &Path) -> std::io::Result<Dir> {
let meta = parent.symlink_metadata(name)?;
if meta.is_dir() && !meta.is_symlink() {
parent.open_dir(name)
} else {
let message = format!("`{}` is not a plain directory below the root", ancestor.display());
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, message))
}
}
/// Both root spellings refer to the capability opened at startup. Do not resolve an untrusted
/// absolute request using ambient filesystem access: strip a known root first and let cap-std
/// resolve the remaining name. New targets and nested export directories need not exist yet.
@@ -533,16 +717,21 @@ pub(crate) struct ExportSequence {
}
impl AuthorizedWorkspace {
/// Open the export directory `path` below the write root as its own capability, creating it
/// (and missing ancestors) first; see [`ensure_export_dir`]. Every file the sequence then
/// writes or removes goes through that held directory handle.
pub(crate) fn export_sequence(&self, path: &str) -> Result<ExportSequence, AutomationError> {
let relative = request_path(path, self.write.as_ref())?;
let root = self.write.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: write authority is absent")))?;
root.dir.create_dir_all(&relative).map_err(|e| file_error("mkdir", path, e))?;
ensure_export_dir(root, &relative).map_err(|e| file_error("mkdir", path, e))?;
let dir = root.dir.open_dir(&relative).map_err(|e| file_error("open export directory", path, e))?;
Ok(ExportSequence { dir, created: Vec::new() })
}
}
impl ExportSequence {
/// Create `name` (a single path component) in the export directory and write `bytes` to
/// it. An existing entry of that name, link or file, is a collision and is refused.
pub(crate) fn write(&mut self, name: &str, bytes: &[u8]) -> Result<(), AutomationError> {
let relative = relative_path(name)?;
if relative.components().count() != 1 {
@@ -556,18 +745,26 @@ impl ExportSequence {
file.write_all(bytes).and_then(|()| file.sync_all()).map_err(|e| file_error("write export", name, e))
}
/// Keep every file written so far: nothing is removed on cleanup or drop.
pub(crate) fn commit(&mut self) {
self.created.clear();
}
/// Remove every file written since the last [`commit`](Self::commit), each through the held
/// directory handle (see [`remove_file_in`]). A file already gone is fine; any other failure
/// is reported, and that name stays listed for a later attempt.
pub(crate) fn cleanup(&mut self) -> Result<(), AutomationError> {
let mut failed = Vec::new();
self.created.retain(|name| match self.dir.remove_file(name) {
Ok(()) => false,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => {
failed.push(format!("{name}: {e}"));
true
let (dir, created) = (&self.dir, &mut self.created);
created.retain(|name| {
let removed = remove_file_in(dir, Path::new(name));
match removed {
Ok(()) => false,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => {
failed.push(format!("{name}: {e}"));
true
}
}
});
if failed.is_empty() { Ok(()) } else { Err(AutomationError::Io(format!("export cleanup failed: {}", failed.join("; ")))) }
@@ -575,6 +772,7 @@ impl ExportSequence {
}
impl Drop for ExportSequence {
/// Remove whatever was not committed, best effort.
fn drop(&mut self) {
// Also cover an escaped rendering panic; normal errors report cleanup failures above.
let _ = self.cleanup();
+253
View File
@@ -371,6 +371,259 @@ fn windows_junction_escape_is_rejected_when_supported() {
let _ = std::fs::remove_dir(link);
}
/// #10317: below a UNC root (`\\wsl.localhost\...`, any share) `cap-std`'s Windows rename,
/// remove and mkdir fail with `ERROR_PATH_NOT_FOUND`, so a save left only its `.pcsave.tmp`
/// behind, an export into a new directory failed, and a cancelled export leaked its frames.
/// There a save still goes through its temporary file and is renamed into place on the
/// canonical root path below pinned directories, cleanup deletes through handles opened
/// relative to the export directory, and directory creation goes through the root path the
/// same pinned way. The administrative share of the temp directory's drive stands in for a
/// network share; a runner without one has nothing to check here.
#[cfg(windows)]
#[test]
fn windows_unc_root_writes_and_exports_when_supported() {
let (inside, outside, _) = roots("unc-root");
let Some(unc) = loopback_unc(&inside) else { return };
if std::fs::metadata(&unc).is_err() {
return;
}
let canonical = std::fs::canonicalize(&unc).unwrap();
assert!(matches!(canonical.components().next(), Some(Component::Prefix(p)) if matches!(p.kind(), Prefix::VerbatimUNC(..))), "{}", canonical.display());
let workspace = AuthorizedWorkspace::new(Some(&unc), Some(&unc)).unwrap();
workspace.write("out.png", b"first").unwrap();
workspace.write("out.png", b"second").unwrap();
std::fs::create_dir(inside.join("sub")).unwrap();
workspace.write("sub/nested.png", b"nested").unwrap();
assert_eq!(std::fs::read(inside.join("out.png")).unwrap(), b"second");
assert_eq!(std::fs::read(inside.join("sub/nested.png")).unwrap(), b"nested");
assert!(temp_files(&inside).is_empty());
assert!(temp_files(&inside.join("sub")).is_empty());
// Still atomic: a writer that fails leaves the previous bytes and no temporary file.
let failed = workspace.write_stream("out.png", &mut |w| {
w.write_all(b"partial")?;
Err(std::io::Error::other("encoder gave up"))
});
assert!(failed.is_err());
assert_eq!(std::fs::read(inside.join("out.png")).unwrap(), b"second");
assert!(temp_files(&inside).is_empty());
// A large streamed save takes the same route as everywhere else.
workspace.write_stream("sub/streamed.png", &mut |w| w.write_all(&[7u8; 1 << 20])).unwrap();
assert_eq!(std::fs::metadata(inside.join("sub/streamed.png")).unwrap().len(), 1 << 20);
assert!(temp_files(&inside.join("sub")).is_empty());
// A missing parent is still an error, with nothing left behind, as on every other root.
assert!(workspace.write("missing/out.png", b"no parent").is_err());
assert!(!inside.join("missing").exists());
let mut export = workspace.export_sequence("frames/nested").unwrap();
export.write("clip_0000.png", b"frame").unwrap();
assert_eq!(std::fs::read(inside.join("frames/nested/clip_0000.png")).unwrap(), b"frame");
export.cleanup().unwrap();
assert!(!inside.join("frames/nested/clip_0000.png").exists());
let mut export = workspace.export_sequence("frames/nested").unwrap();
export.write("clip_0001.png", b"kept").unwrap();
export.commit();
drop(export);
assert_eq!(std::fs::read(inside.join("frames/nested/clip_0001.png")).unwrap(), b"kept");
// The share honours the held ancestors' share mode: no swap while the chain is open.
let root = workspace.write.as_ref().unwrap();
let chain = held_directory_chain(root, Path::new("frames/nested/deeper")).unwrap();
assert!(std::fs::rename(inside.join("frames"), inside.join("frames-moved")).is_err());
assert!(inside.join("frames/nested/deeper").is_dir());
drop(chain);
// Neither the rename into place nor directory creation follows a link out of the root.
let link = inside.join("junction");
if std::process::Command::new("cmd.exe").args(["/D", "/C", "mklink", "/J"]).arg(&link).arg(&outside).status().is_ok_and(|status| status.success()) {
assert!(workspace.write("junction/escape.png", b"blocked").is_err());
assert!(workspace.export_sequence("junction/frames").is_err());
assert!(!outside.join("escape.png").exists());
assert!(!outside.join("frames").exists());
assert!(temp_files(&outside).is_empty());
let _ = std::fs::remove_dir(link);
}
}
/// The handle-relative helpers that stand in for `cap-std`'s path-concatenating Windows
/// operations below a UNC root (#10317) behave like the operations they replace, on any root.
#[cfg(windows)]
#[test]
fn windows_handle_relative_helpers_match_the_capability_operations() {
let (inside, outside, workspace) = roots("unc-helpers");
let root = workspace.write.as_ref().unwrap();
assert!(!is_unc_root(root));
std::fs::write(inside.join("gone.png"), b"x").unwrap();
delete_on_close(&root.dir, Path::new("gone.png")).unwrap();
assert!(!inside.join("gone.png").exists());
std::fs::write(inside.join("gone.png"), b"x").unwrap();
remove_file_in(&root.dir, Path::new("gone.png")).unwrap();
assert!(!inside.join("gone.png").exists());
assert_eq!(delete_on_close(&root.dir, Path::new("missing.png")).unwrap_err().kind(), std::io::ErrorKind::NotFound);
assert_eq!(remove_file_in(&root.dir, Path::new("missing.png")).unwrap_err().kind(), std::io::ErrorKind::NotFound);
std::fs::create_dir(inside.join("dir")).unwrap();
assert!(delete_on_close(&root.dir, Path::new("dir")).is_err());
assert!(inside.join("dir").is_dir());
// The ambient rename on the canonical root path behaves like the capability's: it creates,
// replaces a file, replaces a link instead of writing through it, and refuses a directory.
std::fs::write(inside.join("plain.tmp"), b"the longer first version").unwrap();
rename_unc(root, Path::new("plain.tmp"), Path::new("plain.png")).unwrap();
std::fs::write(inside.join("plain.tmp"), b"short").unwrap();
rename_unc(root, Path::new("plain.tmp"), Path::new("plain.png")).unwrap();
rename_in(root, Path::new("plain.png"), Path::new("plain.png")).unwrap();
assert_eq!(std::fs::read(inside.join("plain.png")).unwrap(), b"short");
assert!(!inside.join("plain.tmp").exists());
assert_eq!(rename_unc(root, Path::new("absent.tmp"), Path::new("plain.png")).unwrap_err().kind(), std::io::ErrorKind::NotFound);
assert_eq!(rename_unc(root, Path::new("plain.png"), Path::new("missing/plain.png")).unwrap_err().kind(), std::io::ErrorKind::NotFound);
assert!(!inside.join("missing").exists());
std::fs::write(inside.join("plain.tmp"), b"x").unwrap();
assert!(rename_unc(root, Path::new("plain.tmp"), Path::new("dir")).is_err());
assert!(inside.join("dir").is_dir());
std::fs::write(outside.join("target.png"), b"outside").unwrap();
if std::os::windows::fs::symlink_file(outside.join("target.png"), inside.join("linked.png")).is_ok() {
rename_unc(root, Path::new("plain.tmp"), Path::new("linked.png")).unwrap();
assert_eq!(std::fs::read(inside.join("linked.png")).unwrap(), b"x");
assert_eq!(std::fs::read(outside.join("target.png")).unwrap(), b"outside");
}
let _ = std::fs::remove_file(inside.join("plain.tmp"));
create_dir_all_unc(root, Path::new("a/b/c")).unwrap();
assert!(inside.join("a/b/c").is_dir());
create_dir_all_unc(root, Path::new("a/b/c")).unwrap();
assert_eq!(held_directory_chain(root, Path::new("a/b/c")).unwrap().len(), 3);
std::fs::write(inside.join("a/file"), b"x").unwrap();
assert_eq!(create_dir_all_unc(root, Path::new("a/file")).unwrap_err().kind(), std::io::ErrorKind::PermissionDenied);
assert!(create_dir_all_unc(root, Path::new("a/file/below")).is_err());
assert!(inside.join("a/file").is_file());
assert!(ensure_export_dir(root, Path::new("a/b/d")).is_ok());
assert!(inside.join("a/b/d").is_dir());
let link = inside.join("junction");
if std::process::Command::new("cmd.exe").args(["/D", "/C", "mklink", "/J"]).arg(&link).arg(&outside).status().is_ok_and(|status| status.success()) {
assert!(create_dir_all_unc(root, Path::new("junction")).is_err());
assert!(create_dir_all_unc(root, Path::new("junction/frames")).is_err());
assert!(!outside.join("frames").exists());
assert_eq!(pinned_directory_chain(root, Path::new("junction")).unwrap_err().kind(), std::io::ErrorKind::PermissionDenied);
std::fs::write(inside.join("escape.tmp"), b"blocked").unwrap();
assert_eq!(rename_unc(root, Path::new("escape.tmp"), Path::new("junction/escape.png")).unwrap_err().kind(), std::io::ErrorKind::PermissionDenied);
assert!(rename_unc(root, Path::new("junction/source.png"), Path::new("escape.png")).is_err());
assert!(!outside.join("escape.png").exists());
assert!(inside.join("escape.tmp").is_file());
let _ = std::fs::remove_dir(link);
}
}
/// The ancestor-swap race against [`create_dir_all_unc`]: between verifying an ancestor and the
/// ambient `create_dir`, an attacker renames the ancestor away and puts a junction to the
/// outside in its place. The walk holds a handle on every verified ancestor, opened by
/// `cap-std` without `FILE_SHARE_DELETE`, so the rename (and a removal) fails with
/// `ERROR_SHARING_VIOLATION` for as long as the chain is held, and the directory is created
/// inside the root. A junction that is already in place is refused before anything is created.
#[cfg(windows)]
#[test]
fn windows_held_ancestors_cannot_be_swapped_during_directory_creation() {
const ERROR_SHARING_VIOLATION: i32 = 32;
let (inside, outside, workspace) = roots("unc-held-chain");
let root = workspace.write.as_ref().unwrap();
std::fs::create_dir(inside.join("a")).unwrap();
let chain = held_directory_chain(root, Path::new("a/b")).unwrap();
assert_eq!(chain.len(), 2);
assert!(inside.join("a/b").is_dir());
let swap = std::fs::rename(inside.join("a"), inside.join("a-moved"));
assert_eq!(swap.unwrap_err().raw_os_error(), Some(ERROR_SHARING_VIOLATION));
assert_eq!(std::fs::remove_dir(inside.join("a/b")).unwrap_err().raw_os_error(), Some(ERROR_SHARING_VIOLATION));
// With the ancestors pinned, the one ambient step lands inside the root.
let held_parent = &chain[1];
open_or_create_plain_dir(held_parent, Path::new("c"), Path::new("a/b/c"), &root.path.join("a/b/c")).unwrap();
assert!(inside.join("a/b/c").is_dir());
assert!(inside.join("a").is_dir());
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 0);
drop(chain);
// The chain that guards the rename pins the same way, and creates nothing.
let pinned = pinned_directory_chain(root, Path::new("a/b/c")).unwrap();
assert_eq!(pinned.len(), 3);
assert_eq!(std::fs::rename(inside.join("a"), inside.join("a-moved")).unwrap_err().raw_os_error(), Some(ERROR_SHARING_VIOLATION));
assert_eq!(pinned_directory_chain(root, Path::new("a/b/c/d")).unwrap_err().kind(), std::io::ErrorKind::NotFound);
assert!(!inside.join("a/b/c/d").exists());
std::fs::write(inside.join("a/b/c/new.tmp"), b"renamed below pinned ancestors").unwrap();
rename_unc(root, Path::new("a/b/c/new.tmp"), Path::new("a/b/c/new.png")).unwrap();
assert_eq!(std::fs::read(inside.join("a/b/c/new.png")).unwrap(), b"renamed below pinned ancestors");
drop(pinned);
std::fs::rename(inside.join("a"), inside.join("a-moved")).unwrap();
// The swap already done: the junction is seen through the parent handle and refused.
let link = inside.join("a");
if std::process::Command::new("cmd.exe").args(["/D", "/C", "mklink", "/J"]).arg(&link).arg(&outside).status().is_ok_and(|status| status.success()) {
let refused = open_or_create_plain_dir(&root.dir, Path::new("a"), Path::new("a"), &root.path.join("a"));
assert_eq!(refused.unwrap_err().kind(), std::io::ErrorKind::PermissionDenied);
assert!(held_directory_chain(root, Path::new("a/b/c")).is_err());
assert!(pinned_directory_chain(root, Path::new("a/b/c")).is_err());
assert!(ensure_export_dir(root, Path::new("a/b/c")).is_err());
assert!(workspace.export_sequence("a/b/c").is_err());
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 0);
let _ = std::fs::remove_dir(link);
}
}
/// The rules that do not depend on the root hold below a UNC root too (#10317): `..` is refused
/// in a request and climbs no further than the root in the configured spelling, a directory at
/// the target is refused before it is opened and survives, and a path past `MAX_PATH` works,
/// since the canonical root is verbatim (`\\?\UNC\...`) and every step below it goes through a
/// handle or that verbatim root path.
#[cfg(windows)]
#[test]
fn windows_unc_root_refuses_traversal_and_accepts_long_paths_when_supported() {
let (inside, outside, _) = roots("unc-rules");
let Some(unc) = loopback_unc(&inside) else { return };
if std::fs::metadata(&unc).is_err() {
return;
}
let workspace = AuthorizedWorkspace::new(Some(&unc), Some(&unc)).unwrap();
for path in ["../escape.png", "sub/../../escape.png", r"sub\..\..\escape.png", r"..\frames"] {
assert!(workspace.write(path, b"no").is_err(), "write accepted {path:?}");
assert!(workspace.export_sequence(path).is_err(), "export accepted {path:?}");
}
let spelled = unc.join("..").join("inside");
let aliased = AuthorizedWorkspace::new(Some(&spelled), Some(&spelled)).unwrap();
aliased.write("spelled.png", b"same grant").unwrap();
assert_eq!(std::fs::read(inside.join("spelled.png")).unwrap(), b"same grant");
assert!(aliased.write(&spelled.join("..").join("outside").join("climb.png").to_string_lossy(), b"no").is_err());
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 0);
std::fs::create_dir(inside.join("adir")).unwrap();
assert!(workspace.write("adir", b"x").is_err());
assert!(inside.join("adir").is_dir());
assert!(temp_files(&inside).is_empty());
let long = format!("frames/{}/{}", "x".repeat(120), "y".repeat(120));
assert!(unc.join(&long).as_os_str().len() > 260);
let mut export = workspace.export_sequence(&long).unwrap();
export.write("clip_0000.png", b"deep").unwrap();
export.commit();
drop(export);
assert_eq!(std::fs::read(inside.join(&long).join("clip_0000.png")).unwrap(), b"deep");
workspace.write(&format!("{long}/saved.png"), b"long save").unwrap();
assert_eq!(std::fs::read(inside.join(&long).join("saved.png")).unwrap(), b"long save");
assert!(temp_files(&inside.join(&long)).is_empty());
}
/// `\\localhost\C$\...` for a path on drive `C:`; `None` for a path without a drive letter.
#[cfg(windows)]
fn loopback_unc(path: &Path) -> Option<PathBuf> {
let mut components = path.components();
let Some(Component::Prefix(prefix)) = components.next() else { return None };
let drive = match prefix.kind() {
Prefix::Disk(drive) | Prefix::VerbatimDisk(drive) => char::from(drive),
_ => return None,
};
let mut unc = PathBuf::from(format!(r"\\localhost\{drive}$"));
unc.extend(components.filter(|component| matches!(component, Component::Normal(_))));
Some(unc)
}
#[test]
fn filesystem_commands_fail_closed() {
for id in [