mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-15 14:43:28 +00:00
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:
1 parent
f68e227026
commit
0a5fc8a4ce
3 files changed
+496
-14
No files matched your search
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in new issue
Block a user