diff --git a/book/src/automation/security-model.md b/book/src/automation/security-model.md index 8afa1d21cd..405b847d42 100644 --- a/book/src/automation/security-model.md +++ b/book/src/automation/security-model.md @@ -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. diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index ade7ff9707..3d999d74cc 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -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 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> { + 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> { + 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) -> std::io::Result> { + let mut chain: Vec = 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 { + 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 { + 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 { 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(); diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs index 3e0f5e7d82..588197a64f 100644 --- a/crates/automation/src/workspace/tests.rs +++ b/crates/automation/src/workspace/tests.rs @@ -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 { + 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 [