From 33585ef6461fdfcb3139bb06e159734c5b3ea94f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 15:14:55 +0900 Subject: [PATCH 01/60] docs(private-fs): state the intent of private-fs private-fs becomes the one place where files and directories are kept from other accounts: callers define rules naming accounts and kinds of access, and private-fs checks, creates and repairs according to them on every platform. The device-key file backend needs to read the permissions of its key directory's ancestors to check them, so its intent now names both rights it needs there. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/device-key/INTENT.md | 2 +- crates/private-fs/INTENT.md | 60 +++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 crates/private-fs/INTENT.md diff --git a/crates/device-key/INTENT.md b/crates/device-key/INTENT.md index 24c0b21761..e12019489c 100644 --- a/crates/device-key/INTENT.md +++ b/crates/device-key/INTENT.md @@ -87,5 +87,5 @@ The crate never falls back to another backend, and never logs that error or rais The file backend is a supported backend, not a test stand-in. Its key files are PKCS#8. On Unix, the directories it creates for keys are `0700`, so the access rule holds before the first key exists. -It needs only traverse permission on the ancestors of its key directory, not read permission, so a service can keep keys under a parent it cannot list. +It needs only two rights on the ancestors of its key directory, traversing them and reading their permissions, not listing them, so a service can keep keys under a parent it cannot list. Opening refuses a key file larger than 64 KiB without reading past that bound; a P-256 key file is about 140 bytes. diff --git a/crates/private-fs/INTENT.md b/crates/private-fs/INTENT.md new file mode 100644 index 0000000000..d5ee0c24f0 --- /dev/null +++ b/crates/private-fs/INTENT.md @@ -0,0 +1,60 @@ +## Overview + +private-fs is a building block for files and directories that only the accounts a rule allows can access. +It exists so that the unsafe calls into the operating system's permission and ACL APIs live in one place, encapsulated, rather than being reimplemented by each crate that needs them. +It is meant for every crate in the workspace that keeps files from other accounts, such as the package broker, the agent's identity, the updater's files and the Gateway's secrets. +It covers files and directories only, not other objects the operating system secures, such as named pipes. + + +## Vocabulary + +- **Rule**: what a caller asks for a file or directory: which accounts may own it, and which accounts may access it with which kind of access. +- **Check**: private-fs comparing an existing file or directory with a rule. +- **Repair**: bringing an existing file or directory into line with a rule. +- **Account**: a user, a group, or a built-in account such as root, SYSTEM or Administrators. + *Avoid:* user, principal, trustee. +- **Owner**: the account that owns a file or directory, in the operating system's sense. +- **Access**: one kind of access: full, read and write, or add only. + *Avoid:* permission, right, privilege and mode as general words; `mode` and DACL name only the platform mechanisms. +- **Supported**: whether a platform can enforce a kind of access. +- **Private**: a file or directory that satisfies its rule. + *Avoid:* restricted, protected, secure, safe. +- **Handle**: an open, checked directory or file through which further operations on it happen. + +*Avoid:* capability, which here could mean Linux capabilities, a handle, or what a platform supports; trusted account, which "an account the rule allows" says with the words above. + + +## Rules + +- Callers define the rules; private-fs checks, creates and repairs files and directories according to each caller's rule. + private-fs knows the operating system's access control, and callers know their file formats, their locations and which accounts a rule allows. +- The checks live in private-fs rather than in each caller, because copies of one check drift. +- A rule names accounts, each with a kind of access. + It names accounts rather than a kind of file, so that private-fs never knows its callers, and kinds of access rather than an exact mode or DACL, so that the knowledge of what each platform needs stays in private-fs. +- A caller writes one rule for every platform. + Where a platform cannot enforce a kind of access, the rule says what to use instead, and private-fs never chooses a broader access on its own. +- Files and directories are created and opened through a handle checked against its rule, never by resolving a path again. + + +## Mechanisms + +private-fs covers every mechanism through which another account can gain access to a file or directory, and a missing one is a defect: + +- the owner and the permission bits; +- Linux ACLs and macOS ACLs; +- the Windows owner and DACL, inherited entries included; +- links and reparse points; +- volumes that ignore or fake ownership. + +An administrator's override, such as root or the Windows backup and restore privileges, and security modules such as SELinux, which can only take access away, are outside it. + + +## Creating, checking and repairing + +- A new directory has exactly its caller's rule from the moment it exists. +- A directory that another process creates first counts as created only if it passes the rule. +- A directory whose files were reported written survives a power cut. +- private-fs refuses a link from the first existing directory of a path down, the part its caller controls. +- As OpenSSH's StrictModes does, private-fs checks the directories above that one on their resolved path, up to the root, and refuses the path when an account other than the system account, administrators and the accounts its rule allows can rename or replace one of them; being able to add an entry beside it does not count. +- A directory above whose permissions private-fs cannot read fails that check, because the account that owns it could otherwise hide it from the check. +- private-fs can bring an existing file or directory into line with a rule; each caller decides whether to repair or refuse. From 13253dd802e506892c7ecb8fd0a4da299696007d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 16:12:01 +0900 Subject: [PATCH 02/60] docs(agent): require the agent's account or root to own Unix pending files One rule for every platform now governs pending enrollment files, and Windows already accepts only SYSTEM, the agent's account or an administrator as their owner. On Linux and macOS only root or the agent's account can create a file in the owner-only pending directory, so no file the agent should process is refused. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/agent-identity/INTENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/agent-identity/INTENT.md b/crates/agent-identity/INTENT.md index 102fbe2d10..653bdf6be4 100644 --- a/crates/agent-identity/INTENT.md +++ b/crates/agent-identity/INTENT.md @@ -52,7 +52,7 @@ The location and format of the files below are stable, because the conformance t - `` is the SHA-256 of the full token's UTF-8 bytes encoded as lowercase hexadecimal, and is the only encoding of a token hash the agent stores: file names and every `token_sha256` field use it. - One file per token, named ``: - Linux and macOS: `/identity/pending/.json`, mode `0600`, with content `{ "version": 1, "token": "" }`. - The agent does not enforce the file's owner, and ignores and keeps a file with any group or other permission bit. + The file is owned by the agent's account or root; the agent ignores and keeps a file owned by any other account or with any group or other permission bit. The file holds the token in plaintext, protected only by its permissions, until the authority answers; that is accepted. - Windows: `\identity\pending\.dat`, whose content is DPAPI `CryptProtectData(json, entropy = "Devolutions.Agent.PendingEnrollment.v1", CRYPTPROTECT_LOCAL_MACHINE)` of the same JSON; `.dat` because the content is an opaque blob, not JSON. The agent accepts exactly two forms and ignores and keeps any other file: From 90146b9baa39f99e28f79e95da88ec679d74834e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 15:30:50 +0900 Subject: [PATCH 03/60] refactor(private-fs): define rules and directory handles Callers describe who may own and access a file or directory with a rule, and create and open what lies below a checked base directory through handles, so that each platform's access control stays inside private-fs. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/Cargo.toml | 6 +- crates/private-fs/src/error.rs | 120 ++++++++++ crates/private-fs/src/lib.rs | 305 ++++++++++++++++++++------ crates/private-fs/src/linux.rs | 1 + crates/private-fs/src/rule.rs | 125 +++++++++++ crates/private-fs/src/unix.rs | 85 +++++++ crates/private-fs/src/windows.rs | 96 ++++++++ testsuite/Cargo.toml | 1 + testsuite/tests/main.rs | 1 + testsuite/tests/private_fs/mod.rs | 6 + testsuite/tests/private_fs/unix.rs | 0 testsuite/tests/private_fs/windows.rs | 0 12 files changed, 681 insertions(+), 65 deletions(-) create mode 100644 crates/private-fs/src/error.rs create mode 100644 crates/private-fs/src/linux.rs create mode 100644 crates/private-fs/src/rule.rs create mode 100644 crates/private-fs/src/unix.rs create mode 100644 crates/private-fs/src/windows.rs create mode 100644 testsuite/tests/private_fs/mod.rs create mode 100644 testsuite/tests/private_fs/unix.rs create mode 100644 testsuite/tests/private_fs/windows.rs diff --git a/crates/private-fs/Cargo.toml b/crates/private-fs/Cargo.toml index 0d6d660321..627b94de56 100644 --- a/crates/private-fs/Cargo.toml +++ b/crates/private-fs/Cargo.toml @@ -3,12 +3,14 @@ name = "private-fs" version = "0.0.0" authors = ["Devolutions Inc. "] edition = "2024" -description = "Unix helpers for files and directories that only their owner can access" +description = "Files and directories that only the accounts a rule allows can access" publish = false [lints] workspace = true -[target.'cfg(unix)'.dependencies] +[dependencies] camino = "1" + +[target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/private-fs/src/error.rs b/crates/private-fs/src/error.rs new file mode 100644 index 0000000000..89de5d37c1 --- /dev/null +++ b/crates/private-fs/src/error.rs @@ -0,0 +1,120 @@ +use std::{fmt, io}; + +use camino::{Utf8Path, Utf8PathBuf}; + +use crate::{Access, Account}; + +/// A failed operation, and the path it failed on. +#[derive(Debug)] +pub struct Error { + path: Utf8PathBuf, + kind: ErrorKind, +} + +#[derive(Debug)] +pub enum ErrorKind { + /// An I/O step failed. + Io { step: &'static str, source: io::Error }, + /// The file or directory at the path, or the directory above it at the path, breaks its rule. + Refused(Refusal), + /// The rule gives `account` an `access` that this platform cannot enforce, and states no fallback. + Unsupported { account: Account, access: Access }, +} + +/// Why a file or directory breaks its rule. +#[derive(Debug)] +pub enum Refusal { + /// It is a symbolic link, a reparse point or a hard link. + Link, + /// It is not a directory. + NotADirectory, + /// It is not a regular file. + NotAFile, + /// An account that the rule doesn't allow owns it. + Owner, + /// It gives an account more access than the rule does, or, under an exact rule, less. + Access, + /// Its volume ignores or fakes ownership, or doesn't keep access control lists. + Volume, + /// It is a directory above, and an account other than the system account, administrators and the accounts the rule + /// allows can rename or replace it. + Replaceable, + /// It is a directory above, and its owner or access can't be read. + Unreadable(io::Error), +} + +impl Error { + pub(crate) fn io(path: &Utf8Path, step: &'static str, source: io::Error) -> Self { + Self { + path: path.to_owned(), + kind: ErrorKind::Io { step, source }, + } + } + + pub(crate) fn refused(path: &Utf8Path, refusal: Refusal) -> Self { + Self { + path: path.to_owned(), + kind: ErrorKind::Refused(refusal), + } + } + + pub(crate) fn unsupported(path: &Utf8Path, account: Account, access: Access) -> Self { + Self { + path: path.to_owned(), + kind: ErrorKind::Unsupported { account, access }, + } + } + + pub fn path(&self) -> &Utf8Path { + &self.path + } + + pub fn kind(&self) -> &ErrorKind { + &self.kind + } + + /// Reports whether a file or directory at the path, or above it, breaks its rule. + pub fn is_refused(&self) -> bool { + matches!(self.kind, ErrorKind::Refused(_)) + } + + /// Reports whether an I/O step failed because a path doesn't exist. + pub fn is_not_found(&self) -> bool { + matches!(&self.kind, ErrorKind::Io { source, .. } if source.kind() == io::ErrorKind::NotFound) + } +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.kind { + ErrorKind::Io { step, .. } => write!(formatter, "{step} {}", self.path), + ErrorKind::Refused(refusal) => { + let reason = match refusal { + Refusal::Link => "is a link", + Refusal::NotADirectory => "is not a directory", + Refusal::NotAFile => "is not a regular file", + Refusal::Owner => "is owned by an account the rule doesn't allow", + Refusal::Access => "gives access the rule doesn't", + Refusal::Volume => "is on a volume that doesn't enforce ownership and access", + Refusal::Replaceable => "can be renamed or replaced by an account the rule doesn't allow", + Refusal::Unreadable(_) => "has an owner or access that can't be read", + }; + write!(formatter, "{} {reason}", self.path) + } + ErrorKind::Unsupported { account, access } => write!( + formatter, + "{access:?} access for {account:?} is not supported here and the rule for {} states no fallback", + self.path + ), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self.kind { + ErrorKind::Io { source, .. } | ErrorKind::Refused(Refusal::Unreadable(source)) => Some(source), + ErrorKind::Refused(_) | ErrorKind::Unsupported { .. } => None, + } + } +} diff --git a/crates/private-fs/src/lib.rs b/crates/private-fs/src/lib.rs index 4e1a6c9942..3d26cac50b 100644 --- a/crates/private-fs/src/lib.rs +++ b/crates/private-fs/src/lib.rs @@ -1,87 +1,266 @@ -//! Unix helpers for files and directories that only their owner can access. +//! Files and directories that only the accounts a rule allows can access. //! -//! Each helper reports a fact or the I/O error of the step that failed. -//! Callers keep their own owner rules and error wording, and decide where their files live. -#![cfg(unix)] +//! A caller describes who may own and access a file or directory with a [`Rule`], opens a base directory with +//! [`Dir::create_base`] or [`Dir::open_base`], and creates, opens, renames and removes what lies below it through +//! [`Dir`] handles, each checked against its rule, never by resolving a path again. +//! +//! See [`INTENT.md`] for what private-fs guarantees and why. +//! +//! [`INTENT.md`]: https://github.com/Devolutions/devolutions-gateway/blob/master/crates/private-fs/INTENT.md +mod error; +mod rule; +#[cfg(unix)] +mod unix; +#[cfg(windows)] +mod windows; + +#[cfg(target_os = "linux")] +mod linux; #[cfg(target_os = "macos")] mod macos; -use std::fs::{self, File}; -use std::io::{self, ErrorKind}; -use std::os::unix::fs::DirBuilderExt as _; +use std::ffi::OsString; +use std::fs::File; use camino::{Utf8Path, Utf8PathBuf}; -#[cfg(target_os = "macos")] -pub use self::macos::{ExtendedAclError, has_extended_acl, volume_ignores_ownership}; +pub use self::error::{Error, ErrorKind, Refusal}; +pub use self::rule::{Access, Account, Fallback, Rule}; +#[cfg(unix)] +use self::unix as sys; +#[cfg(windows)] +use self::windows as sys; +#[cfg(windows)] +pub use self::windows::BackupPrivilege; -/// Returns the process's effective user ID. -pub fn effective_uid() -> libc::uid_t { - // SAFETY: Reading the process's effective UID has no preconditions. - unsafe { libc::geteuid() } +pub type Result = std::result::Result; + +/// An open directory, checked against a rule, through which the files and directories below it are created and opened. +#[derive(Debug)] +pub struct Dir { + inner: sys::Dir, + path: Utf8PathBuf, } -/// Flushes the entries of `dir` to disk. -pub fn sync_directory(dir: &Utf8Path) -> io::Result<()> { - File::open(dir).and_then(|file| file.sync_all()) +/// What [`Dir::create_dir`] does with a directory that already exists. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Existing { + /// The directory counts as created only if it passes the rule. + Check, + /// The directory is brought into line with the rule. + Repair, } -/// The step of [`create_dir_chain`] that failed, and the path it failed on. -#[derive(Debug)] -pub enum DirChainError { - /// The path exists and is not a directory; a link to a directory is not one. - NotADirectory(Utf8PathBuf), - Inspect(Utf8PathBuf, io::Error), - Create(Utf8PathBuf, io::Error), - Sync(Utf8PathBuf, io::Error), +/// Whether a rename may replace an existing entry. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Replace { + Allow, + Refuse, } -/// Creates `dir` and its missing ancestors with mode `0700`, and syncs each directory it creates and that directory's -/// parent. +/// Whether other handles may be open on a file or directory while this one is. /// -/// The first existing path on the chain and each directory created must be a directory and not a link; ancestors above -/// the first existing one are not inspected. -/// A directory that another process creates first counts as created. -pub fn create_dir_chain(dir: &Utf8Path) -> Result<(), DirChainError> { - let mut missing = Vec::new(); - let mut path = dir; - while !directory_exists(path)? { - missing.push(path); - path = parent_or_current(path); - } - let mut builder = fs::DirBuilder::new(); - builder.mode(0o700); - for &path in missing.iter().rev() { - // UNTESTED: a directory that another process creates between the check and `create` counts as created. - // Processes that create the same ancestors at once would otherwise fail at random; no test runs such a race. - // When changing this, keep accepting `AlreadyExists` for a path that is now a directory, and only then. - if let Err(error) = builder.create(path) - && (error.kind() != ErrorKind::AlreadyExists || !directory_exists(path)?) - { - return Err(DirChainError::Create(path.to_owned(), error)); - } - // UNTESTED: each created directory and its parent are synced before the next one is created. - // Without it, a power loss can drop a directory whose files a caller already reported as written; no test can cut power. - // When changing this loop, keep syncing both before creating the next directory. - for synced in [path, parent_or_current(path)] { - sync_directory(synced).map_err(|error| DirChainError::Sync(synced.to_owned(), error))?; +/// Linux and macOS have no such sharing control, and ignore it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Share { + Shared, + Exclusive, +} + +/// How [`Dir::open_file`] and [`Dir::create_file`] open a file. +/// +/// A file opened with neither read nor write is open only for checking its owner and access. +#[derive(Clone, Copy, Debug)] +pub struct FileOptions { + read: bool, + write: bool, + share: Share, +} + +/// An entry of a directory, as [`Dir::entries`] lists it. +#[derive(Clone, Debug)] +pub struct Entry { + pub name: OsString, + pub kind: EntryKind, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EntryKind { + File, + Directory, + /// A symbolic link or another reparse point. + Link, + Other, +} + +impl FileOptions { + pub fn new() -> Self { + Self { + read: false, + write: false, + share: Share::Shared, } } - Ok(()) + + #[must_use] + pub fn read(mut self, read: bool) -> Self { + self.read = read; + self + } + + #[must_use] + pub fn write(mut self, write: bool) -> Self { + self.write = write; + self + } + + #[must_use] + pub fn share(mut self, share: Share) -> Self { + self.share = share; + self + } } -fn directory_exists(path: &Utf8Path) -> Result { - match fs::symlink_metadata(path) { - Ok(metadata) if metadata.is_dir() => Ok(true), - Ok(_) => Err(DirChainError::NotADirectory(path.to_owned())), - Err(error) if error.kind() == ErrorKind::NotFound => Ok(false), - Err(error) => Err(DirChainError::Inspect(path.to_owned(), error)), +impl Default for FileOptions { + fn default() -> Self { + Self::new() } } -fn parent_or_current(path: &Utf8Path) -> &Utf8Path { - path.parent() - .filter(|parent| !parent.as_str().is_empty()) - .unwrap_or_else(|| Utf8Path::new(".")) +impl Dir { + /// Opens the directory at `path`, creating it and its missing parents under `rule`. + /// + /// The first directory of `path` that exists, and every directory above it on its resolved path up to the root, + /// must be owned by the system account, administrators or an account of `rule`, and no other account may be able + /// to rename or replace them; being able to add an entry beside one does not count. + /// That first directory and everything below it must not be a link. + /// Each directory created has exactly `rule` from the moment it exists, and one that another process creates first + /// counts as created only if it passes `rule`. + pub fn create_base(path: &Utf8Path, rule: &Rule) -> Result { + sys::create_base(path, rule).map(|inner| Self { + inner, + path: path.to_owned(), + }) + } + + /// Opens the existing directory at `path`, with the checks of [`Dir::create_base`] on it and the directories above it, + /// and returns `None` when it doesn't exist. + pub fn open_base(path: &Utf8Path, rule: &Rule) -> Result> { + Ok(sys::open_base(path, rule)?.map(|inner| Self { + inner, + path: path.to_owned(), + })) + } + + pub fn path(&self) -> &Utf8Path { + &self.path + } + + /// Creates the directory `name` with exactly `rule` from the moment it exists, and makes its entry durable. + pub fn create_dir(&self, name: &str, rule: &Rule, existing: Existing) -> Result { + let path = self.child(name)?; + sys::create_dir(&self.inner, &path, name, rule, existing).map(|inner| Self { inner, path }) + } + + /// Opens the existing directory `name` once it passes `rule`, and returns `None` when it doesn't exist. + pub fn open_dir(&self, name: &str, rule: &Rule, share: Share) -> Result> { + let path = self.child(name)?; + Ok(sys::open_dir(&self.inner, &path, name, rule, share)?.map(|inner| Self { inner, path })) + } + + /// Opens the existing file `name` once it passes `rule`, and returns `None` when it doesn't exist. + /// + /// A file with more than one hard link fails the check. + pub fn open_file(&self, name: &str, rule: &Rule, options: FileOptions) -> Result> { + let path = self.child(name)?; + sys::open_file(&self.inner, &path, name, rule, options) + } + + /// Creates the file `name`, which must not exist, with exactly `rule` from the moment it exists. + /// + /// The file is open for writing whatever `options` says. + /// Its entry is durable only once [`Dir::persist`] or [`Dir::sync`] returns. + pub fn create_file(&self, name: &str, rule: &Rule, options: FileOptions) -> Result { + let path = self.child(name)?; + sys::create_file(&self.inner, &path, name, rule, options.write(true)) + } + + /// Opens the file `name` once it passes `rule`, or creates it as [`Dir::create_file`] does when it doesn't exist. + pub fn open_or_create_file(&self, name: &str, rule: &Rule, options: FileOptions) -> Result { + loop { + if let Some(file) = self.open_file(name, rule, options)? { + return Ok(file); + } + match self.create_file(name, rule, options) { + Err(error) if error.already_exists() => continue, + created => return created, + } + } + } + + /// Renames the entry `from` to `to`, both in this directory. + pub fn rename(&self, from: &str, to: &str, replace: Replace) -> Result<()> { + let path = self.child(from)?; + self.child(to)?; + sys::rename(&self.inner, &path, from, to, replace) + } + + /// Makes `file`, written under the name `from` in this directory, durable under the name `to`. + /// + /// Once this returns, a power cut leaves `to` with the content of `file`. + pub fn persist(&self, file: &File, from: &str, to: &str, replace: Replace) -> Result<()> { + let path = self.child(from)?; + self.child(to)?; + sys::persist(&self.inner, &path, file, from, to, replace) + } + + /// Removes the file `name`, and returns `false` when it doesn't exist. + pub fn remove_file(&self, name: &str) -> Result { + let path = self.child(name)?; + sys::remove_file(&self.inner, &path, name) + } + + /// Lists the entries of this directory, without `.` and `..`. + pub fn entries(&self) -> Result> { + sys::entries(&self.inner, &self.path) + } + + /// Makes the entries created, renamed and removed in this directory durable. + pub fn sync(&self) -> Result<()> { + sys::sync(&self.inner, &self.path) + } + + /// Checks this directory against `rule`. + pub fn check(&self, rule: &Rule) -> Result<()> { + sys::check_dir(&self.inner, &self.path, rule) + } + + /// Brings this directory into line with `rule`, without changing anything below it. + pub fn repair(&self, rule: &Rule) -> Result<()> { + sys::repair_dir(&self.inner, &self.path, rule) + } + + fn child(&self, name: &str) -> Result { + let path = self.path.join(name); + if name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\']) || name.contains('\0') { + return Err(Error::io( + &path, + "use entry name", + std::io::Error::from(std::io::ErrorKind::InvalidInput), + )); + } + Ok(path) + } +} + +/// Checks an open file, at `path`, against `rule`. +pub fn check_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { + sys::check_file(file, path, rule) +} + +impl Error { + fn already_exists(&self) -> bool { + matches!(self.kind(), ErrorKind::Io { source, .. } if source.kind() == std::io::ErrorKind::AlreadyExists) + } } diff --git a/crates/private-fs/src/linux.rs b/crates/private-fs/src/linux.rs new file mode 100644 index 0000000000..763366e849 --- /dev/null +++ b/crates/private-fs/src/linux.rs @@ -0,0 +1 @@ +//! Linux ACLs and volumes. diff --git a/crates/private-fs/src/rule.rs b/crates/private-fs/src/rule.rs new file mode 100644 index 0000000000..535afdf7fc --- /dev/null +++ b/crates/private-fs/src/rule.rs @@ -0,0 +1,125 @@ +/// An account a rule names. +/// +/// | Account | Linux and macOS | Windows | +/// |---|---|---| +/// | [`Account::CurrentUser`] | the effective user ID | the user of the process token | +/// | [`Account::System`] | root | LocalSystem | +/// | [`Account::Administrators`] | root | BUILTIN\Administrators; as an owner, also any member of it | +/// +/// On Linux and macOS, root can access everything whatever the rule says, so a grant to [`Account::System`] or +/// [`Account::Administrators`] needs nothing on disk there. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Account { + CurrentUser, + System, + Administrators, +} + +/// A kind of access. +/// +/// | Access | Linux and macOS | Windows | +/// |---|---|---| +/// | [`Access::Full`] | supported | supported | +/// | [`Access::ReadWrite`] | supported for root only | supported | +/// | [`Access::AddOnly`] | supported for root only | supported on directories | +/// +/// The owner of a file or directory on Linux and macOS can always change its mode, so it always has full access. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + /// Every operation, including changing the owner or who has access. + Full, + /// Reading, writing and deleting, but not changing the owner or who has access. + ReadWrite, + /// Adding entries to a directory, without listing, reading or removing them. + AddOnly, +} + +/// What a rule uses instead of an access that a platform cannot enforce. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Fallback { + Access(Access), + NoAccess, +} + +/// Which accounts may own a file or directory, and which accounts may access it with which kind of access. +/// +/// A rule is written once for every platform. +/// Where a platform cannot enforce a grant, the grant's fallback applies; without one, every operation using the rule +/// fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), because private-fs never chooses a broader +/// access on its own. +#[derive(Clone, Debug, Default)] +pub struct Rule { + pub(crate) owners: Vec, + pub(crate) grants: Vec, + pub(crate) owner_fallback: Option, + pub(crate) exact: bool, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Grant { + pub(crate) account: Account, + pub(crate) access: Access, + pub(crate) fallback: Option, +} + +impl Rule { + pub fn new() -> Self { + Self::default() + } + + /// Allows `account` to own what the rule covers. + /// + /// private-fs creates files and directories owned by [`Account::CurrentUser`], so a rule used to create must allow it. + #[must_use] + pub fn owner(mut self, account: Account) -> Self { + self.owners.push(account); + self + } + + /// Gives `account` `access`. + #[must_use] + pub fn grant(mut self, account: Account, access: Access) -> Self { + self.grants.push(Grant { + account, + access, + fallback: None, + }); + self + } + + /// Gives `account` `access`, or `fallback` where the platform does not support `access` for `account`. + #[must_use] + pub fn grant_or(mut self, account: Account, access: Access, fallback: Fallback) -> Self { + self.grants.push(Grant { + account, + access, + fallback: Some(fallback), + }); + self + } + + /// Sets what an owner that the rule gives less than full access keeps where the platform cannot take an owner's + /// access away, as on Linux and macOS. + #[must_use] + pub fn owner_fallback(mut self, fallback: Fallback) -> Self { + self.owner_fallback = Some(fallback); + self + } + + /// Makes a check also fail when a file or directory gives an account less than the rule does. + /// + /// By default a check fails only when a file or directory gives an account more than the rule does. + #[must_use] + pub fn exact(mut self) -> Self { + self.exact = true; + self + } + + /// Returns every account the rule names, as an owner or in a grant. + pub(crate) fn accounts(&self) -> impl Iterator + '_ { + self.owners + .iter() + .copied() + .chain(self.grants.iter().map(|grant| grant.account)) + } +} diff --git a/crates/private-fs/src/unix.rs b/crates/private-fs/src/unix.rs new file mode 100644 index 0000000000..fa0d7e79d9 --- /dev/null +++ b/crates/private-fs/src/unix.rs @@ -0,0 +1,85 @@ +//! Backend contract, implemented per platform. + +use std::fs::File; + +use camino::Utf8Path; + +use crate::{Entry, Existing, FileOptions, Replace, Result, Rule, Share}; + +#[derive(Debug)] +pub(crate) struct Dir; + +pub(crate) fn create_base(path: &Utf8Path, rule: &Rule) -> Result { + todo!() +} + +pub(crate) fn open_base(path: &Utf8Path, rule: &Rule) -> Result> { + todo!() +} + +pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, existing: Existing) -> Result { + todo!() +} + +pub(crate) fn open_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, share: Share) -> Result> { + todo!() +} + +pub(crate) fn open_file( + parent: &Dir, + path: &Utf8Path, + name: &str, + rule: &Rule, + options: FileOptions, +) -> Result> { + todo!() +} + +pub(crate) fn create_file( + parent: &Dir, + path: &Utf8Path, + name: &str, + rule: &Rule, + options: FileOptions, +) -> Result { + todo!() +} + +pub(crate) fn rename(parent: &Dir, path: &Utf8Path, from: &str, to: &str, replace: Replace) -> Result<()> { + todo!() +} + +pub(crate) fn persist( + parent: &Dir, + path: &Utf8Path, + file: &File, + from: &str, + to: &str, + replace: Replace, +) -> Result<()> { + todo!() +} + +pub(crate) fn remove_file(parent: &Dir, path: &Utf8Path, name: &str) -> Result { + todo!() +} + +pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { + todo!() +} + +pub(crate) fn sync(dir: &Dir, path: &Utf8Path) -> Result<()> { + todo!() +} + +pub(crate) fn check_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { + todo!() +} + +pub(crate) fn repair_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { + todo!() +} + +pub(crate) fn check_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { + todo!() +} diff --git a/crates/private-fs/src/windows.rs b/crates/private-fs/src/windows.rs new file mode 100644 index 0000000000..5852662660 --- /dev/null +++ b/crates/private-fs/src/windows.rs @@ -0,0 +1,96 @@ +//! Backend contract, implemented per platform. + +use std::fs::File; + +use camino::Utf8Path; + +use crate::{Entry, Existing, FileOptions, Replace, Result, Rule, Share}; + +#[derive(Debug)] +pub(crate) struct Dir; + +pub(crate) fn create_base(path: &Utf8Path, rule: &Rule) -> Result { + todo!() +} + +pub(crate) fn open_base(path: &Utf8Path, rule: &Rule) -> Result> { + todo!() +} + +pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, existing: Existing) -> Result { + todo!() +} + +pub(crate) fn open_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, share: Share) -> Result> { + todo!() +} + +pub(crate) fn open_file( + parent: &Dir, + path: &Utf8Path, + name: &str, + rule: &Rule, + options: FileOptions, +) -> Result> { + todo!() +} + +pub(crate) fn create_file( + parent: &Dir, + path: &Utf8Path, + name: &str, + rule: &Rule, + options: FileOptions, +) -> Result { + todo!() +} + +pub(crate) fn rename(parent: &Dir, path: &Utf8Path, from: &str, to: &str, replace: Replace) -> Result<()> { + todo!() +} + +pub(crate) fn persist( + parent: &Dir, + path: &Utf8Path, + file: &File, + from: &str, + to: &str, + replace: Replace, +) -> Result<()> { + todo!() +} + +pub(crate) fn remove_file(parent: &Dir, path: &Utf8Path, name: &str) -> Result { + todo!() +} + +pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { + todo!() +} + +pub(crate) fn sync(dir: &Dir, path: &Utf8Path) -> Result<()> { + todo!() +} + +pub(crate) fn check_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { + todo!() +} + +pub(crate) fn repair_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { + todo!() +} + +pub(crate) fn check_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { + todo!() +} + +/// Enables the backup privilege for the current thread until dropped, so that private-fs can read the owner and access +/// of directories the process token cannot read otherwise. +#[derive(Debug)] +pub struct BackupPrivilege; + +impl BackupPrivilege { + pub fn enable() -> Result { + todo!() + } +} diff --git a/testsuite/Cargo.toml b/testsuite/Cargo.toml index 2204d5d18d..25c9b1c378 100644 --- a/testsuite/Cargo.toml +++ b/testsuite/Cargo.toml @@ -49,6 +49,7 @@ mcp-proxy.path = "../crates/mcp-proxy" network-scanner = { path = "../crates/network-scanner", features = ["test-utils"] } network-scanner-proto = { path = "../crates/network-scanner-proto" } nonempty = "0.12" +private-fs.path = "../crates/private-fs" picky = { version = "7.0.0-rc.25", default-features = false, features = ["jose"] } proxy-socks = { path = "../crates/proxy-socks" } quinn = "0.11" diff --git a/testsuite/tests/main.rs b/testsuite/tests/main.rs index 00eb74ac1c..be4e65b128 100644 --- a/testsuite/tests/main.rs +++ b/testsuite/tests/main.rs @@ -7,6 +7,7 @@ mod agent_tunnel; mod cli; mod mcp_proxy; mod network_scanner; +mod private_fs; #[cfg(windows)] mod service_accounts; mod sysevent; diff --git a/testsuite/tests/private_fs/mod.rs b/testsuite/tests/private_fs/mod.rs new file mode 100644 index 0000000000..bb03382476 --- /dev/null +++ b/testsuite/tests/private_fs/mod.rs @@ -0,0 +1,6 @@ +//! Tests of private-fs through its public API. + +#[cfg(unix)] +mod unix; +#[cfg(windows)] +mod windows; diff --git a/testsuite/tests/private_fs/unix.rs b/testsuite/tests/private_fs/unix.rs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs new file mode 100644 index 0000000000..e69de29bb2 From 071e150e93fbdaa63ae8b99cf03bb8e8fad2652b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 16:04:15 +0900 Subject: [PATCH 04/60] feat(private-fs): implement the Windows backend Files and directories are created and opened with NtCreateFile relative to a checked directory handle, never by path, and a new one carries its owner and protected DACL in its creation security descriptor, so it has its rule from the moment it exists. Checks compare the owner and DACL with the rule, narrower or exact, and refuse links, hard links and volumes without persistent ACLs; an OWNER RIGHTS entry takes away the implicit access of an owner the rule gives less than full access. A base directory is opened only when the directories above it, on its resolved path, cannot be renamed or replaced by an account other than the system accounts, administrators and the accounts of the rule. A repair sets the DACL with SetKernelObjectSecurity, which leaves the children's DACLs untouched. Errors carry the Win32 error code an NTSTATUS maps to, so callers can match a sharing violation or an existing entry as they do for Win32 calls. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 + crates/private-fs/Cargo.toml | 14 + crates/private-fs/src/windows.rs | 96 --- crates/private-fs/src/windows/mod.rs | 668 +++++++++++++++++ crates/private-fs/src/windows/nt.rs | 334 +++++++++ crates/private-fs/src/windows/security.rs | 867 ++++++++++++++++++++++ testsuite/tests/private_fs/windows.rs | 678 +++++++++++++++++ 7 files changed, 2564 insertions(+), 96 deletions(-) delete mode 100644 crates/private-fs/src/windows.rs create mode 100644 crates/private-fs/src/windows/mod.rs create mode 100644 crates/private-fs/src/windows/nt.rs create mode 100644 crates/private-fs/src/windows/security.rs diff --git a/Cargo.lock b/Cargo.lock index e1607cc280..dd69ba2bdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6178,6 +6178,8 @@ version = "0.0.0" dependencies = [ "camino", "libc", + "win-api-wrappers", + "windows 0.61.3", ] [[package]] @@ -8135,6 +8137,7 @@ dependencies = [ "network-scanner-proto", "nonempty", "picky", + "private-fs", "proxy-socks", "quinn", "rcgen", diff --git a/crates/private-fs/Cargo.toml b/crates/private-fs/Cargo.toml index 627b94de56..d8f407be10 100644 --- a/crates/private-fs/Cargo.toml +++ b/crates/private-fs/Cargo.toml @@ -14,3 +14,17 @@ camino = "1" [target.'cfg(unix)'.dependencies] libc = "0.2" + +[target.'cfg(windows)'.dependencies] +win-api-wrappers = { path = "../win-api-wrappers" } +windows = { version = "0.61", features = [ + "Wdk_Foundation", + "Wdk_Storage_FileSystem", + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_SystemServices", + "Win32_System_Threading", +] } diff --git a/crates/private-fs/src/windows.rs b/crates/private-fs/src/windows.rs deleted file mode 100644 index 5852662660..0000000000 --- a/crates/private-fs/src/windows.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! Backend contract, implemented per platform. - -use std::fs::File; - -use camino::Utf8Path; - -use crate::{Entry, Existing, FileOptions, Replace, Result, Rule, Share}; - -#[derive(Debug)] -pub(crate) struct Dir; - -pub(crate) fn create_base(path: &Utf8Path, rule: &Rule) -> Result { - todo!() -} - -pub(crate) fn open_base(path: &Utf8Path, rule: &Rule) -> Result> { - todo!() -} - -pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, existing: Existing) -> Result { - todo!() -} - -pub(crate) fn open_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, share: Share) -> Result> { - todo!() -} - -pub(crate) fn open_file( - parent: &Dir, - path: &Utf8Path, - name: &str, - rule: &Rule, - options: FileOptions, -) -> Result> { - todo!() -} - -pub(crate) fn create_file( - parent: &Dir, - path: &Utf8Path, - name: &str, - rule: &Rule, - options: FileOptions, -) -> Result { - todo!() -} - -pub(crate) fn rename(parent: &Dir, path: &Utf8Path, from: &str, to: &str, replace: Replace) -> Result<()> { - todo!() -} - -pub(crate) fn persist( - parent: &Dir, - path: &Utf8Path, - file: &File, - from: &str, - to: &str, - replace: Replace, -) -> Result<()> { - todo!() -} - -pub(crate) fn remove_file(parent: &Dir, path: &Utf8Path, name: &str) -> Result { - todo!() -} - -pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { - todo!() -} - -pub(crate) fn sync(dir: &Dir, path: &Utf8Path) -> Result<()> { - todo!() -} - -pub(crate) fn check_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { - todo!() -} - -pub(crate) fn repair_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { - todo!() -} - -pub(crate) fn check_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { - todo!() -} - -/// Enables the backup privilege for the current thread until dropped, so that private-fs can read the owner and access -/// of directories the process token cannot read otherwise. -#[derive(Debug)] -pub struct BackupPrivilege; - -impl BackupPrivilege { - pub fn enable() -> Result { - todo!() - } -} diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs new file mode 100644 index 0000000000..a1a8af5df9 --- /dev/null +++ b/crates/private-fs/src/windows/mod.rs @@ -0,0 +1,668 @@ +//! Backend contract, implemented for Windows. +//! +//! Files and directories are opened and created with `NtCreateFile` relative to a directory handle, so no path is +//! resolved again below a checked directory, and created with a security descriptor holding the owner and the protected +//! DACL of their rule, so they have it from the moment they exist. + +mod nt; +mod security; + +use std::fs::{File, OpenOptions}; +use std::io; +use std::marker::PhantomData; +use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::os::windows::io::{AsHandle as _, BorrowedHandle, FromRawHandle as _, OwnedHandle}; + +use camino::{Utf8Path, Utf8PathBuf}; +use windows::Wdk::Storage::FileSystem::{ + FILE_CREATE, FILE_DIRECTORY_FILE, FILE_NON_DIRECTORY_FILE, FILE_OPEN, FILE_OPEN_FOR_BACKUP_INTENT, + FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT, NTCREATEFILE_CREATE_OPTIONS, +}; +use windows::Win32::Foundation::{ERROR_NO_TOKEN, ERROR_NOT_ALL_ASSIGNED, HANDLE, LUID}; +use windows::Win32::Security::{ + AdjustTokenPrivileges, DACL_SECURITY_INFORMATION, DuplicateTokenEx, LUID_AND_ATTRIBUTES, LookupPrivilegeValueW, + OWNER_SECURITY_INFORMATION, RevertToSelf, SE_BACKUP_NAME, SE_PRIVILEGE_ENABLED, SecurityImpersonation, + SetKernelObjectSecurity, TOKEN_ADJUST_PRIVILEGES, TOKEN_DUPLICATE, TOKEN_IMPERSONATE, TOKEN_PRIVILEGES, + TOKEN_QUERY, TokenImpersonation, +}; +use windows::Win32::Storage::FileSystem::{ + DELETE, FILE_ACCESS_RIGHTS, FILE_ADD_FILE, FILE_ATTRIBUTE_DEVICE, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, + FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAGS_AND_ATTRIBUTES, + FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, + FILE_SHARE_MODE, FILE_SHARE_NONE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, READ_CONTROL, SYNCHRONIZE, + WRITE_DAC, WRITE_OWNER, +}; +use windows::Win32::System::Threading::{ + GetCurrentProcess, GetCurrentThread, OpenProcessToken, OpenThreadToken, SetThreadToken, +}; + +use self::nt::{Request, raw}; +use self::security::{Descriptor, Kind, OwnerAndDacl, Policy, administrators_member}; +use crate::{Entry, EntryKind, Error, Existing, FileOptions, Refusal, Replace, Result, Rule, Share}; + +/// The access to check an item: its attributes and its owner and DACL. +const CHECK_ACCESS: FILE_ACCESS_RIGHTS = FILE_ACCESS_RIGHTS(FILE_READ_ATTRIBUTES.0 | READ_CONTROL.0 | SYNCHRONIZE.0); + +/// The access a directory handle has: checking it, listing it and opening what it holds. +const DIRECTORY_ACCESS: FILE_ACCESS_RIGHTS = + FILE_ACCESS_RIGHTS(CHECK_ACCESS.0 | FILE_LIST_DIRECTORY.0 | FILE_TRAVERSE.0); + +/// The options of every open: never follow a reparse point, let the backup privilege apply, and give std a +/// synchronous handle. +const OPEN_OPTIONS: NTCREATEFILE_CREATE_OPTIONS = NTCREATEFILE_CREATE_OPTIONS( + FILE_OPEN_REPARSE_POINT.0 | FILE_OPEN_FOR_BACKUP_INTENT.0 | FILE_SYNCHRONOUS_IO_NONALERT.0, +); + +const SHARE_ALL: FILE_SHARE_MODE = FILE_SHARE_MODE(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0 | FILE_SHARE_DELETE.0); + +#[derive(Debug)] +pub(crate) struct Dir { + handle: OwnedHandle, +} + +impl Dir { + fn handle(&self) -> BorrowedHandle<'_> { + self.handle.as_handle() + } +} + +pub(crate) fn create_base(path: &Utf8Path, rule: &Rule) -> Result { + let policy = creation_policy(rule, Kind::Directory, path)?; + let (existing, missing) = first_existing(path)?; + let mut dir = open_first_existing(&existing, &policy)?; + let mut current = existing; + for name in missing { + current.push(&name); + dir = create_dir(&dir, ¤t, &name, rule, Existing::Check)?; + } + Ok(dir) +} + +pub(crate) fn open_base(path: &Utf8Path, rule: &Rule) -> Result> { + let policy = Policy::new(rule, Kind::Directory, path)?; + let (existing, missing) = first_existing(path)?; + let mut dir = open_first_existing(&existing, &policy)?; + let mut current = existing; + for name in missing { + current.push(&name); + validate_name(¤t, &name)?; + let Some(handle) = open_entry( + dir.handle(), + ¤t, + &name, + DIRECTORY_ACCESS, + SHARE_ALL, + "open directory", + )? + else { + return Ok(None); + }; + // Below the first existing directory, a directory is checked as the first one is, not against the rule. + classify(handle.as_handle(), ¤t, Kind::Directory)?; + let security = OwnerAndDacl::of_handle(handle.as_handle()) + .map_err(|error| Error::refused(¤t, Refusal::Unreadable(error)))?; + policy.check_ancestor(&security, ¤t, false, &administrators_member)?; + dir = Dir { handle }; + } + Ok(Some(dir)) +} + +pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, existing: Existing) -> Result { + validate_name(path, name)?; + let policy = creation_policy(rule, Kind::Directory, path)?; + let descriptor = Descriptor::new(&policy.accounts.user, &policy.dacl(&policy.accounts.user)) + .map_err(|error| Error::io(path, "build security descriptor", error))?; + let request = Request { + // FILE_ADD_FILE lets the new directory's handle be flushed. + access: FILE_ACCESS_RIGHTS(DIRECTORY_ACCESS.0 | FILE_ADD_FILE.0), + share: SHARE_ALL, + disposition: FILE_CREATE, + options: NTCREATEFILE_CREATE_OPTIONS(OPEN_OPTIONS.0 | FILE_DIRECTORY_FILE.0), + attributes: FILE_FLAGS_AND_ATTRIBUTES(0), + descriptor: Some(descriptor.as_ref()), + }; + match nt::create(parent.handle(), name, &request) { + Ok(handle) => { + check_item(handle.as_handle(), path, Kind::Directory, &policy)?; + // UNTESTED: once this returns, a power cut leaves the new directory's entry in its parent. + // No test can cut the power; NTFS logs the entry's creation, and flushing the new directory commits the + // log up to it. + // When changing this, keep a flush of a handle on the new directory after its creation. + nt::flush(handle.as_handle()).map_err(|error| Error::io(path, "flush directory", error))?; + Ok(Dir { handle }) + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + let handle = open_entry( + parent.handle(), + path, + name, + DIRECTORY_ACCESS, + SHARE_ALL, + "open directory", + )? + .ok_or_else(|| Error::io(path, "open directory", io::Error::from(io::ErrorKind::NotFound)))?; + let dir = Dir { handle }; + match existing { + Existing::Check => check_dir(&dir, path, rule)?, + Existing::Repair => repair_dir(&dir, path, rule)?, + } + Ok(dir) + } + Err(error) => Err(Error::io(path, "create directory", error)), + } +} + +pub(crate) fn open_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, share: Share) -> Result> { + validate_name(path, name)?; + let policy = Policy::new(rule, Kind::Directory, path)?; + let Some(handle) = open_entry( + parent.handle(), + path, + name, + DIRECTORY_ACCESS, + share_mode(share), + "open directory", + )? + else { + return Ok(None); + }; + check_item(handle.as_handle(), path, Kind::Directory, &policy)?; + Ok(Some(Dir { handle })) +} + +pub(crate) fn open_file( + parent: &Dir, + path: &Utf8Path, + name: &str, + rule: &Rule, + options: FileOptions, +) -> Result> { + validate_name(path, name)?; + let policy = Policy::new(rule, Kind::File, path)?; + let Some(handle) = open_entry( + parent.handle(), + path, + name, + file_access(options), + share_mode(options.share), + "open file", + )? + else { + return Ok(None); + }; + check_item(handle.as_handle(), path, Kind::File, &policy)?; + Ok(Some(File::from(handle))) +} + +pub(crate) fn create_file( + parent: &Dir, + path: &Utf8Path, + name: &str, + rule: &Rule, + options: FileOptions, +) -> Result { + validate_name(path, name)?; + let policy = creation_policy(rule, Kind::File, path)?; + let descriptor = Descriptor::new(&policy.accounts.user, &policy.dacl(&policy.accounts.user)) + .map_err(|error| Error::io(path, "build security descriptor", error))?; + let request = Request { + // DELETE lets `persist` rename the file through this handle. + access: FILE_ACCESS_RIGHTS(file_access(options).0 | DELETE.0 | SYNCHRONIZE.0), + share: share_mode(options.share), + disposition: FILE_CREATE, + options: NTCREATEFILE_CREATE_OPTIONS( + FILE_NON_DIRECTORY_FILE.0 | FILE_OPEN_REPARSE_POINT.0 | FILE_SYNCHRONOUS_IO_NONALERT.0, + ), + attributes: FILE_ATTRIBUTE_NORMAL, + descriptor: Some(descriptor.as_ref()), + }; + let handle = nt::create(parent.handle(), name, &request).map_err(|error| Error::io(path, "create file", error))?; + if let Err(error) = check_item(handle.as_handle(), path, Kind::File, &policy) { + // A file that fails its rule, as on a volume that drops DACLs, is not left behind. + let _ = nt::delete_on_close(handle.as_handle()); + return Err(error); + } + Ok(File::from(handle)) +} + +pub(crate) fn rename(parent: &Dir, path: &Utf8Path, from: &str, to: &str, replace: Replace) -> Result<()> { + validate_name(path, from)?; + validate_name(path, to)?; + let source = open_entry( + parent.handle(), + path, + from, + FILE_ACCESS_RIGHTS(DELETE.0 | SYNCHRONIZE.0), + SHARE_ALL, + "open entry", + )? + .ok_or_else(|| Error::io(path, "open entry", io::Error::from(io::ErrorKind::NotFound)))?; + nt::rename(source.as_handle(), parent.handle(), to, replace == Replace::Allow) + .map_err(|error| Error::io(path, "rename entry", error)) +} + +pub(crate) fn persist( + parent: &Dir, + path: &Utf8Path, + file: &File, + from: &str, + to: &str, + replace: Replace, +) -> Result<()> { + validate_name(path, from)?; + validate_name(path, to)?; + file.sync_all().map_err(|error| Error::io(path, "sync file", error))?; + nt::rename(file.as_handle(), parent.handle(), to, replace == Replace::Allow) + .map_err(|error| Error::io(path, "rename file", error))?; + // UNTESTED: once this returns, a power cut leaves `to` with the file's content. + // No test can cut the power; Windows has no directory fsync, but NTFS logs the rename, and flushing the renamed + // file commits the log up to that record. + // When changing this, keep the flush of the file after the rename. + nt::flush(file.as_handle()).map_err(|error| Error::io(path, "flush file", error)) +} + +pub(crate) fn remove_file(parent: &Dir, path: &Utf8Path, name: &str) -> Result { + validate_name(path, name)?; + let request = Request { + access: FILE_ACCESS_RIGHTS(DELETE.0 | SYNCHRONIZE.0), + share: SHARE_ALL, + disposition: FILE_OPEN, + options: NTCREATEFILE_CREATE_OPTIONS( + FILE_NON_DIRECTORY_FILE.0 | FILE_OPEN_REPARSE_POINT.0 | FILE_SYNCHRONOUS_IO_NONALERT.0, + ), + attributes: FILE_FLAGS_AND_ATTRIBUTES(0), + descriptor: None, + }; + let handle = match nt::create(parent.handle(), name, &request) { + Ok(handle) => handle, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(Error::io(path, "open file", error)), + }; + nt::delete_on_close(handle.as_handle()).map_err(|error| Error::io(path, "remove file", error))?; + Ok(true) +} + +/// Removes `file`, which this directory's handle created as `name`, through the file's own handle. +#[expect(dead_code, reason = "lib.rs exposes it as Dir::discard")] +pub(crate) fn discard(_parent: &Dir, path: &Utf8Path, file: &File, _name: &str) -> Result<()> { + nt::delete_on_close(file.as_handle()).map_err(|error| Error::io(path, "remove file", error)) +} + +pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { + let listed = nt::list(dir.handle()).map_err(|error| Error::io(path, "list directory", error))?; + Ok(listed + .into_iter() + .map(|(name, attributes)| Entry { + name, + kind: if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { + EntryKind::Link + } else if attributes & FILE_ATTRIBUTE_DIRECTORY.0 != 0 { + EntryKind::Directory + } else if attributes & FILE_ATTRIBUTE_DEVICE.0 != 0 { + EntryKind::Other + } else { + EntryKind::File + }, + }) + .collect()) +} + +/// Windows has no directory fsync: [`create_dir`] and [`persist`] flush what they change. +pub(crate) fn sync(_dir: &Dir, _path: &Utf8Path) -> Result<()> { + Ok(()) +} + +pub(crate) fn check_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { + let policy = Policy::new(rule, Kind::Directory, path)?; + check_item(dir.handle(), path, Kind::Directory, &policy) +} + +pub(crate) fn repair_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { + let policy = Policy::new(rule, Kind::Directory, path)?; + classify(dir.handle(), path, Kind::Directory)?; + let security = + OwnerAndDacl::of_handle(dir.handle()).map_err(|error| Error::io(path, "read access control list", error))?; + let current_owner = security.owner(); + let owner_allowed = match current_owner { + Some(owner) => policy.owner_allowed(owner, path, &administrators_member)?, + None => false, + }; + let owner = match current_owner { + Some(owner) if owner_allowed => owner.clone(), + // The repair would make the current user the owner, which the rule must then allow. + _ if policy.allows_user_owner() => policy.accounts.user.clone(), + _ => return Err(Error::refused(path, Refusal::Owner)), + }; + let descriptor = Descriptor::new(&owner, &policy.dacl(&owner)) + .map_err(|error| Error::io(path, "build security descriptor", error))?; + let mut access = READ_CONTROL.0 | WRITE_DAC.0 | FILE_READ_ATTRIBUTES.0 | SYNCHRONIZE.0; + let mut information = DACL_SECURITY_INFORMATION; + if !owner_allowed { + access |= WRITE_OWNER.0; + information |= OWNER_SECURITY_INFORMATION; + } + // An empty name relative to a handle opens the object that handle refers to. + let request = Request { + access: FILE_ACCESS_RIGHTS(access), + share: SHARE_ALL, + disposition: FILE_OPEN, + options: OPEN_OPTIONS, + attributes: FILE_FLAGS_AND_ATTRIBUTES(0), + descriptor: None, + }; + let reopened = + nt::create(dir.handle(), "", &request).map_err(|error| Error::io(path, "open directory for repair", error))?; + let original = nt::file_id(dir.handle()).map_err(|error| Error::io(path, "read file ID", error))?; + let current = nt::file_id(reopened.as_handle()).map_err(|error| Error::io(path, "read file ID", error))?; + if original != current { + return Err(Error::io( + path, + "open directory for repair", + io::Error::other("the directory was replaced while reopening it"), + )); + } + // Unlike SetSecurityInfo, SetKernelObjectSecurity doesn't propagate inheritable entries to existing children, so a + // repair never changes what lies below the directory. + // SAFETY: The handle is open and the descriptor stays live for the call. + unsafe { SetKernelObjectSecurity(raw(reopened.as_handle()), information, descriptor.as_raw()) } + .map_err(|error| Error::io(path, "set access control list", nt::win32_error(error)))?; + check_item(dir.handle(), path, Kind::Directory, &policy.exact()) +} + +pub(crate) fn check_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { + let policy = Policy::new(rule, Kind::File, path)?; + check_item(file.as_handle(), path, Kind::File, &policy) +} + +/// Enables the backup privilege for the current thread until dropped, so that private-fs can read the owner and access +/// of directories the process token cannot read otherwise. +/// +/// The current thread impersonates a copy of the process token with the privilege enabled, and goes back to its earlier +/// token when this is dropped, so it is tied to the thread that enabled it. +#[derive(Debug)] +pub struct BackupPrivilege { + /// The thread's token before this one, which it impersonates again on drop. + previous: Option, + _token: OwnedHandle, + /// Impersonation is a property of the thread, so this stays on the thread that created it. + _thread: PhantomData<*const ()>, +} + +impl BackupPrivilege { + pub fn enable() -> Result { + let path = Utf8Path::new(""); + let error = |step, error| Error::io(path, step, nt::win32_error(error)); + // SAFETY: Getting the pseudo-handle of the current thread has no preconditions. + let thread = unsafe { GetCurrentThread() }; + let mut previous = HANDLE::default(); + // SAFETY: The pseudo-handle of the current thread is always valid and the output is writable. + let previous = match unsafe { OpenThreadToken(thread, TOKEN_IMPERSONATE, true, &raw mut previous) } { + // SAFETY: OpenThreadToken succeeded, so the handle is open and nothing else owns it. + Ok(()) => Some(unsafe { OwnedHandle::from_raw_handle(previous.0) }), + Err(cause) if cause.code() == ERROR_NO_TOKEN.to_hresult() => None, + Err(cause) => return Err(error("open thread token", cause)), + }; + // SAFETY: Getting the pseudo-handle of the current process has no preconditions. + let current_process = unsafe { GetCurrentProcess() }; + let mut process = HANDLE::default(); + // SAFETY: The pseudo-handle of the current process is always valid and the output is writable. + unsafe { OpenProcessToken(current_process, TOKEN_DUPLICATE | TOKEN_QUERY, &raw mut process) } + .map_err(|cause| error("open process token", cause))?; + // SAFETY: OpenProcessToken succeeded, so the handle is open and nothing else owns it. + let process = unsafe { OwnedHandle::from_raw_handle(process.0) }; + let mut token = HANDLE::default(); + // SAFETY: The process token is open with TOKEN_DUPLICATE and the output is writable. + unsafe { + DuplicateTokenEx( + raw(process.as_handle()), + TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY | TOKEN_IMPERSONATE, + None, + SecurityImpersonation, + TokenImpersonation, + &raw mut token, + ) + } + .map_err(|cause| error("duplicate process token", cause))?; + // SAFETY: DuplicateTokenEx succeeded, so the handle is open and nothing else owns it. + let token = unsafe { OwnedHandle::from_raw_handle(token.0) }; + let mut luid = LUID::default(); + // SAFETY: The privilege name is a NUL-terminated constant and the output is writable. + unsafe { LookupPrivilegeValueW(None, SE_BACKUP_NAME, &raw mut luid) } + .map_err(|cause| error("look up backup privilege", cause))?; + let privileges = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + Privileges: [LUID_AND_ATTRIBUTES { + Luid: luid, + Attributes: SE_PRIVILEGE_ENABLED, + }], + }; + // SAFETY: The token is open with TOKEN_ADJUST_PRIVILEGES and the new state stays live for the call. + unsafe { + AdjustTokenPrivileges( + raw(token.as_handle()), + false, + Some(&raw const privileges), + 0, + None, + None, + ) + } + .map_err(|cause| error("enable backup privilege", cause))?; + // AdjustTokenPrivileges succeeds without enabling a privilege the token doesn't hold, and says so in the last + // error. + let last = io::Error::last_os_error(); + if last.raw_os_error() == Some(ERROR_NOT_ALL_ASSIGNED.0.cast_signed()) { + return Err(Error::io(path, "enable backup privilege", last)); + } + // SAFETY: The token is an impersonation token open with TOKEN_IMPERSONATE. + unsafe { SetThreadToken(None, Some(raw(token.as_handle()))) } + .map_err(|cause| error("impersonate backup token", cause))?; + Ok(Self { + previous, + _token: token, + _thread: PhantomData, + }) + } +} + +impl Drop for BackupPrivilege { + fn drop(&mut self) { + // A failure leaves the thread with the backup privilege, and there is no caller to report it to. + match &self.previous { + Some(previous) => { + // SAFETY: The earlier token is open with TOKEN_IMPERSONATE. + let _ = unsafe { SetThreadToken(None, Some(raw(previous.as_handle()))) }; + } + None => { + // SAFETY: Reverting the current thread to the process token has no preconditions. + let _ = unsafe { RevertToSelf() }; + } + } + } +} + +/// Maps a rule for creating a directory or file: the rule must allow the current user to own it, since creation makes +/// the current user the owner. +fn creation_policy(rule: &Rule, kind: Kind, path: &Utf8Path) -> Result { + let policy = Policy::new(rule, kind, path)?; + if !policy.allows_user_owner() { + return Err(Error::io( + path, + "use rule", + io::Error::new( + io::ErrorKind::InvalidInput, + "the rule doesn't allow the current user to own what it creates", + ), + )); + } + Ok(policy) +} + +fn validate_name(path: &Utf8Path, name: &str) -> Result<()> { + // A colon names an alternate data stream of a file, not an entry of the directory. + if name.contains(':') { + return Err(Error::io( + path, + "use entry name", + io::Error::from(io::ErrorKind::InvalidInput), + )); + } + Ok(()) +} + +fn share_mode(share: Share) -> FILE_SHARE_MODE { + match share { + Share::Shared => SHARE_ALL, + Share::Exclusive => FILE_SHARE_NONE, + } +} + +fn file_access(options: FileOptions) -> FILE_ACCESS_RIGHTS { + let mut access = CHECK_ACCESS.0; + if options.read { + access |= FILE_GENERIC_READ.0; + } + if options.write { + access |= FILE_GENERIC_WRITE.0; + } + FILE_ACCESS_RIGHTS(access) +} + +/// Opens the existing entry `name` of `parent` without following a reparse point, or returns `None` when it doesn't +/// exist. +fn open_entry( + parent: BorrowedHandle<'_>, + path: &Utf8Path, + name: &str, + access: FILE_ACCESS_RIGHTS, + share: FILE_SHARE_MODE, + step: &'static str, +) -> Result> { + let request = Request { + access, + share, + disposition: FILE_OPEN, + // Neither FILE_DIRECTORY_FILE nor FILE_NON_DIRECTORY_FILE, so that an entry of the wrong type opens and is + // refused by type rather than failing to open. + options: OPEN_OPTIONS, + attributes: FILE_FLAGS_AND_ATTRIBUTES(0), + descriptor: None, + }; + match nt::create(parent, name, &request) { + Ok(handle) => Ok(Some(handle)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(Error::io(path, step, error)), + } +} + +/// Refuses an item that is a link, isn't of `kind`, or is on a volume that doesn't enforce ACLs. +fn classify(handle: BorrowedHandle<'_>, path: &Utf8Path, kind: Kind) -> Result<()> { + let attributes = nt::attributes(handle).map_err(|error| Error::io(path, "read attributes", error))?; + if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { + return Err(Error::refused(path, Refusal::Link)); + } + let is_directory = attributes & FILE_ATTRIBUTE_DIRECTORY.0 != 0; + match kind { + Kind::Directory if !is_directory => return Err(Error::refused(path, Refusal::NotADirectory)), + Kind::File if is_directory || attributes & FILE_ATTRIBUTE_DEVICE.0 != 0 => { + return Err(Error::refused(path, Refusal::NotAFile)); + } + Kind::File => { + let links = nt::link_count(handle).map_err(|error| Error::io(path, "read link count", error))?; + if 1 < links { + return Err(Error::refused(path, Refusal::Link)); + } + } + Kind::Directory => {} + } + if !nt::persistent_acls(handle).map_err(|error| Error::io(path, "read volume information", error))? { + return Err(Error::refused(path, Refusal::Volume)); + } + Ok(()) +} + +/// Checks an item's type, link status, volume, owner and DACL against `policy`. +fn check_item(handle: BorrowedHandle<'_>, path: &Utf8Path, kind: Kind, policy: &Policy) -> Result<()> { + classify(handle, path, kind)?; + let security = + OwnerAndDacl::of_handle(handle).map_err(|error| Error::io(path, "read access control list", error))?; + policy.check(&security, path, &administrators_member) +} + +/// Finds the first existing directory of `path`, walking up without following a link, and returns it with the names +/// below it that don't exist, from the top down. +fn first_existing(path: &Utf8Path) -> Result<(Utf8PathBuf, Vec)> { + let mut candidate = path.to_owned(); + let mut missing = Vec::new(); + loop { + match std::fs::symlink_metadata(&candidate) { + Ok(metadata) => { + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { + return Err(Error::refused(&candidate, Refusal::Link)); + } + if !metadata.is_dir() { + return Err(Error::refused(&candidate, Refusal::NotADirectory)); + } + missing.reverse(); + return Ok((candidate, missing)); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + // Only a plain name can be created; `..` or a prefix that doesn't exist cannot. + let Some(name) = candidate.file_name() else { + return Err(Error::io(&candidate, "inspect directory", error)); + }; + missing.push(name.to_owned()); + candidate = match candidate.parent() { + Some(parent) if !parent.as_str().is_empty() => parent.to_owned(), + Some(_) => Utf8PathBuf::from("."), + None => return Err(Error::io(&candidate, "inspect directory", error)), + }; + } + Err(error) => return Err(Error::io(&candidate, "inspect directory", error)), + } + } +} + +/// Opens the first existing directory of a base path without following a link, and checks it and every directory above +/// it on its resolved path. +fn open_first_existing(path: &Utf8Path, policy: &Policy) -> Result { + let handle = open_path(path, DIRECTORY_ACCESS).map_err(|error| Error::io(path, "open directory", error))?; + // The directory may have been replaced since it was inspected by path. + classify(handle.as_handle(), path, Kind::Directory)?; + let resolved = + nt::final_path(handle.as_handle()).map_err(|error| Error::io(path, "resolve directory path", error))?; + let resolved = Utf8PathBuf::from(resolved); + let security = OwnerAndDacl::of_handle(handle.as_handle()) + .map_err(|error| Error::refused(path, Refusal::Unreadable(error)))?; + policy.check_ancestor(&security, path, resolved.parent().is_none(), &administrators_member)?; + let mut above = resolved.parent(); + while let Some(directory) = above { + check_above(directory, policy)?; + above = directory.parent(); + } + Ok(Dir { handle }) +} + +/// Checks a directory above the first existing one, at its resolved path. +fn check_above(path: &Utf8Path, policy: &Policy) -> Result<()> { + let unreadable = |error| Error::refused(path, Refusal::Unreadable(error)); + let handle = open_path(path, CHECK_ACCESS).map_err(unreadable)?; + let attributes = nt::attributes(handle.as_handle()).map_err(unreadable)?; + // The path was resolved through every link, so a link on it now was put there since. + if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 || attributes & FILE_ATTRIBUTE_DIRECTORY.0 == 0 { + return Err(Error::refused(path, Refusal::Replaceable)); + } + let security = OwnerAndDacl::of_handle(handle.as_handle()).map_err(unreadable)?; + policy.check_ancestor(&security, path, path.parent().is_none(), &administrators_member) +} + +/// Opens a directory by path without following a reparse point. +fn open_path(path: &Utf8Path, access: FILE_ACCESS_RIGHTS) -> io::Result { + let file = OpenOptions::new() + .access_mode(access.0) + .share_mode(SHARE_ALL.0) + // FILE_FLAG_BACKUP_SEMANTICS opens a directory, and asks for backup intent so the backup privilege applies. + .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(path)?; + Ok(OwnedHandle::from(file)) +} diff --git a/crates/private-fs/src/windows/nt.rs b/crates/private-fs/src/windows/nt.rs new file mode 100644 index 0000000000..c6b4788d0c --- /dev/null +++ b/crates/private-fs/src/windows/nt.rs @@ -0,0 +1,334 @@ +//! Handle operations: opening and creating relative to a directory handle, and reading and changing what a handle +//! refers to. + +use std::ffi::OsString; +use std::io; +use std::mem::offset_of; +use std::os::windows::ffi::OsStringExt as _; +use std::os::windows::io::{AsRawHandle as _, BorrowedHandle, FromRawHandle as _, OwnedHandle}; + +use windows::Wdk::Foundation::OBJECT_ATTRIBUTES; +use windows::Wdk::Storage::FileSystem::{ + FILE_RENAME_INFORMATION, FileRenameInformation, NTCREATEFILE_CREATE_DISPOSITION, NTCREATEFILE_CREATE_OPTIONS, + NtCreateFile, NtSetInformationFile, +}; +use windows::Win32::Foundation::{ + ERROR_NO_MORE_FILES, HANDLE, NTSTATUS, OBJ_CASE_INSENSITIVE, RtlNtStatusToDosError, UNICODE_STRING, +}; +use windows::Win32::Security::SECURITY_DESCRIPTOR; +use windows::Win32::Storage::FileSystem::{ + FILE_ACCESS_RIGHTS, FILE_BASIC_INFO, FILE_DISPOSITION_INFO, FILE_FLAGS_AND_ATTRIBUTES, FILE_ID_BOTH_DIR_INFO, + FILE_ID_INFO, FILE_INFO_BY_HANDLE_CLASS, FILE_NAME_NORMALIZED, FILE_SHARE_MODE, FILE_STANDARD_INFO, FileBasicInfo, + FileDispositionInfo, FileIdBothDirectoryInfo, FileIdBothDirectoryRestartInfo, FileIdInfo, FileStandardInfo, + FlushFileBuffers, GETFINALPATHNAMEBYHANDLE_FLAGS, GetFileInformationByHandleEx, GetFinalPathNameByHandleW, + GetVolumeInformationByHandleW, SetFileInformationByHandle, VOLUME_NAME_DOS, +}; +use windows::Win32::System::IO::IO_STATUS_BLOCK; +use windows::Win32::System::SystemServices::FILE_PERSISTENT_ACLS; +use windows::core::PWSTR; + +/// What [`create`] asks `NtCreateFile` for. +pub(super) struct Request<'a> { + pub(super) access: FILE_ACCESS_RIGHTS, + pub(super) share: FILE_SHARE_MODE, + pub(super) disposition: NTCREATEFILE_CREATE_DISPOSITION, + pub(super) options: NTCREATEFILE_CREATE_OPTIONS, + pub(super) attributes: FILE_FLAGS_AND_ATTRIBUTES, + /// The security descriptor a created file or directory gets. + pub(super) descriptor: Option<&'a SECURITY_DESCRIPTOR>, +} + +pub(super) fn raw(handle: BorrowedHandle<'_>) -> HANDLE { + HANDLE(handle.as_raw_handle()) +} + +/// Converts a failed `NTSTATUS` to the Win32 error it maps to, so that callers see the same error codes and kinds as +/// from the Win32 functions. +pub(super) fn status_error(status: NTSTATUS) -> io::Error { + // SAFETY: RtlNtStatusToDosError only maps a value. + let code = unsafe { RtlNtStatusToDosError(status) }; + io::Error::from_raw_os_error(code.cast_signed()) +} + +/// Converts an error of the `windows` crate to the Win32 error it carries. +/// +/// The `windows` crate converts to an `io::Error` holding the whole HRESULT, whose kind std can't decode. +pub(super) fn win32_error(error: windows::core::Error) -> io::Error { + let code = error.code().0.cast_unsigned(); + // An HRESULT made from a Win32 error code has the severity bit, the facility FACILITY_WIN32 (7) and the code in + // its low word. + if code & 0xFFFF_0000 == 0x8007_0000 { + io::Error::from_raw_os_error((code & 0xFFFF).cast_signed()) + } else { + io::Error::from_raw_os_error(code.cast_signed()) + } +} + +/// Opens or creates the entry `name` of the directory `root`, without resolving any path but `name`. +pub(super) fn create(root: BorrowedHandle<'_>, name: &str, request: &Request<'_>) -> io::Result { + let mut wide: Vec = name.encode_utf16().collect(); + let length = wide + .len() + .checked_mul(size_of::()) + .and_then(|length| u16::try_from(length).ok()) + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidFilename))?; + let object_name = UNICODE_STRING { + Length: length, + MaximumLength: length, + Buffer: PWSTR(wide.as_mut_ptr()), + }; + let attributes = OBJECT_ATTRIBUTES { + Length: u32::try_from(size_of::()).expect("OBJECT_ATTRIBUTES is a few dozen bytes"), + RootDirectory: raw(root), + ObjectName: &raw const object_name, + // Win32 file names are case-insensitive, and so are the names private-fs opens. + Attributes: OBJ_CASE_INSENSITIVE, + SecurityDescriptor: request.descriptor.map_or(std::ptr::null(), std::ptr::from_ref), + SecurityQualityOfService: std::ptr::null(), + }; + let mut handle = HANDLE::default(); + let mut status_block = IO_STATUS_BLOCK::default(); + // SAFETY: The name, its buffer, the object attributes and the security descriptor stay live for the call, the root + // handle is open, and the handle and status block outputs are writable. + let status = unsafe { + NtCreateFile( + &raw mut handle, + request.access, + &raw const attributes, + &raw mut status_block, + None, + request.attributes, + request.share, + request.disposition, + request.options, + None, + 0, + ) + }; + // NT_SUCCESS: success and informational codes are not negative; warnings and errors are. + if status.0 < 0 { + return Err(status_error(status)); + } + // SAFETY: NtCreateFile succeeded, so the handle is open and nothing else owns it. + Ok(unsafe { OwnedHandle::from_raw_handle(handle.0) }) +} + +/// Reads one fixed-size information class of `handle`. +fn information(handle: BorrowedHandle<'_>, class: FILE_INFO_BY_HANDLE_CLASS) -> io::Result { + let mut info = T::default(); + // SAFETY: The handle is open, and the output is a writable `T` of the size passed, which callers pair with the + // structure that `class` fills. + unsafe { + GetFileInformationByHandleEx( + raw(handle), + class, + (&raw mut info).cast(), + u32::try_from(size_of::()).expect("file information structures are a few dozen bytes"), + ) + } + .map_err(win32_error)?; + Ok(info) +} + +/// Returns the file attributes, such as `FILE_ATTRIBUTE_DIRECTORY` and `FILE_ATTRIBUTE_REPARSE_POINT`. +pub(super) fn attributes(handle: BorrowedHandle<'_>) -> io::Result { + Ok(information::(handle, FileBasicInfo)?.FileAttributes) +} + +pub(super) fn link_count(handle: BorrowedHandle<'_>) -> io::Result { + Ok(information::(handle, FileStandardInfo)?.NumberOfLinks) +} + +/// Returns the volume serial number and the file ID, which together identify a file. +pub(super) fn file_id(handle: BorrowedHandle<'_>) -> io::Result<(u64, [u8; 16])> { + let info = information::(handle, FileIdInfo)?; + Ok((info.VolumeSerialNumber, info.FileId.Identifier)) +} + +/// Reports whether the volume holding `handle` keeps and enforces access control lists. +/// +/// UNTESTED: a FAT or exFAT volume reports `false`, and an NTFS or ReFS volume `true`. +/// Tests run on NTFS only, and a FAT volume needs a removable disk or a mounted image; when changing this, check both, +/// because on a volume without ACLs every DACL private-fs sets is silently dropped and every check would read none. +pub(super) fn persistent_acls(handle: BorrowedHandle<'_>) -> io::Result { + let mut flags = 0u32; + // SAFETY: The handle is open, the flags output is writable, and the other outputs are not requested. + unsafe { GetVolumeInformationByHandleW(raw(handle), None, None, None, Some(&raw mut flags), None) } + .map_err(win32_error)?; + Ok(flags & FILE_PERSISTENT_ACLS != 0) +} + +/// Returns the normalized path of `handle` with its DOS volume name, such as `\\?\C:\Users`. +pub(super) fn final_path(handle: BorrowedHandle<'_>) -> io::Result { + let mut buffer = vec![0u16; 512]; + loop { + // SAFETY: The handle is open and the buffer is writable for its length. + let length = unsafe { + GetFinalPathNameByHandleW( + raw(handle), + &mut buffer, + GETFINALPATHNAMEBYHANDLE_FLAGS(FILE_NAME_NORMALIZED.0 | VOLUME_NAME_DOS.0), + ) + }; + if length == 0 { + return Err(io::Error::last_os_error()); + } + let length = usize::try_from(length).expect("a path length fits in usize"); + // INVARIANT: A length less than the buffer is the path's, without the terminating NUL; otherwise it is the + // buffer size needed, with it. + if length < buffer.len() { + buffer.truncate(length); + return String::from_utf16(&buffer).map_err(|_| io::Error::from(io::ErrorKind::InvalidData)); + } + buffer.resize(length, 0); + } +} + +pub(super) fn flush(handle: BorrowedHandle<'_>) -> io::Result<()> { + // SAFETY: The handle is open. + unsafe { FlushFileBuffers(raw(handle)) }.map_err(win32_error) +} + +/// Renames the file or directory `handle` refers to, to the entry `name` of the directory `root`. +/// +/// The handle needs `DELETE` access. +pub(super) fn rename( + handle: BorrowedHandle<'_>, + root: BorrowedHandle<'_>, + name: &str, + replace: bool, +) -> io::Result<()> { + let wide: Vec = name.encode_utf16().collect(); + let name_bytes = wide + .len() + .checked_mul(size_of::()) + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidFilename))?; + let name_offset = offset_of!(FILE_RENAME_INFORMATION, FileName); + let size = size_of::() + .checked_add(name_bytes) + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidFilename))?; + let invalid_name = || io::Error::from(io::ErrorKind::InvalidFilename); + // A u64 buffer is aligned for FILE_RENAME_INFORMATION, whose largest field is a handle. + let mut buffer = vec![0u64; size.div_ceil(size_of::())]; + { + // The fields are written as bytes, so that every byte of the buffer, padding included, stays initialized. + // SAFETY: The buffer is initialized, and u8 has no alignment requirement and no invalid values. + let bytes = unsafe { + std::slice::from_raw_parts_mut(buffer.as_mut_ptr().cast::(), buffer.len() * size_of::()) + }; + let mut put = |offset: usize, value: &[u8]| bytes[offset..][..value.len()].copy_from_slice(value); + // ReplaceIfExists is a BOOLEAN at the start of the union. + put(offset_of!(FILE_RENAME_INFORMATION, Anonymous), &[u8::from(replace)]); + put( + offset_of!(FILE_RENAME_INFORMATION, RootDirectory), + &raw(root).0.addr().to_ne_bytes(), + ); + put( + offset_of!(FILE_RENAME_INFORMATION, FileNameLength), + &u32::try_from(name_bytes).map_err(|_| invalid_name())?.to_ne_bytes(), + ); + for (index, unit) in wide.iter().enumerate() { + put(name_offset + index * size_of::(), &unit.to_ne_bytes()); + } + } + let mut status_block = IO_STATUS_BLOCK::default(); + // NtSetInformationFile takes the name relative to RootDirectory, which SetFileInformationByHandle refuses. + // SAFETY: The handle is open, the rename structure, with its root handle and name, stays live for the call, and the + // status block is writable. + let status = unsafe { + NtSetInformationFile( + raw(handle), + &raw mut status_block, + buffer.as_ptr().cast(), + u32::try_from(size).map_err(|_| invalid_name())?, + FileRenameInformation, + ) + }; + if status.0 < 0 { + return Err(status_error(status)); + } + Ok(()) +} + +/// Marks the file `handle` refers to for deletion once every handle to it is closed. +/// +/// The handle needs `DELETE` access. +pub(super) fn delete_on_close(handle: BorrowedHandle<'_>) -> io::Result<()> { + let info = FILE_DISPOSITION_INFO { DeleteFile: true }; + // SAFETY: The handle is open and the disposition structure stays live for the call. + unsafe { + SetFileInformationByHandle( + raw(handle), + FileDispositionInfo, + (&raw const info).cast(), + u32::try_from(size_of::()).expect("FILE_DISPOSITION_INFO is one byte"), + ) + } + .map_err(win32_error) +} + +/// Lists the entries of the directory `handle` refers to, with their file attributes, without `.` and `..`. +pub(super) fn list(handle: BorrowedHandle<'_>) -> io::Result> { + // A u64 buffer is aligned for FILE_ID_BOTH_DIR_INFO, whose largest fields are 64-bit. + let mut buffer = vec![0u64; 8 * 1024]; + let byte_length = buffer.len() * size_of::(); + let mut entries = Vec::new(); + // The restart class rewinds the handle's listing, which an earlier call on the same handle may have left at its end. + let mut class = FileIdBothDirectoryRestartInfo; + loop { + buffer.fill(0); + // SAFETY: The handle is open and the buffer is writable for the length passed. + let listed = unsafe { + GetFileInformationByHandleEx( + raw(handle), + class, + buffer.as_mut_ptr().cast(), + u32::try_from(byte_length).expect("the listing buffer is 64 KiB"), + ) + }; + match listed { + Ok(()) => {} + Err(error) if error.code() == ERROR_NO_MORE_FILES.to_hresult() => return Ok(entries), + Err(error) => return Err(win32_error(error)), + } + class = FileIdBothDirectoryInfo; + // SAFETY: The buffer is initialized, and u8 has no alignment requirement and no invalid values. + let bytes = unsafe { std::slice::from_raw_parts(buffer.as_ptr().cast::(), byte_length) }; + parse_listing(bytes, &mut entries)?; + } +} + +/// Appends the entries of one buffer of `FILE_ID_BOTH_DIR_INFO` records to `entries`. +fn parse_listing(bytes: &[u8], entries: &mut Vec<(OsString, u32)>) -> io::Result<()> { + let invalid = || io::Error::new(io::ErrorKind::InvalidData, "malformed directory listing"); + let mut offset = 0usize; + loop { + let field = |at: usize| -> io::Result { + let start = offset.checked_add(at).ok_or_else(invalid)?; + let field = bytes.get(start..start + size_of::()).ok_or_else(invalid)?; + Ok(u32::from_le_bytes(field.try_into().map_err(|_| invalid())?)) + }; + let next = field(offset_of!(FILE_ID_BOTH_DIR_INFO, NextEntryOffset))?; + let attributes = field(offset_of!(FILE_ID_BOTH_DIR_INFO, FileAttributes))?; + let name_length = + usize::try_from(field(offset_of!(FILE_ID_BOTH_DIR_INFO, FileNameLength))?).map_err(|_| invalid())?; + let start = offset + offset_of!(FILE_ID_BOTH_DIR_INFO, FileName); + let name = bytes + .get(start..start.checked_add(name_length).ok_or_else(invalid)?) + .ok_or_else(invalid)?; + let units: Vec = name + .chunks_exact(size_of::()) + .map(|unit| u16::from_le_bytes([unit[0], unit[1]])) + .collect(); + let name = OsString::from_wide(&units); + if name != "." && name != ".." { + entries.push((name, attributes)); + } + if next == 0 { + return Ok(()); + } + offset = offset + .checked_add(usize::try_from(next).map_err(|_| invalid())?) + .ok_or_else(invalid)?; + } +} diff --git a/crates/private-fs/src/windows/security.rs b/crates/private-fs/src/windows/security.rs new file mode 100644 index 0000000000..baa2fdb41b --- /dev/null +++ b/crates/private-fs/src/windows/security.rs @@ -0,0 +1,867 @@ +//! Accounts, the DACL a rule maps to, and the checks of owners and DACLs. + +use std::io; +use std::mem::offset_of; +use std::os::windows::io::BorrowedHandle; + +use camino::Utf8Path; +use win_api_wrappers::identity::sid::{Sid, StringSid}; +use win_api_wrappers::security::descriptor::SecurityDescriptor; +use win_api_wrappers::token::Token; +use windows::Win32::Foundation::{GENERIC_ALL, LUID}; +use windows::Win32::Security::Authorization::{ + AUTHZ_CLIENT_CONTEXT_HANDLE, AUTHZ_RESOURCE_MANAGER_HANDLE, AUTHZ_RM_FLAG_NO_AUDIT, AuthzContextInfoGroupsSids, + AuthzFreeContext, AuthzFreeResourceManager, AuthzGetInformationFromContext, AuthzInitializeContextFromSid, + AuthzInitializeResourceManager, SE_FILE_OBJECT, +}; +use windows::Win32::Security::{ + self, ACCESS_ALLOWED_ACE, ACE_FLAGS, ACE_HEADER, ACL, ACL_REVISION, AddAccessAllowedAceEx, CONTAINER_INHERIT_ACE, + GetAce, GetSecurityDescriptorControl, GetSecurityDescriptorDacl, INHERIT_ONLY_ACE, InitializeAcl, + InitializeSecurityDescriptor, NO_PROPAGATE_INHERIT_ACE, OBJECT_INHERIT_ACE, PSECURITY_DESCRIPTOR, + SE_DACL_PROTECTED, SECURITY_DESCRIPTOR, SetSecurityDescriptorControl, SetSecurityDescriptorDacl, + SetSecurityDescriptorOwner, +}; +use windows::Win32::Storage::FileSystem::{ + DELETE, FILE_ADD_FILE, FILE_ALL_ACCESS, FILE_DELETE_CHILD, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, + FILE_GENERIC_WRITE, SYNCHRONIZE, WRITE_DAC, WRITE_OWNER, +}; +use windows::Win32::System::SystemServices::{ + ACCESS_ALLOWED_ACE_TYPE, ACCESS_ALLOWED_CALLBACK_ACE_TYPE, ACCESS_DENIED_ACE_TYPE, ACCESS_DENIED_CALLBACK_ACE_TYPE, + ACCESS_DENIED_CALLBACK_OBJECT_ACE_TYPE, ACCESS_DENIED_OBJECT_ACE_TYPE, SECURITY_DESCRIPTOR_REVISION, +}; +use windows::core::{BOOL, PCWSTR}; + +use super::nt::{raw, win32_error}; +use crate::rule::Grant; +use crate::{Access, Account, Error, Fallback, Refusal, Result, Rule}; + +/// NT SERVICE\TrustedInstaller, which owns `C:\` and the system directories. +const TRUSTED_INSTALLER: &str = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"; + +/// The SIDs the accounts of a rule, and the other accounts private-fs knows, map to. +pub(super) struct Accounts { + /// The user of the process token, not its default owner, which is BUILTIN\Administrators for an elevated + /// administrator. + pub(super) user: Sid, + system: Sid, + administrators: Sid, + owner_rights: Sid, + trusted_installer: Sid, +} + +impl Accounts { + pub(super) fn current() -> io::Result { + let well_known = |kind| Sid::from_well_known(kind, None).map_err(io::Error::other); + let trusted_installer = StringSid::from_str(TRUSTED_INSTALLER) + .map_err(io::Error::other)? + .to_sid() + .map_err(io::Error::other)?; + Ok(Self { + // The pseudo-handle of the process token, so that a thread impersonating, as under `BackupPrivilege`, + // still maps CurrentUser to the process's user. + user: Token::current_process_token() + .sid_and_attributes() + .map_err(io::Error::other)? + .sid, + system: well_known(Security::WinLocalSystemSid)?, + administrators: well_known(Security::WinBuiltinAdministratorsSid)?, + owner_rights: well_known(Security::WinCreatorOwnerRightsSid)?, + trusted_installer, + }) + } + + fn sid(&self, account: Account) -> &Sid { + match account { + Account::CurrentUser => &self.user, + Account::System => &self.system, + Account::Administrators => &self.administrators, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Kind { + Directory, + File, +} + +/// One allow entry of a DACL: its SID, access mask and ACE flags. +pub(super) type Entry = (Sid, u32, u8); + +/// A rule mapped to SIDs and access masks for one kind of item. +pub(super) struct Policy { + pub(super) accounts: Accounts, + kind: Kind, + exact: bool, + /// One grant per SID: several accounts can map to one SID, as CurrentUser and System do for LocalSystem. + grants: Vec<(Sid, Access)>, + owners: Vec, + /// Whether an owner that is a member of BUILTIN\Administrators is allowed. + administrator_members_own: bool, + /// The SIDs of every account the rule names, which the ancestor check allows besides the system accounts. + rule_sids: Vec, +} + +impl Policy { + /// Maps `rule` for an item of `kind`, applying the fallback of each grant this platform cannot enforce. + pub(super) fn new(rule: &Rule, kind: Kind, path: &Utf8Path) -> Result { + let accounts = Accounts::current().map_err(|error| Error::io(path, "read accounts", error))?; + let mut grants: Vec<(Sid, Access)> = Vec::new(); + for grant in &rule.grants { + let Some(access) = effective_access(grant, kind, path)? else { + continue; + }; + let sid = accounts.sid(grant.account); + match grants.iter_mut().find(|(existing, _)| existing == sid) { + Some((_, merged)) => *merged = union(*merged, access), + None => grants.push((sid.clone(), access)), + } + } + let mut owners: Vec = Vec::new(); + for owner in &rule.owners { + let sid = accounts.sid(*owner); + if !owners.contains(sid) { + owners.push(sid.clone()); + } + } + let rule_sids = rule.accounts().map(|account| accounts.sid(account).clone()).collect(); + Ok(Self { + kind, + exact: rule.exact, + grants, + owners, + administrator_members_own: rule.owners.contains(&Account::Administrators), + rule_sids, + accounts, + }) + } + + /// Returns this policy with an exact check. + pub(super) fn exact(mut self) -> Self { + self.exact = true; + self + } + + /// Reports whether the rule allows the current user to own what it covers, as creation requires. + pub(super) fn allows_user_owner(&self) -> bool { + self.owners.contains(&self.accounts.user) + } + + fn access_of(&self, sid: &Sid) -> Option { + self.grants + .iter() + .find(|(granted, _)| granted == sid) + .map(|(_, access)| *access) + } + + /// Returns the canonical entries of the rule: one per SID, with its mask and inheritance. + fn entries(&self) -> Vec { + self.grants + .iter() + .map(|(sid, access)| (sid.clone(), mask(*access, self.kind), inheritance(*access, self.kind))) + .collect() + } + + /// Returns the mask of the OWNER RIGHTS entry for `owner`: exactly the access the rule gives the owner, so that the + /// owner keeps no implicit access beyond it. + fn owner_rights_mask(&self, owner: &Sid) -> u32 { + self.access_of(owner).map_or(0, |access| mask(access, self.kind)) + } + + /// Returns the DACL entries that private-fs writes when `owner` owns the item: the canonical entries, and an OWNER + /// RIGHTS entry when the rule gives the owner less than full access. + pub(super) fn dacl(&self, owner: &Sid) -> Vec { + let mut entries = self.entries(); + if self.access_of(owner) != Some(Access::Full) { + entries.push((self.accounts.owner_rights.clone(), self.owner_rights_mask(owner), 0)); + } + entries + } + + /// Reports whether the rule allows `owner` to own the item. + pub(super) fn owner_allowed(&self, owner: &Sid, path: &Utf8Path, membership: &Membership<'_>) -> Result { + if self.owners.contains(owner) { + return Ok(true); + } + if !self.administrator_members_own { + return Ok(false); + } + membership(owner).map_err(|error| Error::io(path, "check administrators membership", error)) + } + + /// Checks the owner and DACL of an item against the rule. + pub(super) fn check(&self, security: &OwnerAndDacl, path: &Utf8Path, membership: &Membership<'_>) -> Result<()> { + let refused = |refusal| Error::refused(path, refusal); + let owner = security.owner.as_ref().ok_or_else(|| refused(Refusal::Owner))?; + if !self.owner_allowed(owner, path, membership)? { + return Err(refused(Refusal::Owner)); + } + // A missing or NULL DACL grants everyone full access, and an unprotected one takes inherited entries. + let Some(dacl) = security.dacl.as_ref().filter(|_| security.protected) else { + return Err(refused(Refusal::Access)); + }; + let allowed_flags = match self.kind { + Kind::Directory => { + (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE | NO_PROPAGATE_INHERIT_ACE | INHERIT_ONLY_ACE).0 + } + Kind::File => 0, + }; + let owner_access = self.access_of(owner); + let owner_rights_mask = self.owner_rights_mask(owner); + let mut owner_rights = Vec::new(); + let mut others = Vec::new(); + for ace in dacl { + let sid = match (&ace.sid, u32::from(ace.kind)) { + (Some(sid), ACCESS_ALLOWED_ACE_TYPE) => sid, + _ => return Err(refused(Refusal::Access)), + }; + if u32::from(ace.flags) & !allowed_flags != 0 { + return Err(refused(Refusal::Access)); + } + let granted = if *sid == self.accounts.owner_rights { + owner_rights.push((ace.mask, ace.flags)); + owner_rights_mask + } else { + others.push((sid.clone(), ace.mask, ace.flags)); + self.access_of(sid) + .map(|access| mask(access, self.kind)) + .ok_or_else(|| refused(Refusal::Access))? + }; + if ace.mask & !granted != 0 { + return Err(refused(Refusal::Access)); + } + } + // Without an OWNER RIGHTS entry that applies to the item itself, the owner keeps READ_CONTROL and WRITE_DAC, + // and through WRITE_DAC any access. + let applies_to_item = |(_, flags): &(u32, u8)| u32::from(*flags) & INHERIT_ONLY_ACE.0 == 0; + if owner_access != Some(Access::Full) && !owner_rights.iter().any(applies_to_item) { + return Err(refused(Refusal::Access)); + } + if self.exact { + let mut expected = self.entries(); + expected.sort(); + others.sort(); + let owner_rights_exact = match owner_rights.as_slice() { + [] => true, + [(mask, flags)] => *mask == owner_rights_mask && *flags == 0, + _ => false, + }; + if others != expected || !owner_rights_exact { + return Err(refused(Refusal::Access)); + } + } + Ok(()) + } + + /// Checks a directory above the item: its owner is the system account, administrators or an account of the rule, + /// and no other account can rename or replace it or an entry of it. + pub(super) fn check_ancestor( + &self, + security: &OwnerAndDacl, + path: &Utf8Path, + is_volume_root: bool, + membership: &Membership<'_>, + ) -> Result<()> { + let accounts = &self.accounts; + let allowed = |sid: &Sid| { + [ + &accounts.system, + &accounts.administrators, + &accounts.trusted_installer, + // OWNER RIGHTS applies to the owner, which this check requires to be allowed. + &accounts.owner_rights, + ] + .contains(&sid) + || self.rule_sids.contains(sid) + }; + let Some(owner) = &security.owner else { + return Err(Error::refused( + path, + Refusal::Unreadable(io::Error::new(io::ErrorKind::InvalidData, "the directory has no owner")), + )); + }; + if !allowed(owner) + && !membership(owner).map_err(|error| Error::io(path, "check administrators membership", error))? + { + return Err(Error::refused(path, Refusal::Replaceable)); + } + // A missing or NULL DACL grants everyone full access. + let Some(dacl) = &security.dacl else { + return Err(Error::refused(path, Refusal::Replaceable)); + }; + let mut replacing = WRITE_DAC.0 | WRITE_OWNER.0 | FILE_DELETE_CHILD.0 | GENERIC_ALL.0; + // Nobody can delete or rename the root of a volume. + if !is_volume_root { + replacing |= DELETE.0; + } + for ace in dacl { + match u32::from(ace.kind) { + // Deny entries only take access away. + ACCESS_DENIED_ACE_TYPE + | ACCESS_DENIED_CALLBACK_ACE_TYPE + | ACCESS_DENIED_OBJECT_ACE_TYPE + | ACCESS_DENIED_CALLBACK_OBJECT_ACE_TYPE => continue, + ACCESS_ALLOWED_ACE_TYPE | ACCESS_ALLOWED_CALLBACK_ACE_TYPE => {} + _ => { + return Err(Error::refused( + path, + Refusal::Unreadable(io::Error::new( + io::ErrorKind::InvalidData, + "the directory has an access control entry of an unknown type", + )), + )); + } + } + if u32::from(ace.flags) & INHERIT_ONLY_ACE.0 != 0 { + continue; + } + let Some(sid) = &ace.sid else { + return Err(Error::refused(path, Refusal::Replaceable)); + }; + if !allowed(sid) && !is_app_container(sid) && ace.mask & replacing != 0 { + return Err(Error::refused(path, Refusal::Replaceable)); + } + } + Ok(()) + } +} + +/// Reports whether `sid` is an AppContainer package SID (S-1-15-2-...) or capability SID (S-1-15-3-...). +/// +/// An entry for one never grants access by itself: Windows grants an AppContainer process only the access that both +/// its user and groups, and its package and capabilities, are granted, and other processes don't hold these SIDs. +/// Windows puts such entries on user profile directories, such as `AppData\Local`. +fn is_app_container(sid: &Sid) -> bool { + // SECURITY_APP_PACKAGE_AUTHORITY. + const APP_PACKAGE_AUTHORITY: [u8; 6] = [0, 0, 0, 0, 0, 15]; + // SECURITY_APP_PACKAGE_BASE_RID and SECURITY_CAPABILITY_BASE_RID. + const BASE_RIDS: [u32; 2] = [2, 3]; + sid.identifier_authority().Value == APP_PACKAGE_AUTHORITY + && sid.sub_authorities().first().is_some_and(|rid| BASE_RIDS.contains(rid)) +} + +/// Reports whether an account is a member of BUILTIN\Administrators; injectable so that tests can decide it. +pub(super) type Membership<'a> = dyn Fn(&Sid) -> io::Result + 'a; + +/// Returns the access a grant gives on an item of `kind` here, after its fallback, or `None` when it gives none. +fn effective_access(grant: &Grant, kind: Kind, path: &Utf8Path) -> Result> { + if supported(grant.access, kind) { + return Ok(Some(grant.access)); + } + match grant.fallback { + None => Err(Error::unsupported(path, grant.account, grant.access)), + Some(Fallback::NoAccess) => Ok(None), + Some(Fallback::Access(access)) if supported(access, kind) => Ok(Some(access)), + Some(Fallback::Access(access)) => Err(Error::unsupported(path, grant.account, access)), + } +} + +/// Reports whether Windows can enforce `access` on an item of `kind`: add-only access is a directory's. +fn supported(access: Access, kind: Kind) -> bool { + !(access == Access::AddOnly && kind == Kind::File) +} + +/// Returns the union of two kinds of access, whose masks nest: add-only within read and write within full. +fn union(left: Access, right: Access) -> Access { + let rank = |access| match access { + Access::AddOnly => 0, + Access::ReadWrite => 1, + Access::Full => 2, + }; + if rank(left) < rank(right) { right } else { left } +} + +pub(super) fn mask(access: Access, kind: Kind) -> u32 { + match (access, kind) { + (Access::Full, _) => FILE_ALL_ACCESS.0, + (Access::ReadWrite, Kind::File) => (FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE).0, + (Access::ReadWrite, Kind::Directory) => { + (FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE | FILE_DELETE_CHILD).0 + } + (Access::AddOnly, _) => (FILE_ADD_FILE | SYNCHRONIZE).0, + } +} + +/// Returns the ACE flags of a grant: full and read-write entries of a directory are inherited by what it holds. +fn inheritance(access: Access, kind: Kind) -> u8 { + match (access, kind) { + (Access::Full | Access::ReadWrite, Kind::Directory) => { + u8::try_from((CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE).0).expect("ACE flags are one byte") + } + (Access::AddOnly, _) | (_, Kind::File) => 0, + } +} + +/// One entry of a DACL as read from a security descriptor. +#[derive(Debug)] +pub(super) struct Ace { + kind: u8, + flags: u8, + mask: u32, + /// The SID of an access-allowed or access-denied entry, `None` for an entry of another type. + sid: Option, +} + +/// The owner and DACL of an item. +#[derive(Debug)] +pub(super) struct OwnerAndDacl { + owner: Option, + protected: bool, + /// `None` for a missing or NULL DACL. + dacl: Option>, +} + +impl OwnerAndDacl { + pub(super) fn owner(&self) -> Option<&Sid> { + self.owner.as_ref() + } + + /// Reads the owner and DACL of the item `handle` refers to, which needs `READ_CONTROL`. + pub(super) fn of_handle(handle: BorrowedHandle<'_>) -> io::Result { + let descriptor = SecurityDescriptor::of_handle(raw(handle), SE_FILE_OBJECT).map_err(win32_error)?; + // SAFETY: The descriptor stays live through the call. + unsafe { Self::parse(descriptor.as_raw()) } + } + + /// # Safety + /// + /// `descriptor` points to a valid security descriptor that stays live through the call. + pub(super) unsafe fn parse(descriptor: PSECURITY_DESCRIPTOR) -> io::Result { + // SAFETY: Per the function's contract, the descriptor is valid and live. + let owner = unsafe { SecurityDescriptor::from_raw(descriptor) } + .owner() + .map_err(io::Error::other)?; + let mut control = 0; + let mut revision = 0; + // SAFETY: The descriptor is live and both outputs are writable. + unsafe { GetSecurityDescriptorControl(descriptor, &raw mut control, &raw mut revision) } + .map_err(win32_error)?; + let mut present = BOOL::default(); + let mut defaulted = BOOL::default(); + let mut acl: *mut ACL = std::ptr::null_mut(); + // SAFETY: The descriptor is live and every output is writable. + unsafe { GetSecurityDescriptorDacl(descriptor, &raw mut present, &raw mut acl, &raw mut defaulted) } + .map_err(win32_error)?; + let dacl = if present.as_bool() && !acl.is_null() { + // SAFETY: GetSecurityDescriptorDacl returned an ACL within the live descriptor. + Some(unsafe { read_acl(acl) }?) + } else { + None + }; + Ok(Self { + owner, + protected: control & SE_DACL_PROTECTED.0 != 0, + dacl, + }) + } +} + +/// # Safety +/// +/// `acl` points to a valid ACL that stays live through the call. +unsafe fn read_acl(acl: *mut ACL) -> io::Result> { + // SAFETY: Per the function's contract, the ACL is valid. + let count = unsafe { (*acl).AceCount }; + let mut entries = Vec::with_capacity(usize::from(count)); + for index in 0..u32::from(count) { + let mut pointer = std::ptr::null_mut(); + // SAFETY: The ACL is valid, the index is less than its entry count, and the output is writable. + unsafe { GetAce(acl, index, &raw mut pointer) }.map_err(win32_error)?; + if pointer.is_null() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid access control entry", + )); + } + // SAFETY: GetAce returned an entry within the ACL, which starts with an ACE_HEADER. + let header = unsafe { pointer.cast::().read_unaligned() }; + let has_sid = matches!( + u32::from(header.AceType), + ACCESS_ALLOWED_ACE_TYPE + | ACCESS_DENIED_ACE_TYPE + | ACCESS_ALLOWED_CALLBACK_ACE_TYPE + | ACCESS_DENIED_CALLBACK_ACE_TYPE + ); + let (mask, sid) = if has_sid { + // These four types share the layout of ACCESS_ALLOWED_ACE: the header, the mask, then the SID. + if usize::from(header.AceSize) < offset_of!(ACCESS_ALLOWED_ACE, SidStart) + 8 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "access control entry too short", + )); + } + let mask_pointer = pointer.cast::().wrapping_add(offset_of!(ACCESS_ALLOWED_ACE, Mask)); + // SAFETY: The entry is at least as long as the fields read, per its size checked above. + let mask = unsafe { mask_pointer.cast::().read_unaligned() }; + let sid_start = pointer + .cast::() + .wrapping_add(offset_of!(ACCESS_ALLOWED_ACE, SidStart)); + // SAFETY: The entry holds a SID starting at SidStart, which from_psid validates before copying. + let sid = unsafe { Sid::from_psid(Security::PSID(sid_start.cast())) }.map_err(io::Error::other)?; + (mask, Some(sid)) + } else { + (0, None) + }; + entries.push(Ace { + kind: header.AceType, + flags: header.AceFlags, + mask, + sid, + }); + } + Ok(entries) +} + +/// An absolute security descriptor with an owner and a protected DACL, for creating an item or setting its +/// security. +pub(super) struct Descriptor { + descriptor: Box, + // INVARIANT: The descriptor points into these buffers, so they live as long as it does and never move. + _acl: Vec, + _owner: Sid, +} + +impl Descriptor { + pub(super) fn new(owner: &Sid, entries: &[Entry]) -> io::Result { + let too_large = || io::Error::new(io::ErrorKind::InvalidInput, "the access control list is too large"); + // A SID is 8 bytes of header and 4 bytes per subauthority; an entry is its header and mask, then its SID. + let size = entries.iter().try_fold(size_of::(), |size, (sid, _, _)| { + size.checked_add(offset_of!(ACCESS_ALLOWED_ACE, SidStart) + 8 + 4 * sid.sub_authorities().len()) + }); + // The ACL size is a 16-bit field. + let size = size.and_then(|size| u16::try_from(size).ok()).ok_or_else(too_large)?; + // A u64 buffer is aligned for an ACL, which needs 4-byte alignment. + let mut acl = vec![0u64; usize::from(size).div_ceil(size_of::())]; + let acl_pointer = acl.as_mut_ptr().cast::(); + // SAFETY: The buffer is aligned and writable for `size` bytes. + unsafe { InitializeAcl(acl_pointer, u32::from(size), ACL_REVISION) }.map_err(win32_error)?; + for (sid, mask, flags) in entries { + // SAFETY: The ACL is initialized with room for every entry, and the SID is valid and live. + unsafe { + AddAccessAllowedAceEx( + acl_pointer, + ACL_REVISION, + ACE_FLAGS(u32::from(*flags)), + *mask, + sid.as_psid_const(), + ) + } + .map_err(win32_error)?; + } + let owner = owner.clone(); + let mut descriptor = Box::new(SECURITY_DESCRIPTOR::default()); + let pointer = PSECURITY_DESCRIPTOR((&raw mut *descriptor).cast()); + // SAFETY: The descriptor is writable. + unsafe { InitializeSecurityDescriptor(pointer, SECURITY_DESCRIPTOR_REVISION) }.map_err(win32_error)?; + // SAFETY: The descriptor is initialized, and the owner SID is valid and lives as long as the descriptor. + unsafe { SetSecurityDescriptorOwner(pointer, Some(owner.as_psid_const()), false) }.map_err(win32_error)?; + // SAFETY: The descriptor is initialized, and the ACL is valid and lives as long as the descriptor. + unsafe { SetSecurityDescriptorDacl(pointer, true, Some(acl_pointer.cast_const()), false) } + .map_err(win32_error)?; + // A protected DACL takes no entries from the parent directory. + // SAFETY: The descriptor is initialized. + unsafe { SetSecurityDescriptorControl(pointer, SE_DACL_PROTECTED, SE_DACL_PROTECTED) }.map_err(win32_error)?; + Ok(Self { + descriptor, + _acl: acl, + _owner: owner, + }) + } + + pub(super) fn as_ref(&self) -> &SECURITY_DESCRIPTOR { + &self.descriptor + } + + pub(super) fn as_raw(&self) -> PSECURITY_DESCRIPTOR { + PSECURITY_DESCRIPTOR(std::ptr::from_ref(&*self.descriptor).cast_mut().cast()) + } +} + +/// Reports whether `account` is BUILTIN\Administrators or a member of it, through its Authz group expansion, falling +/// back to the members of the local Administrators group where Authz can't resolve the account. +pub(super) fn administrators_member(account: &Sid) -> io::Result { + let administrators = Sid::from_well_known(Security::WinBuiltinAdministratorsSid, None).map_err(io::Error::other)?; + if *account == administrators { + return Ok(true); + } + match authz_member(account, &administrators) { + Ok(is_member) => Ok(is_member), + Err(error) => { + // UNTESTED: an account that Authz can't resolve, such as a domain account while the domain controller + // is unreachable, is a member when it is listed in the local Administrators group. + // Every account a test can make is local, which Authz resolves; when changing this, keep the fallback to + // a membership that exists only through the listing, and keep failing when neither source answers. + let members = win_api_wrappers::netmgmt::get_local_admin_group_members().map_err(|_| error)?; + Ok(members.contains(account)) + } + } +} + +struct AuthzResourceManager(AUTHZ_RESOURCE_MANAGER_HANDLE); + +impl Drop for AuthzResourceManager { + fn drop(&mut self) { + // SAFETY: AuthzInitializeResourceManager returned this handle, which nothing else frees. + let _ = unsafe { AuthzFreeResourceManager(self.0) }; + } +} + +struct AuthzContext(AUTHZ_CLIENT_CONTEXT_HANDLE); + +impl Drop for AuthzContext { + fn drop(&mut self) { + // SAFETY: AuthzInitializeContextFromSid returned this handle, which nothing else frees. + let _ = unsafe { AuthzFreeContext(self.0) }; + } +} + +fn authz_member(account: &Sid, group: &Sid) -> io::Result { + let mut manager = AUTHZ_RESOURCE_MANAGER_HANDLE::default(); + // SAFETY: The output is writable, and the callbacks and the name are optional. + unsafe { + AuthzInitializeResourceManager( + AUTHZ_RM_FLAG_NO_AUDIT.0, + None, + None, + None, + PCWSTR::null(), + &raw mut manager, + ) + } + .map_err(win32_error)?; + let manager = AuthzResourceManager(manager); + let mut context = AUTHZ_CLIENT_CONTEXT_HANDLE::default(); + // SAFETY: The SID and the resource manager stay live until the context is freed, and the output is writable. + unsafe { + AuthzInitializeContextFromSid( + 0, + account.as_psid_const(), + manager.0, + None, + LUID::default(), + None, + &raw mut context, + ) + } + .map_err(win32_error)?; + let context = AuthzContext(context); + let mut required = 0; + // SAFETY: A query with no buffer writes only the size it needs. + let _ = unsafe { + AuthzGetInformationFromContext( + context.0, + AuthzContextInfoGroupsSids, + 0, + &raw mut required, + std::ptr::null_mut(), + ) + }; + let size = usize::try_from(required).map_err(io::Error::other)?; + let invalid = || io::Error::new(io::ErrorKind::InvalidData, "invalid group list size"); + if size < size_of::() { + return Err(invalid()); + } + // A usize buffer is aligned for TOKEN_GROUPS, whose largest fields are pointers. + let mut buffer = vec![0usize; size.div_ceil(size_of::())]; + // SAFETY: The buffer is aligned and writable for `required` bytes. + unsafe { + AuthzGetInformationFromContext( + context.0, + AuthzContextInfoGroupsSids, + required, + &raw mut required, + buffer.as_mut_ptr().cast(), + ) + } + .map_err(win32_error)?; + // SAFETY: AuthzGetInformationFromContext filled the aligned buffer, which starts with a TOKEN_GROUPS header. + let groups = unsafe { &*buffer.as_ptr().cast::() }; + let count = usize::try_from(groups.GroupCount).map_err(io::Error::other)?; + let bytes = count + .checked_mul(size_of::()) + .and_then(|bytes| bytes.checked_add(offset_of!(Security::TOKEN_GROUPS, Groups))) + .ok_or_else(invalid)?; + if size < bytes { + return Err(invalid()); + } + // SAFETY: The buffer holds `count` entries, per the size checked above. + let entries = unsafe { std::slice::from_raw_parts(groups.Groups.as_ptr(), count) }; + for entry in entries { + // SAFETY: AuthzGetInformationFromContext returned a valid SID in each entry, within the live buffer. + if unsafe { Sid::from_psid(entry.Sid) }.map_err(io::Error::other)? == *group { + return Ok(true); + } + } + Ok(false) +} + +#[cfg(test)] +mod tests { + use windows::Win32::Foundation::{HLOCAL, LocalFree}; + use windows::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows::core::HSTRING; + + use super::*; + + /// Parses an SDDL string, in which `{user}` stands for the current user's SID. + fn security(sddl: &str) -> OwnerAndDacl { + let user = Accounts::current().expect("read accounts").user; + let sddl = HSTRING::from(sddl.replace("{user}", &user.to_string())); + let mut descriptor = PSECURITY_DESCRIPTOR::default(); + // SAFETY: The string is NUL-terminated and the output is writable. + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW(&sddl, SDDL_REVISION_1, &raw mut descriptor, None) + } + .expect("parse SDDL"); + // SAFETY: The conversion returned a valid descriptor, freed below. + let security = unsafe { OwnerAndDacl::parse(descriptor) }.expect("read descriptor"); + // SAFETY: The conversion allocated the descriptor with LocalAlloc, and nothing uses it any more. + unsafe { LocalFree(Some(HLOCAL(descriptor.0))) }; + security + } + + fn policy(rule: &Rule, kind: Kind) -> Policy { + Policy::new(rule, kind, Utf8Path::new("item")).expect("map rule") + } + + fn no_member(_: &Sid) -> io::Result { + Ok(false) + } + + fn refusal(result: Result<()>) -> String { + match result { + Ok(()) => "accepted".to_owned(), + Err(error) => match error.kind() { + crate::ErrorKind::Refused(refusal) => format!("{refusal:?}"), + kind => format!("{kind:?}"), + }, + } + } + + fn system_rule() -> Rule { + Rule::new() + .owner(Account::CurrentUser) + .grant(Account::System, Access::Full) + } + + // oracle: spec MS-DTYP §2.4.4.1 ACE_HEADER + #[test] + fn an_inherit_only_owner_rights_entry_leaves_the_owner_its_implicit_access() { + // An inherit-only entry doesn't apply to the directory itself, so its owner keeps READ_CONTROL and WRITE_DAC. + let security = security("O:{user}D:P(A;OICI;FA;;;SY)(A;OICIIO;;;;OW)"); + let checked = policy(&system_rule(), Kind::Directory).check(&security, Utf8Path::new("item"), &no_member); + assert_eq!(refusal(checked), "Access"); + } + + // oracle: intent crates/private-fs/INTENT.md §mechanisms + #[test] + fn a_check_refuses_a_deny_entry() { + // A check that skipped entries it can't compare would accept any other entry type. + let security = security("O:{user}D:P(A;;FA;;;SY)(A;;;;;OW)(D;;FA;;;BU)"); + let checked = policy(&system_rule(), Kind::File).check(&security, Utf8Path::new("item"), &no_member); + assert_eq!(refusal(checked), "Access"); + } + + // oracle: intent crates/private-fs/INTENT.md §mechanisms + #[test] + fn a_file_entry_with_inheritance_flags_is_refused() { + let security = security("O:{user}D:P(A;OI;FA;;;SY)(A;;;;;OW)"); + let checked = policy(&system_rule(), Kind::File).check(&security, Utf8Path::new("item"), &no_member); + assert_eq!(refusal(checked), "Access"); + } + + // oracle: intent crates/private-fs/INTENT.md §vocabulary + #[test] + fn an_exact_check_refuses_a_duplicate_entry() { + let security = security("O:{user}D:P(A;;FA;;;SY)(A;;FA;;;SY)(A;;;;;OW)"); + let checked = policy(&system_rule().exact(), Kind::File).check(&security, Utf8Path::new("item"), &no_member); + assert_eq!(refusal(checked), "Access"); + } + + // oracle: intent crates/private-fs/INTENT.md §vocabulary + #[test] + fn an_exact_check_refuses_owner_rights_wider_than_the_owner_s_access() { + // The rule gives the owner no access, so OWNER RIGHTS must grant nothing; a narrower check also refuses more. + let security = security("O:{user}D:P(A;;FA;;;SY)(A;;FR;;;OW)"); + let checked = policy(&system_rule(), Kind::File).check(&security, Utf8Path::new("item"), &no_member); + assert_eq!(refusal(checked), "Access"); + } + + // oracle: mutant grants to one account kept as two entries, which an exact check of what was created refuses + #[test] + fn grants_to_one_account_merge_into_one_entry() { + let rule = system_rule() + .grant(Account::System, Access::ReadWrite) + .grant(Account::CurrentUser, Access::Full); + let policy = policy(&rule, Kind::Directory); + let system = &policy.accounts.system; + let entries: Vec<_> = policy + .dacl(&policy.accounts.user) + .into_iter() + .filter(|(sid, _, _)| sid == system) + .collect(); + assert_eq!(entries, [(system.clone(), 0x001F_01FF, 0x3)]); + } + + // oracle: spec MS-DTYP §2.4.4.1 ACE_HEADER + #[test] + fn an_ancestor_deny_or_inherit_only_entry_is_ignored() { + // A deny entry only takes access away, and an inherit-only entry doesn't apply to the directory. + let security = security("O:SYD:P(A;;FA;;;SY)(D;;FA;;;BU)(A;OICIIO;FA;;;BU)"); + let checked = + policy(&system_rule(), Kind::Directory).check_ancestor(&security, Utf8Path::new("item"), false, &no_member); + assert_eq!(refusal(checked), "accepted"); + } + + // oracle: intent crates/private-fs/INTENT.md §paths + #[test] + fn delete_on_an_ancestor_is_refused_below_the_volume_root() { + let security = security("O:SYD:P(A;;FA;;;SY)(A;;SD;;;BU)"); + let checked = + policy(&system_rule(), Kind::Directory).check_ancestor(&security, Utf8Path::new("item"), false, &no_member); + assert_eq!(refusal(checked), "Replaceable"); + } + + // oracle: intent crates/private-fs/INTENT.md §paths + #[test] + fn delete_on_the_volume_root_is_ignored() { + // Nobody can delete or rename the root of a volume. + let security = security("O:SYD:P(A;;FA;;;SY)(A;;SD;;;BU)"); + let checked = + policy(&system_rule(), Kind::Directory).check_ancestor(&security, Utf8Path::new("item"), true, &no_member); + assert_eq!(refusal(checked), "accepted"); + } + + // oracle: spec Microsoft Learn "AppContainer isolation" + #[test] + fn an_ancestor_entry_for_an_app_container_capability_is_ignored() { + // The capability SID that Windows puts on AppData\Local, with full access. + let security = security( + "O:SYD:P(A;;FA;;;SY)(A;;FA;;;S-1-15-3-3557520199-3666692283-3112367039-3524159787-2791857073-3163583606-3692855932)", + ); + let checked = + policy(&system_rule(), Kind::Directory).check_ancestor(&security, Utf8Path::new("item"), false, &no_member); + assert_eq!(refusal(checked), "accepted"); + } + + // oracle: intent crates/private-fs/INTENT.md §paths + #[test] + fn an_ancestor_with_a_null_dacl_is_replaceable() { + // A NULL DACL grants everyone full access. + let security = security("O:SYD:NO_ACCESS_CONTROL"); + let checked = + policy(&system_rule(), Kind::Directory).check_ancestor(&security, Utf8Path::new("item"), false, &no_member); + assert_eq!(refusal(checked), "Replaceable"); + } + + // oracle: intent crates/private-fs/INTENT.md §paths + #[test] + fn an_ancestor_owned_by_another_account_is_replaceable() { + let security = security("O:BUD:P(A;;FA;;;SY)"); + let checked = + policy(&system_rule(), Kind::Directory).check_ancestor(&security, Utf8Path::new("item"), false, &no_member); + assert_eq!(refusal(checked), "Replaceable"); + } +} diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index e69de29bb2..6323d536e4 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -0,0 +1,678 @@ +//! Tests of the Windows backend of private-fs, which read and set owners and DACLs through the Windows API rather than +//! through private-fs. +//! +//! The access masks are the file and directory access rights of [MS-SMB2] §2.2.13.1, which NTFS shares. + +use std::io::{Read as _, Write as _}; +use std::os::windows::fs::OpenOptionsExt as _; +use std::os::windows::io::AsRawHandle as _; + +use anyhow::Context as _; +use camino::{Utf8Path, Utf8PathBuf}; +use private_fs::{ + Access, Account, BackupPrivilege, Dir, EntryKind, Error, ErrorKind, Existing, Fallback, FileOptions, Refusal, + Replace, Rule, Share, +}; +use win_api_wrappers::identity::sid::Sid; +use win_api_wrappers::process::Process; +use win_api_wrappers::raw::Win32::Foundation::HANDLE; +use win_api_wrappers::raw::Win32::Security::Authorization::{GRANT_ACCESS, SE_FILE_OBJECT}; +use win_api_wrappers::raw::Win32::Security::{ + self, ACE_FLAGS, SecurityImpersonation, TOKEN_ADJUST_PRIVILEGES, TOKEN_DUPLICATE, TOKEN_IMPERSONATE, TOKEN_QUERY, + TokenImpersonation, +}; +use win_api_wrappers::raw::Win32::Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT}; +use win_api_wrappers::security::acl::{ + Acl, ExplicitAccess, InheritableAcl, InheritableAclKind, Trustee, set_named_security_info, +}; +use win_api_wrappers::security::descriptor::SecurityDescriptor; +use win_api_wrappers::security::privilege::SE_RESTORE_NAME; +use win_api_wrappers::str::U16CString; +use win_api_wrappers::token::Token; + +/// Full access: `STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE | 0x1FF`, the value of `FILE_ALL_ACCESS`. +const FULL: u32 = 0x001F_01FF; +/// Read and write on a directory: `FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE | +/// FILE_DELETE_CHILD`. +const READ_WRITE_DIRECTORY: u32 = 0x0013_01FF; +/// `READ_CONTROL | SYNCHRONIZE | FILE_READ_DATA | FILE_READ_EA | FILE_READ_ATTRIBUTES`, `FILE_GENERIC_READ`. +const READ: u32 = 0x0012_0089; +/// `FILE_DELETE_CHILD`. +const DELETE_CHILD: u32 = 0x40; +/// `FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | SYNCHRONIZE`. +const ADD_ENTRIES: u32 = 0x0010_0006; +/// `CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE`. +const INHERITED_BY_CHILDREN: u32 = 0x3; +/// `ERROR_SHARING_VIOLATION`. +const SHARING_VIOLATION: i32 = 32; +/// `ERROR_INVALID_OWNER`, which setting an owner without the restore privilege fails with. +const INVALID_OWNER: u32 = 1307; + +/// A directory under the system temporary directory, whose ancestors pass the ancestor check, removed on drop. +struct TestDir(Utf8PathBuf); + +impl TestDir { + /// Returns a path under the temporary directory that doesn't exist yet. + fn new() -> Self { + let temp = Utf8PathBuf::from_path_buf(std::env::temp_dir()).expect("the temporary directory path is UTF-8"); + Self(temp.join(format!("pfs-{}", uuid::Uuid::new_v4().simple()))) + } + + fn path(&self) -> &Utf8Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn user() -> Sid { + Token::current_process_token() + .sid_and_attributes() + .expect("read the process user") + .sid +} + +fn well_known(kind: Security::WELL_KNOWN_SID_TYPE) -> Sid { + Sid::from_well_known(kind, None).expect("make a well-known SID") +} + +fn system() -> Sid { + well_known(Security::WinLocalSystemSid) +} + +fn administrators() -> Sid { + well_known(Security::WinBuiltinAdministratorsSid) +} + +fn owner_rights() -> Sid { + well_known(Security::WinCreatorOwnerRightsSid) +} + +fn users() -> Sid { + well_known(Security::WinBuiltinUsersSid) +} + +/// A rule the current user owns with full access, which lets the test clean up. +fn user_rule() -> Rule { + Rule::new() + .owner(Account::CurrentUser) + .grant(Account::CurrentUser, Access::Full) +} + +/// An allow entry: its SID, access mask and ACE flags. +type AllowEntry = (Sid, u32, u32); + +/// Reads the owner and the DACL entries of `path`, sorted, failing unless the DACL is protected and allow-only. +fn security(path: &Utf8Path) -> anyhow::Result<(Sid, Vec)> { + // The backup privilege, where the process token holds it, reads a DACL that gives the test's own token no access. + let _backup = BackupPrivilege::enable().ok(); + let file = std::fs::OpenOptions::new() + .access_mode(0x0002_0000) // READ_CONTROL + .share_mode(0x7) + .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(path) + .with_context(|| format!("open {path} to read its security"))?; + let descriptor = SecurityDescriptor::of_handle(HANDLE(file.as_raw_handle()), SE_FILE_OBJECT)?; + let owner = descriptor.owner()?.context("no owner")?; + let mut entries: Vec<_> = descriptor + .protected_allow_entries()? + .into_iter() + .map(|entry| (entry.sid, entry.mask, entry.flags)) + .collect(); + entries.sort(); + Ok((owner, entries)) +} + +fn sorted(mut entries: Vec) -> Vec { + entries.sort(); + entries +} + +/// Sets the DACL of `path`, through the path rather than private-fs. +fn set_dacl(path: &Utf8Path, entries: &[AllowEntry], kind: InheritableAclKind) -> anyhow::Result<()> { + let entries: Vec<_> = entries + .iter() + .map(|(sid, mask, flags)| ExplicitAccess { + access_permissions: *mask, + access_mode: GRANT_ACCESS, + inheritance: ACE_FLAGS(*flags), + trustee: Trustee::Sid(sid.clone()), + }) + .collect(); + let acl = Acl::new()?.set_entries(&entries)?; + set_named_security_info( + &U16CString::from_str(path.as_str())?, + SE_FILE_OBJECT, + None, + None, + Some(&InheritableAcl { kind, acl }), + None, + ) +} + +/// Makes `owner` the owner of `path` with the restore privilege, or returns `false` when the process token doesn't +/// hold it. +fn set_owner(path: &Utf8Path, owner: &Sid) -> anyhow::Result { + let mut token = Process::current_process() + .token(TOKEN_DUPLICATE | TOKEN_QUERY)? + .duplicate( + TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY | TOKEN_IMPERSONATE, + None, + SecurityImpersonation, + TokenImpersonation, + )?; + token.enable_privilege(SE_RESTORE_NAME)?; + let _impersonation = token.impersonate()?; + let set = set_named_security_info( + &U16CString::from_str(path.as_str())?, + SE_FILE_OBJECT, + Some(owner), + None, + None, + None, + ); + match set { + Ok(()) => Ok(true), + Err(error) + if error + .downcast_ref::() + .is_some_and(|error| error.code() == windows::core::HRESULT::from_win32(INVALID_OWNER)) => + { + Ok(false) + } + Err(error) => Err(error), + } +} + +fn refusal(result: private_fs::Result) -> Error { + match result { + Ok(_) => panic!("the operation succeeded"), + Err(error) => error, + } +} + +fn is_refused(error: &Error, expected: fn(&Refusal) -> bool) -> bool { + matches!(error.kind(), ErrorKind::Refused(refusal) if expected(refusal)) +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn create_base_gives_each_new_directory_exactly_its_rule() -> anyhow::Result<()> { + // Catches a new directory that inherits its parent's entries, or that maps a kind of access to the wrong mask or + // inheritance. + let test = TestDir::new(); + let path = test.path().join("a").join("b"); + let rule = user_rule() + .grant(Account::System, Access::Full) + .grant(Account::Administrators, Access::ReadWrite); + Dir::create_base(&path, &rule)?; + let expected = sorted(vec![ + (user(), FULL, INHERITED_BY_CHILDREN), + (system(), FULL, INHERITED_BY_CHILDREN), + (administrators(), READ_WRITE_DIRECTORY, INHERITED_BY_CHILDREN), + ]); + for directory in [test.path().to_owned(), test.path().join("a"), path] { + assert_eq!(security(&directory)?, (user(), expected.clone()), "{directory}"); + } + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §mechanisms +#[test] +fn a_created_file_takes_its_owner_s_implicit_access_away() -> anyhow::Result<()> { + // Catches a file whose owner keeps the READ_CONTROL and WRITE_DAC that Windows gives an owner without an OWNER + // RIGHTS entry, through which the owner can grant itself anything. + if let Err(error) = BackupPrivilege::enable() { + println!("SKIP: reading a DACL that grants the test nothing needs the backup privilege: {error}"); + return Ok(()); + } + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + let rule = Rule::new() + .owner(Account::CurrentUser) + .grant(Account::System, Access::Full); + drop(base.create_file("file", &rule, FileOptions::new())?); + assert_eq!( + security(&test.path().join("file"))?, + (user(), sorted(vec![(system(), FULL, 0), (owner_rights(), 0, 0)])) + ); + Ok(()) +} + +// oracle: spec Microsoft Learn "Privilege Constants" SE_BACKUP_NAME +#[test] +fn the_backup_privilege_opens_a_file_the_token_cannot() -> anyhow::Result<()> { + // Catches a backup privilege that is not enabled on the thread, or opens that don't ask for backup intent. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + let rule = Rule::new() + .owner(Account::CurrentUser) + .grant(Account::System, Access::Full); + drop(base.create_file("file", &rule, FileOptions::new())?); + let denied = refusal(base.open_file("file", &rule, FileOptions::new())); + assert!( + matches!(denied.kind(), ErrorKind::Io { source, .. } if source.kind() == std::io::ErrorKind::PermissionDenied), + "{denied:?}" + ); + let _backup = match BackupPrivilege::enable() { + Ok(backup) => backup, + Err(error) => { + println!("SKIP: the process token doesn't hold the backup privilege: {error}"); + return Ok(()); + } + }; + assert!(base.open_file("file", &rule, FileOptions::new())?.is_some()); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §rules +#[test] +fn add_only_access_to_a_file_without_a_fallback_is_unsupported() -> anyhow::Result<()> { + // Catches a grant that Windows cannot enforce on a file being widened or silently dropped. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + let rule = user_rule().grant(Account::System, Access::AddOnly); + let error = refusal(base.create_file("file", &rule, FileOptions::new())); + assert!( + matches!( + error.kind(), + ErrorKind::Unsupported { + account: Account::System, + access: Access::AddOnly + } + ), + "{error:?}" + ); + assert!(!test.path().join("file").exists()); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §rules +#[test] +fn a_no_access_fallback_drops_an_unsupported_grant() -> anyhow::Result<()> { + // Catches a fallback that is ignored, so that the file grants what the rule's fallback took away. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + let rule = user_rule().grant_or(Account::System, Access::AddOnly, Fallback::NoAccess); + drop(base.create_file("file", &rule, FileOptions::new())?); + assert_eq!(security(&test.path().join("file"))?, (user(), vec![(user(), FULL, 0)])); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §mechanisms +#[test] +fn a_check_refuses_an_account_the_rule_does_not_name() -> anyhow::Result<()> { + // Catches a check that accepts an extra allow entry. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + drop(base.create_dir("child", &user_rule(), Existing::Check)?); + set_dacl( + &test.path().join("child"), + &[(user(), FULL, INHERITED_BY_CHILDREN), (users(), READ, 0)], + InheritableAclKind::Protected, + )?; + let error = refusal(base.open_dir("child", &user_rule(), Share::Shared)); + assert!( + is_refused(&error, |refusal| matches!(refusal, Refusal::Access)), + "{error:?}" + ); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §mechanisms +#[test] +fn a_check_refuses_an_unprotected_dacl() -> anyhow::Result<()> { + // Catches a check that ignores inherited entries, which the parent directory decides. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + drop(base.create_dir("child", &user_rule(), Existing::Check)?); + set_dacl( + &test.path().join("child"), + &[(user(), FULL, INHERITED_BY_CHILDREN)], + InheritableAclKind::Inherit, + )?; + let error = refusal(base.open_dir("child", &user_rule(), Share::Shared)); + assert!( + is_refused(&error, |refusal| matches!(refusal, Refusal::Access)), + "{error:?}" + ); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §vocabulary +#[test] +fn a_narrower_check_accepts_less_access_than_the_rule() -> anyhow::Result<()> { + // Catches a default check that also requires every grant of the rule. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + drop(base.create_dir("child", &user_rule(), Existing::Check)?); + let wider = user_rule().grant(Account::System, Access::Full); + assert!(base.open_dir("child", &wider, Share::Shared)?.is_some()); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §vocabulary +#[test] +fn an_exact_check_refuses_less_access_than_the_rule() -> anyhow::Result<()> { + // Catches an exact check that accepts a missing grant. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + drop(base.create_dir("child", &user_rule(), Existing::Check)?); + let wider = user_rule().grant(Account::System, Access::Full).exact(); + let error = refusal(base.open_dir("child", &wider, Share::Shared)); + assert!( + is_refused(&error, |refusal| matches!(refusal, Refusal::Access)), + "{error:?}" + ); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn a_repair_restores_the_rule_without_changing_what_lies_below() -> anyhow::Result<()> { + // Catches a repair that propagates the new inheritable entries to existing children, as SetSecurityInfo does. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + let child = base.create_dir("child", &user_rule(), Existing::Check)?; + drop(child.create_file("file", &user_rule(), FileOptions::new())?); + let file = test.path().join("child").join("file"); + set_dacl( + &file, + &[(user(), FULL, 0), (users(), READ, 0)], + InheritableAclKind::Protected, + )?; + let file_before = security(&file)?; + drop(child); + set_dacl( + &test.path().join("child"), + &[ + (user(), FULL, INHERITED_BY_CHILDREN), + (users(), FULL, INHERITED_BY_CHILDREN), + ], + InheritableAclKind::Protected, + )?; + let rule = user_rule().exact(); + drop(base.create_dir("child", &rule, Existing::Repair)?); + assert_eq!( + security(&test.path().join("child"))?, + (user(), vec![(user(), FULL, INHERITED_BY_CHILDREN)]) + ); + assert_eq!(security(&file)?, file_before); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §mechanisms +#[test] +fn a_repair_takes_ownership_from_an_owner_the_rule_does_not_allow() -> anyhow::Result<()> { + // Catches a check that ignores the owner, which can always rewrite the DACL, and a repair that leaves it. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + drop(base.create_dir("child", &user_rule(), Existing::Check)?); + let child = test.path().join("child"); + if !set_owner(&child, &well_known(Security::WinLocalServiceSid))? { + println!("SKIP: the process token doesn't hold the restore privilege"); + return Ok(()); + } + let error = refusal(base.create_dir("child", &user_rule(), Existing::Check)); + assert!( + is_refused(&error, |refusal| matches!(refusal, Refusal::Owner)), + "{error:?}" + ); + drop(base.create_dir("child", &user_rule(), Existing::Repair)?); + assert_eq!(security(&child)?.0, user()); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §vocabulary +#[test] +fn administrators_as_an_owner_allow_a_member_of_the_group() -> anyhow::Result<()> { + // Catches an owner check that compares with the BUILTIN\Administrators SID only. + let token = Process::current_process().token(TOKEN_QUERY | TOKEN_DUPLICATE)?; + if !token.is_member(&administrators())? { + println!("SKIP: the current user is not a member of Administrators"); + return Ok(()); + } + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + drop(base.create_dir("child", &user_rule(), Existing::Check)?); + let rule = Rule::new() + .owner(Account::Administrators) + .grant(Account::CurrentUser, Access::Full); + assert!(base.open_dir("child", &rule, Share::Shared)?.is_some()); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn open_dir_refuses_a_symbolic_link() -> anyhow::Result<()> { + // Catches an open that follows a link out of the checked directory. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + drop(base.create_dir("target", &user_rule(), Existing::Check)?); + if let Err(error) = std::os::windows::fs::symlink_dir(test.path().join("target"), test.path().join("link")) { + println!("SKIP: cannot create a symbolic link: {error}"); + return Ok(()); + } + let error = refusal(base.open_dir("link", &user_rule(), Share::Shared)); + assert!( + is_refused(&error, |refusal| matches!(refusal, Refusal::Link)), + "{error:?}" + ); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn create_base_refuses_a_link_as_the_first_existing_directory() -> anyhow::Result<()> { + // Catches a base directory resolved through a link that someone else can point elsewhere. + let test = TestDir::new(); + Dir::create_base(&test.path().join("target"), &user_rule())?; + if let Err(error) = std::os::windows::fs::symlink_dir(test.path().join("target"), test.path().join("link")) { + println!("SKIP: cannot create a symbolic link: {error}"); + return Ok(()); + } + let error = refusal(Dir::create_base(&test.path().join("link").join("new"), &user_rule())); + assert!( + is_refused(&error, |refusal| matches!(refusal, Refusal::Link)), + "{error:?}" + ); + assert_eq!(error.path(), test.path().join("link")); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §mechanisms +#[test] +fn open_file_refuses_a_file_with_another_hard_link() -> anyhow::Result<()> { + // Catches a file that another name, in a directory with other access, also reaches. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + drop(base.create_file("file", &user_rule(), FileOptions::new())?); + std::fs::hard_link(test.path().join("file"), test.path().join("other"))?; + let error = refusal(base.open_file("file", &user_rule(), FileOptions::new())); + assert!( + is_refused(&error, |refusal| matches!(refusal, Refusal::Link)), + "{error:?}" + ); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn a_directory_above_whose_entries_others_can_delete_is_refused() -> anyhow::Result<()> { + // Catches an ancestor check that misses FILE_DELETE_CHILD, through which another account can replace the base. + let test = TestDir::new(); + std::fs::create_dir(test.path())?; + set_dacl( + test.path(), + &[(user(), FULL, INHERITED_BY_CHILDREN), (users(), DELETE_CHILD, 0)], + InheritableAclKind::Protected, + )?; + let error = refusal(Dir::create_base(&test.path().join("base"), &user_rule())); + assert!( + is_refused(&error, |refusal| matches!(refusal, Refusal::Replaceable)), + "{error:?}" + ); + assert_eq!(error.path(), test.path()); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn a_directory_above_others_can_only_add_to_is_accepted() -> anyhow::Result<()> { + // Catches an ancestor check that counts adding an entry beside the base as replacing it. + let test = TestDir::new(); + std::fs::create_dir(test.path())?; + set_dacl( + test.path(), + &[(user(), FULL, INHERITED_BY_CHILDREN), (users(), ADD_ENTRIES, 0)], + InheritableAclKind::Protected, + )?; + Dir::create_base(&test.path().join("base"), &user_rule())?; + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn open_base_returns_none_for_a_missing_directory() -> anyhow::Result<()> { + // Catches an open that creates what it should only open. + let test = TestDir::new(); + assert!(Dir::open_base(&test.path().join("missing"), &user_rule())?.is_none()); + assert!(!test.path().exists()); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn persist_refusing_to_replace_keeps_the_existing_file() -> anyhow::Result<()> { + // Catches a rename without replacement that overwrites the destination. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + base.create_file("old", &user_rule(), FileOptions::new())? + .write_all(b"old")?; + let mut new = base.create_file("new", &user_rule(), FileOptions::new())?; + new.write_all(b"new")?; + let error = refusal(base.persist(&new, "new", "old", Replace::Refuse)); + assert!( + matches!(error.kind(), ErrorKind::Io { source, .. } if source.kind() == std::io::ErrorKind::AlreadyExists), + "{error:?}" + ); + assert_eq!(std::fs::read(test.path().join("old"))?, b"old"); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn persist_replacing_moves_the_content_to_the_destination() -> anyhow::Result<()> { + // Catches a persist that renames something other than the given file, or doesn't replace. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + base.create_file("old", &user_rule(), FileOptions::new())? + .write_all(b"old")?; + let mut new = base.create_file("new", &user_rule(), FileOptions::new())?; + new.write_all(b"new")?; + base.persist(&new, "new", "old", Replace::Allow)?; + drop(new); + let mut content = Vec::new(); + base.open_file("old", &user_rule(), FileOptions::new().read(true))? + .context("the destination exists")? + .read_to_end(&mut content)?; + assert_eq!(content, b"new"); + assert!(!test.path().join("new").exists()); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn rename_refusing_to_replace_fails_on_an_existing_entry() -> anyhow::Result<()> { + // Catches a rename without replacement that overwrites the destination. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + base.create_file("a", &user_rule(), FileOptions::new())? + .write_all(b"a")?; + base.create_file("b", &user_rule(), FileOptions::new())? + .write_all(b"b")?; + let error = refusal(base.rename("a", "b", Replace::Refuse)); + assert!( + matches!(error.kind(), ErrorKind::Io { source, .. } if source.kind() == std::io::ErrorKind::AlreadyExists), + "{error:?}" + ); + assert_eq!(std::fs::read(test.path().join("b"))?, b"b"); + base.rename("a", "c", Replace::Refuse)?; + assert_eq!(std::fs::read(test.path().join("c"))?, b"a"); + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §mechanisms +#[test] +fn entries_lists_each_entry_with_its_kind() -> anyhow::Result<()> { + // Catches a listing that follows links or misses entries. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + drop(base.create_file("file", &user_rule(), FileOptions::new())?); + drop(base.create_dir("directory", &user_rule(), Existing::Check)?); + let mut expected = vec![ + ("directory".to_owned(), EntryKind::Directory), + ("file".to_owned(), EntryKind::File), + ]; + if std::os::windows::fs::symlink_dir(test.path().join("directory"), test.path().join("link")).is_ok() { + expected.push(("link".to_owned(), EntryKind::Link)); + } + // The second listing checks that each listing starts from the first entry. + for _ in 0..2 { + let mut entries: Vec<_> = base + .entries()? + .into_iter() + .map(|entry| (entry.name.into_string().expect("UTF-16 name"), entry.kind)) + .collect(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + assert_eq!(entries, expected); + } + Ok(()) +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn remove_file_reports_whether_the_file_existed() -> anyhow::Result<()> { + // Catches a removal that fails on a missing file or leaves the file. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + drop(base.create_file("file", &user_rule(), FileOptions::new())?); + assert!(base.remove_file("file")?); + assert!(!test.path().join("file").exists()); + assert!(!base.remove_file("file")?); + Ok(()) +} + +// oracle: spec MS-ERREF §2.2 ERROR_SHARING_VIOLATION +#[test] +fn an_exclusive_open_makes_another_open_fail_with_a_sharing_violation() -> anyhow::Result<()> { + // Catches an NTSTATUS mapped to an error code callers can't match, since they retry on a sharing violation. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + let _exclusive = base.create_file("file", &user_rule(), FileOptions::new().share(Share::Exclusive))?; + let error = refusal(base.open_file("file", &user_rule(), FileOptions::new().read(true))); + assert!( + matches!(error.kind(), ErrorKind::Io { source, .. } if source.raw_os_error() == Some(SHARING_VIOLATION)), + "{error:?}" + ); + Ok(()) +} + +// oracle: spec MS-FSCC §2.1.5 Pathname +#[test] +fn a_name_with_a_stream_separator_is_refused() -> anyhow::Result<()> { + // Catches a name that opens an alternate data stream of another entry, which has that entry's access. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + let error = refusal(base.create_file("file:stream", &user_rule(), FileOptions::new())); + assert!( + matches!(error.kind(), ErrorKind::Io { source, .. } if source.kind() == std::io::ErrorKind::InvalidInput), + "{error:?}" + ); + assert!(!test.path().join("file").exists()); + Ok(()) +} From 16372ca7b49f4b3f12f9b889f6c257cdea4bce13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 16:01:25 +0900 Subject: [PATCH 05/60] feat(private-fs): implement the Linux and macOS backend Directories and files are created and opened relative to checked directory handles with the openat family, never by resolving a path again, and each is checked against its rule: owner, group and other bits, Linux POSIX ACLs read from their extended attributes, macOS extended ACLs, links, hard links, file type and volumes that ignore or fake ownership. A base directory's first existing directory and those above it on its resolved path are checked as OpenSSH's StrictModes does, so another account that could rename or replace one of them is refused, while write access limited by the sticky bit is not. New directories get mode 0700 and new files 0600, under a parent without inheritable ACLs, and directories and renames are synced so they survive a power cut. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/linux.rs | 514 +++++++++++++++++ crates/private-fs/src/macos.rs | 249 ++++++-- crates/private-fs/src/unix.rs | 893 ++++++++++++++++++++++++++++- testsuite/tests/private_fs/unix.rs | 580 +++++++++++++++++++ 4 files changed, 2175 insertions(+), 61 deletions(-) diff --git a/crates/private-fs/src/linux.rs b/crates/private-fs/src/linux.rs index 763366e849..0c87c87a74 100644 --- a/crates/private-fs/src/linux.rs +++ b/crates/private-fs/src/linux.rs @@ -1 +1,515 @@ //! Linux ACLs and volumes. +//! +//! POSIX ACLs are read from the `system.posix_acl_access` and `system.posix_acl_default` extended attributes, in the +//! format of the kernel's [`include/uapi/linux/posix_acl_xattr.h`]: a 4-byte little-endian version, 2, then 8-byte +//! entries of a 2-byte tag, a 2-byte permission set and a 4-byte user or group ID, all little-endian. +//! +//! [`include/uapi/linux/posix_acl_xattr.h`]: https://github.com/torvalds/linux/blob/master/include/uapi/linux/posix_acl_xattr.h + +use std::ffi::{CStr, CString}; +use std::fs::File; +use std::io; +use std::mem::MaybeUninit; +use std::os::fd::AsRawFd as _; + +use camino::Utf8Path; + +use crate::unix::{Allowed, admin_gid}; +use crate::{Error, Refusal, Result}; + +/// Groups whose write access to a directory above doesn't count: root's. +pub(crate) const ADMIN_GIDS: &[libc::gid_t] = &[0]; + +const ACCESS_ACL: &CStr = c"system.posix_acl_access"; +const DEFAULT_ACL: &CStr = c"system.posix_acl_default"; +const ACL_VERSION: u32 = 2; +/// `XATTR_SIZE_MAX`, the largest extended attribute value the kernel stores. +const XATTR_SIZE_MAX: usize = 65536; + +const ACL_USER_OBJ: u16 = 0x01; +const ACL_USER: u16 = 0x02; +const ACL_GROUP_OBJ: u16 = 0x04; +const ACL_GROUP: u16 = 0x08; +const ACL_MASK: u16 = 0x10; +const ACL_OTHER: u16 = 0x20; +const ACL_WRITE: u16 = 0x02; +/// Read, write and execute, the permissions a missing mask leaves. +const ACL_ALL: u16 = 0x07; + +/// The `statfs` types of volumes that ignore or fake ownership: FUSE, CIFS, SMB2, SMB, NFS, 9P, vboxsf, MSDOS/vfat and +/// exFAT. +const IGNORING_VOLUMES: [u32; 9] = [ + 0x6573_5546, + 0xFF53_4D42, + 0xFE53_4D42, + 0x0000_517B, + 0x0000_6969, + 0x0102_1997, + 0x786F_4256, + 0x0000_4D44, + 0x2011_BAB0, +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct AclEntry { + tag: u16, + perm: u16, + id: u32, +} + +/// The access ACL of a directory above, if it has one. +pub(crate) struct AncestorAcl(Option>); + +pub(crate) fn check_volume(file: &File, path: &Utf8Path) -> Result<()> { + let mut info = MaybeUninit::::uninit(); + // SAFETY: The descriptor is open and the output buffer is writable. + if unsafe { libc::fstatfs(file.as_raw_fd(), info.as_mut_ptr()) } != 0 { + return Err(Error::io(path, "inspect volume", io::Error::last_os_error())); + } + // SAFETY: fstatfs initialized the output buffer on success. + let info = unsafe { info.assume_init() }; + // UNTESTED: a file on one of the listed volume types is refused. + // CI has no such volume; on one, the owner and mode that the other checks read are made up by the driver. + // When changing this, check a file on a FUSE or SMB mount and on a vfat image, and one on ext4. + if ignores_ownership(i128::from(info.f_type)) { + return Err(Error::refused(path, Refusal::Volume)); + } + Ok(()) +} + +/// Reports whether `f_type`, of the platform's signed or unsigned width, is the type of a volume that ignores or fakes +/// ownership. +fn ignores_ownership(f_type: i128) -> bool { + // A 32-bit platform stores the type in a signed 32-bit word, so a type such as CIFS's reads as negative there. + let f_type = f_type & 0xFFFF_FFFF; + IGNORING_VOLUMES.iter().any(|&magic| i128::from(magic) == f_type) +} + +pub(crate) fn check_no_extended_acl(file: &File, path: &Utf8Path, is_dir: bool) -> Result<()> { + let read_error = |error| Error::io(path, "read access control list", error); + if let Some(entries) = read_acl(file, ACCESS_ACL).map_err(read_error)? + && entries + .iter() + .any(|entry| !matches!(entry.tag, ACL_USER_OBJ | ACL_GROUP_OBJ | ACL_OTHER)) + { + return Err(Error::refused(path, Refusal::Access)); + } + if is_dir && read_acl(file, DEFAULT_ACL).map_err(read_error)?.is_some() { + return Err(Error::refused(path, Refusal::Access)); + } + Ok(()) +} + +/// Refuses to create an entry in `parent` when it has a default ACL, which the entry would inherit. +pub(crate) fn refuse_inheritable_acl(parent: &File, path: &Utf8Path) -> Result<()> { + let default = read_acl(parent, DEFAULT_ACL).map_err(|error| Error::io(path, "read access control list", error))?; + if default.is_some() { + return Err(Error::refused(path, Refusal::Access)); + } + Ok(()) +} + +pub(crate) fn remove_extended_acl(file: &File, path: &Utf8Path) -> Result<()> { + for name in [ACCESS_ACL, DEFAULT_ACL] { + // SAFETY: The descriptor is open and the name is NUL-terminated. + if unsafe { libc::fremovexattr(file.as_raw_fd(), name.as_ptr()) } != 0 { + let error = io::Error::last_os_error(); + if !no_acl(&error) { + return Err(Error::io(path, "remove access control list", error)); + } + } + } + Ok(()) +} + +pub(crate) fn ancestor_acl_of_file(file: &File) -> io::Result { + read_acl(file, ACCESS_ACL).map(AncestorAcl) +} + +pub(crate) fn ancestor_acl_of_path(path: &Utf8Path) -> io::Result { + let path = CString::new(path.as_str()).map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + read_xattr(|value, size| { + // SAFETY: The path and name are NUL-terminated, and `value` is writable for `size` bytes. + unsafe { libc::lgetxattr(path.as_ptr(), ACCESS_ACL.as_ptr(), value, size) } + }) + .map(AncestorAcl) +} + +/// Reports whether a named user or group entry of `acl` lets an account that `allowed` doesn't name write, so rename or +/// replace entries, unless the sticky bit limits writers to adding entries and removing their own. +pub(crate) fn acl_lets_replace(acl: &AncestorAcl, sticky: bool, allowed: &Allowed) -> bool { + let Some(entries) = &acl.0 else { + return false; + }; + if sticky { + return false; + } + let mask = mask(entries); + entries.iter().any(|entry| { + entry.perm & mask & ACL_WRITE != 0 + && match entry.tag { + ACL_USER => !allowed.uid(entry.id), + ACL_GROUP => !admin_gid(entry.id), + _ => false, + } + }) +} + +/// Reports whether the owning group can write, from the ACL's group entry and mask, when the directory has an ACL. +/// +/// With an ACL, the mode's group bits show the mask rather than the owning group's access. +pub(crate) fn owning_group_writes(acl: &AncestorAcl) -> Option { + let entries = acl.0.as_ref()?; + let group = entries.iter().find(|entry| entry.tag == ACL_GROUP_OBJ)?; + Some(group.perm & mask(entries) & ACL_WRITE != 0) +} + +pub(crate) fn rename_exclusive(fd: libc::c_int, from: &CStr, to: &CStr) -> io::Result<()> { + // The raw system call avoids glibc's renameat2 wrapper, which needs glibc 2.28; the release baseline is 2.27. + // SAFETY: The descriptor is open and both names are NUL-terminated. + let status = unsafe { + libc::syscall( + libc::SYS_renameat2, + fd, + from.as_ptr(), + fd, + to.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + if status != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +pub(crate) fn clear_errno() { + // SAFETY: __errno_location has no preconditions and returns the calling thread's errno. + let errno = unsafe { libc::__errno_location() }; + // SAFETY: The pointer is valid for the calling thread. + unsafe { *errno = 0 }; +} + +fn mask(entries: &[AclEntry]) -> u16 { + entries + .iter() + .find(|entry| entry.tag == ACL_MASK) + .map_or(ACL_ALL, |entry| entry.perm) +} + +/// Reads the ACL in the extended attribute `name` of `file`, and returns `None` when it has none. +fn read_acl(file: &File, name: &CStr) -> io::Result>> { + read_xattr(|value, size| { + // SAFETY: The descriptor is open, the name is NUL-terminated, and `value` is writable for `size` bytes. + unsafe { libc::fgetxattr(file.as_raw_fd(), name.as_ptr(), value, size) } + }) +} + +/// Reads an ACL attribute with `get`, which takes a buffer and its size as `getxattr` does. +fn read_xattr(get: impl FnOnce(*mut libc::c_void, usize) -> isize) -> io::Result>> { + let mut buffer = vec![0u8; XATTR_SIZE_MAX]; + let size = get(buffer.as_mut_ptr().cast(), buffer.len()); + let Ok(size) = usize::try_from(size) else { + let error = io::Error::last_os_error(); + return if no_acl(&error) { Ok(None) } else { Err(error) }; + }; + buffer.truncate(size); + parse_acl(&buffer).map(Some) +} + +fn no_acl(error: &io::Error) -> bool { + matches!(error.raw_os_error(), Some(libc::ENODATA | libc::EOPNOTSUPP)) + || error.raw_os_error() == Some(libc::ENOTSUP) +} + +fn parse_acl(bytes: &[u8]) -> io::Result> { + let invalid = || io::Error::new(io::ErrorKind::InvalidData, "malformed POSIX ACL attribute"); + let (version, entries) = bytes.split_first_chunk::<4>().ok_or_else(invalid)?; + if u32::from_le_bytes(*version) != ACL_VERSION || entries.len() % 8 != 0 { + return Err(invalid()); + } + entries + .chunks_exact(8) + .map(|entry| { + let entry = AclEntry { + tag: u16::from_le_bytes([entry[0], entry[1]]), + perm: u16::from_le_bytes([entry[2], entry[3]]), + id: u32::from_le_bytes([entry[4], entry[5], entry[6], entry[7]]), + }; + match entry.tag { + ACL_USER_OBJ | ACL_USER | ACL_GROUP_OBJ | ACL_GROUP | ACL_MASK | ACL_OTHER => Ok(entry), + _ => Err(invalid()), + } + }) + .collect() +} + +#[cfg(test)] +impl AncestorAcl { + pub(crate) fn empty() -> Self { + Self(None) + } +} + +#[cfg(test)] +mod tests { + use std::io::Write as _; + + use super::*; + + const NOBODY: u32 = 65534; + + fn entry(tag: u16, perm: u16, id: u32) -> [u8; 8] { + let mut bytes = [0; 8]; + bytes[..2].copy_from_slice(&tag.to_le_bytes()); + bytes[2..4].copy_from_slice(&perm.to_le_bytes()); + bytes[4..].copy_from_slice(&id.to_le_bytes()); + bytes + } + + fn attribute(entries: &[[u8; 8]]) -> Vec { + let mut bytes = 2u32.to_le_bytes().to_vec(); + entries.iter().for_each(|entry| bytes.extend_from_slice(entry)); + bytes + } + + fn ancestor(entries: &[[u8; 8]]) -> AncestorAcl { + AncestorAcl(Some(parse_acl(&attribute(entries)).expect("valid ACL"))) + } + + /// `setfacl -m u:nobody:rwx` on a directory with mode 0755: owner rwx, nobody rwx, group r-x, mask rwx, other r-x. + fn nobody_can_write() -> [[u8; 8]; 5] { + [ + entry(ACL_USER_OBJ, 7, u32::MAX), + entry(ACL_USER, 7, NOBODY), + entry(ACL_GROUP_OBJ, 5, u32::MAX), + entry(ACL_MASK, 7, u32::MAX), + entry(ACL_OTHER, 5, u32::MAX), + ] + } + + // A parser that misread the header or an entry would hide a named entry from the checks. + // oracle: spec Linux include/uapi/linux/posix_acl_xattr.h + #[test] + fn parses_the_kernel_attribute_format() { + // Version 2, then user nobody with read and write: tag 0x0002, perm 0x0006, ID 65534 (0x0000FFFE). + let bytes = [2, 0, 0, 0, 0x02, 0x00, 0x06, 0x00, 0xFE, 0xFF, 0x00, 0x00]; + assert_eq!( + parse_acl(&bytes).expect("valid ACL"), + [AclEntry { + tag: ACL_USER, + perm: 6, + id: NOBODY + }] + ); + } + + // oracle: spec Linux include/uapi/linux/posix_acl_xattr.h + #[test] + fn rejects_an_attribute_it_cannot_read() { + // Version 1. + assert!(parse_acl(&[1, 0, 0, 0]).is_err()); + // A truncated entry. + assert!(parse_acl(&[2, 0, 0, 0, 1, 0, 7, 0]).is_err()); + // A tag the format doesn't define. + assert!(parse_acl(&attribute(&[entry(0x40, 7, 0)])).is_err()); + assert!(parse_acl(&[]).is_err()); + } + + // StrictModes: a named user who can write to a directory above can rename or replace its entries. + // oracle: intent crates/private-fs/INTENT.md §paths + #[test] + fn a_named_user_who_can_write_makes_a_directory_replaceable() { + let allowed = Allowed::new_for_tests(Some(1000)); + assert!(acl_lets_replace(&ancestor(&nobody_can_write()), false, &allowed)); + // The sticky bit limits nobody to adding entries and removing its own. + assert!(!acl_lets_replace(&ancestor(&nobody_can_write()), true, &allowed)); + // A mask without write takes write away from named entries. + let mut masked = nobody_can_write(); + masked[3] = entry(ACL_MASK, 5, u32::MAX); + assert!(!acl_lets_replace(&ancestor(&masked), false, &allowed)); + // The current user, named by the rule, may write. + let mut current_user = nobody_can_write(); + current_user[1] = entry(ACL_USER, 7, 1000); + assert!(!acl_lets_replace(&ancestor(¤t_user), false, &allowed)); + } + + // oracle: intent crates/private-fs/INTENT.md §paths + #[test] + fn a_named_group_other_than_root_that_can_write_makes_a_directory_replaceable() { + let allowed = Allowed::new_for_tests(None); + let mut group = nobody_can_write(); + group[1] = entry(ACL_GROUP, 7, 100); + assert!(acl_lets_replace(&ancestor(&group), false, &allowed)); + group[1] = entry(ACL_GROUP, 7, 0); + assert!(!acl_lets_replace(&ancestor(&group), false, &allowed)); + } + + // With an ACL, the mode's group bits are the mask, so the owning group's write comes from its entry. + // oracle: spec acl(5) §ACCESS CHECK ALGORITHM + #[test] + fn the_owning_group_writes_only_through_its_entry_and_the_mask() { + assert_eq!(owning_group_writes(&ancestor(&nobody_can_write())), Some(false)); + let mut group_writes = nobody_can_write(); + group_writes[2] = entry(ACL_GROUP_OBJ, 7, u32::MAX); + assert_eq!(owning_group_writes(&ancestor(&group_writes)), Some(true)); + group_writes[3] = entry(ACL_MASK, 5, u32::MAX); + assert_eq!(owning_group_writes(&ancestor(&group_writes)), Some(false)); + assert_eq!(owning_group_writes(&AncestorAcl(None)), None); + } + + // oracle: spec Linux include/uapi/linux/magic.h + #[test] + fn recognizes_volumes_that_fake_ownership() { + // CIFS_SUPER_MAGIC as a 64-bit platform reports it, and as a 32-bit one does, sign-extended. + assert!(ignores_ownership(0xFF53_4D42)); + assert!(ignores_ownership(i128::from(0xFF53_4D42u32.cast_signed()))); + // EXT4_SUPER_MAGIC and TMPFS_MAGIC. + assert!(!ignores_ownership(0xEF53)); + assert!(!ignores_ownership(0x0102_1994)); + } + + /// A directory under the system temporary directory, removed when dropped. + struct Scratch(camino::Utf8PathBuf); + + impl Scratch { + fn new(test: &str) -> Self { + let path = camino::Utf8PathBuf::from_path_buf(std::env::temp_dir()) + .expect("UTF-8 temporary directory") + .join(format!("private-fs-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir(&path).expect("create a scratch directory"); + set_mode(&path, 0o700); + Self(path) + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn set_mode(path: &Utf8Path, mode: u32) { + std::fs::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(mode)).expect("set a test mode"); + } + + fn rule() -> crate::Rule { + crate::Rule::new() + .owner(crate::Account::CurrentUser) + .grant(crate::Account::CurrentUser, crate::Access::Full) + } + + /// Sets the ACL attribute `name` of `path` as `setfacl` would, and returns `false` when the volume has no ACLs. + fn set_acl(path: &Utf8Path, name: &CStr, entries: &[[u8; 8]]) -> bool { + let path = CString::new(path.as_str()).expect("NUL-free path"); + let value = attribute(entries); + // SAFETY: The path and name are NUL-terminated, and the value is readable for its length. + let status = unsafe { libc::setxattr(path.as_ptr(), name.as_ptr(), value.as_ptr().cast(), value.len(), 0) }; + if status == 0 { + return true; + } + let error = io::Error::last_os_error(); + assert!(no_acl(&error), "set a test ACL: {error}"); + // The test harness captures `eprintln!` output from a passing test, but not direct writes to stderr. + let _ = writeln!(io::stderr(), "SKIP: the temporary directory's volume has no POSIX ACLs"); + false + } + + fn refusal(error: &Error) -> Option<&Refusal> { + match error.kind() { + crate::ErrorKind::Refused(refusal) => Some(refusal), + crate::ErrorKind::Io { .. } | crate::ErrorKind::Unsupported { .. } => None, + } + } + + /// `setfacl -m u:nobody:r,m::-` on a file with mode 0600: nobody is named, and the mask leaves it nothing, so the + /// mode still reads 0600. + fn nobody_named() -> [[u8; 8]; 5] { + [ + entry(ACL_USER_OBJ, 6, u32::MAX), + entry(ACL_USER, 4, NOBODY), + entry(ACL_GROUP_OBJ, 0, u32::MAX), + entry(ACL_MASK, 0, u32::MAX), + entry(ACL_OTHER, 0, u32::MAX), + ] + } + + // A named entry is an access the mode doesn't show, and a mask change can open it up. + // oracle: intent crates/private-fs/INTENT.md §mechanisms + #[test] + fn a_file_with_a_named_acl_entry_is_refused() { + let scratch = Scratch::new("named-entry"); + let base = crate::Dir::create_base(&scratch.0, &rule()).expect("open the scratch directory"); + drop( + base.create_file("key", &rule(), crate::FileOptions::new()) + .expect("create a file"), + ); + if !set_acl(&scratch.0.join("key"), ACCESS_ACL, &nobody_named()) { + return; + } + assert_eq!( + std::os::unix::fs::MetadataExt::mode(&std::fs::metadata(scratch.0.join("key")).expect("inspect")) & 0o777, + 0o600 + ); + + let error = base + .open_file("key", &rule(), crate::FileOptions::new()) + .expect_err("refused"); + assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); + } + + // A new entry would inherit the default ACL, and so not have exactly its rule from the moment it exists. + // oracle: intent crates/private-fs/INTENT.md §paths + #[test] + fn a_default_acl_refuses_creation_until_repaired() { + let scratch = Scratch::new("default-acl"); + let base = crate::Dir::create_base(&scratch.0, &rule()).expect("open the scratch directory"); + let parent = base + .create_dir("parent", &rule(), crate::Existing::Check) + .expect("create a directory"); + let default = [ + entry(ACL_USER_OBJ, 7, u32::MAX), + entry(ACL_GROUP_OBJ, 0, u32::MAX), + entry(ACL_OTHER, 0, u32::MAX), + ]; + if !set_acl(&scratch.0.join("parent"), DEFAULT_ACL, &default) { + return; + } + + let error = parent + .create_file("file", &rule(), crate::FileOptions::new()) + .expect_err("refused"); + assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); + assert!(!scratch.0.join("parent").join("file").exists()); + let error = parent.check(&rule()).expect_err("refused"); + assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); + + parent.repair(&rule()).expect("repair"); + drop( + parent + .create_file("file", &rule(), crate::FileOptions::new()) + .expect("create a file"), + ); + } + + // StrictModes: a named user who can write to a directory above can rename or replace its entries. + // oracle: intent crates/private-fs/INTENT.md §paths + #[test] + fn a_directory_above_that_a_named_user_can_write_is_refused() { + let scratch = Scratch::new("ancestor-acl"); + let shared = scratch.0.join("shared"); + std::fs::create_dir(&shared).expect("create a directory"); + set_mode(&shared, 0o755); + // The mask makes the mode read 0775, but the owning group can't write: only the named entry can. + if !set_acl(&shared, ACCESS_ACL, &nobody_can_write()) { + return; + } + + let error = crate::Dir::create_base(&shared.join("base"), &rule()).expect_err("refused"); + assert!(matches!(refusal(&error), Some(Refusal::Replaceable)), "{error:?}"); + + set_mode(&shared, 0o1775); + crate::Dir::create_base(&shared.join("base"), &rule()).expect("accepted with the sticky bit"); + } +} diff --git a/crates/private-fs/src/macos.rs b/crates/private-fs/src/macos.rs index 7dfc1bda42..cf4227ef4f 100644 --- a/crates/private-fs/src/macos.rs +++ b/crates/private-fs/src/macos.rs @@ -1,78 +1,245 @@ -use std::ffi::c_void; +//! macOS ACLs and volumes. +//! +//! The ACL constants are those of Apple's [`sys/acl.h`]. +//! +//! [`sys/acl.h`]: https://github.com/apple-oss-distributions/Libc/blob/main/include/sys/acl.h + +use std::ffi::{CStr, CString, c_void}; use std::fs::File; use std::io; +use std::mem::MaybeUninit; use std::os::fd::AsRawFd as _; use std::ptr::NonNull; +use camino::Utf8Path; + +use crate::unix::Allowed; +use crate::{Error, Refusal, Result}; + +/// Groups whose write access to a directory above doesn't count: root's (`wheel`) and `admin`. +pub(crate) const ADMIN_GIDS: &[libc::gid_t] = &[0, 80]; + const ACL_TYPE_EXTENDED: libc::c_int = 0x100; const ACL_FIRST_ENTRY: libc::c_int = 0; +const ACL_NEXT_ENTRY: libc::c_int = -1; +const ACL_EXTENDED_ALLOW: libc::c_int = 1; +const ACL_DELETE: libc::c_int = 1 << 4; +const ACL_DELETE_CHILD: libc::c_int = 1 << 6; +const ACL_WRITE_SECURITY: libc::c_int = 1 << 12; +const ACL_CHANGE_OWNER: libc::c_int = 1 << 13; +const ACL_ENTRY_FILE_INHERIT: libc::c_int = 1 << 5; +const ACL_ENTRY_DIRECTORY_INHERIT: libc::c_int = 1 << 6; unsafe extern "C" { fn acl_get_fd_np(fd: libc::c_int, acl_type: libc::c_int) -> *mut c_void; + fn acl_get_link_np(path: *const libc::c_char, acl_type: libc::c_int) -> *mut c_void; fn acl_get_entry(acl: *mut c_void, entry_id: libc::c_int, entry: *mut *mut c_void) -> libc::c_int; - fn acl_valid(acl: *mut c_void) -> libc::c_int; - fn acl_free(acl: *mut c_void) -> libc::c_int; + fn acl_get_tag_type(entry: *mut c_void, tag: *mut libc::c_int) -> libc::c_int; + fn acl_get_flagset_np(object: *mut c_void, flagset: *mut *mut c_void) -> libc::c_int; + fn acl_get_flag_np(flagset: *mut c_void, flag: libc::c_int) -> libc::c_int; + fn acl_get_permset(entry: *mut c_void, permset: *mut *mut c_void) -> libc::c_int; + fn acl_get_perm_np(permset: *mut c_void, perm: libc::c_int) -> libc::c_int; + fn acl_init(count: libc::c_int) -> *mut c_void; + fn acl_set_fd_np(fd: libc::c_int, acl: *mut c_void, acl_type: libc::c_int) -> libc::c_int; + fn acl_free(object: *mut c_void) -> libc::c_int; +} + +/// What the checks need to know of an extended ACL. +#[derive(Clone, Copy, Debug, Default)] +struct AclSummary { + has_entries: bool, + /// An entry that new files or directories inherit. + inheritable: bool, + /// An allow entry that lets its account delete, delete children, change the ACL or take ownership. + replace_allowed: bool, +} + +/// What a directory above's ACL lets other accounts do. +pub(crate) struct AncestorAcl { + replace_allowed: bool, } struct Acl(NonNull); impl Drop for Acl { fn drop(&mut self) { - // SAFETY: acl_get_fd_np returned this owned ACL. + // SAFETY: acl_get_fd_np, acl_get_link_np or acl_init returned this ACL, and it is freed only here. let _ = unsafe { acl_free(self.0.as_ptr()) }; } } -/// Reports whether the volume holding `file` ignores file ownership. -/// -/// UNTESTED: a volume with "Ignore ownership on this volume" set reports `true`, and the boot volume `false`. -/// No CI job runs tests on macOS, and a test would need such a volume; when changing this, check both on a Mac, because -/// on a volume that ignores ownership every owner check of the callers passes whatever the file's owner. -pub fn volume_ignores_ownership(file: &File) -> io::Result { - let mut info = std::mem::MaybeUninit::::uninit(); - // SAFETY: The file stays open and the output buffer is writable. +pub(crate) fn check_volume(file: &File, path: &Utf8Path) -> Result<()> { + let mut info = MaybeUninit::::uninit(); + // SAFETY: The descriptor is open and the output buffer is writable. if unsafe { libc::fstatfs(file.as_raw_fd(), info.as_mut_ptr()) } != 0 { - return Err(io::Error::last_os_error()); + return Err(Error::io(path, "inspect volume", io::Error::last_os_error())); } // SAFETY: fstatfs initialized the output buffer on success. let info = unsafe { info.assume_init() }; - Ok(info.f_flags & libc::MNT_IGNORE_OWNERSHIP.cast_unsigned() != 0) + // UNTESTED: a volume with "Ignore ownership on this volume" set is refused, and the boot volume is not. + // CI has no such volume; on one, every owner check passes whatever the file's owner. + // When changing this, check both on a Mac, for example on a disk image after `diskutil disableOwnership`. + if info.f_flags & libc::MNT_IGNORE_OWNERSHIP.cast_unsigned() != 0 { + return Err(Error::refused(path, Refusal::Volume)); + } + Ok(()) +} + +pub(crate) fn check_no_extended_acl(file: &File, path: &Utf8Path, _is_dir: bool) -> Result<()> { + let summary = acl_of_file(file).map_err(|error| Error::io(path, "read access control list", error))?; + if summary.has_entries { + return Err(Error::refused(path, Refusal::Access)); + } + Ok(()) +} + +/// Refuses to create an entry in `parent` when an entry of its ACL is inheritable, because the new entry would +/// inherit it. +pub(crate) fn refuse_inheritable_acl(parent: &File, path: &Utf8Path) -> Result<()> { + let summary = acl_of_file(parent).map_err(|error| Error::io(path, "read access control list", error))?; + if summary.inheritable { + return Err(Error::refused(path, Refusal::Access)); + } + Ok(()) +} + +pub(crate) fn remove_extended_acl(file: &File, path: &Utf8Path) -> Result<()> { + let remove_error = |error| Error::io(path, "remove access control list", error); + // SAFETY: acl_init has no preconditions. + let empty = unsafe { acl_init(0) }; + let empty = Acl(NonNull::new(empty).ok_or_else(|| remove_error(io::Error::last_os_error()))?); + // SAFETY: The descriptor is open and the ACL is live. + if unsafe { acl_set_fd_np(file.as_raw_fd(), empty.0.as_ptr(), ACL_TYPE_EXTENDED) } != 0 { + return Err(remove_error(io::Error::last_os_error())); + } + Ok(()) +} + +pub(crate) fn ancestor_acl_of_file(file: &File) -> io::Result { + acl_of_file(file).map(|summary| AncestorAcl { + replace_allowed: summary.replace_allowed, + }) } -/// The step of [`has_extended_acl`] that failed. -#[derive(Debug)] -pub enum ExtendedAclError { - Inspect(io::Error), - Validate(io::Error), - ReadEntries(io::Error), +/// Reads the ACL at `path` without following a link. +/// +/// The caller compares the directory at `path` before and after, because a missing ACL and a missing path both read +/// as `ENOENT`. +pub(crate) fn ancestor_acl_of_path(path: &Utf8Path) -> io::Result { + let path = CString::new(path.as_str()).map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + // SAFETY: The path is NUL-terminated. + let acl = unsafe { acl_get_link_np(path.as_ptr(), ACL_TYPE_EXTENDED) }; + summarize(acl).map(|summary| AncestorAcl { + replace_allowed: summary.replace_allowed, + }) } -/// Reports whether `file` has extended ACL entries. +/// Reports whether an allow entry lets an account rename or replace a directory or its entries. /// -/// UNTESTED: a file without an ACL reports `false`, a file after `chmod +a` reports `true`, and the same file after -/// `chmod -N` reports `false` again. -/// No CI job runs tests on macOS; when changing this, check the three cases on a Mac, because an extended ACL entry can -/// grant another account access that the mode bits the callers check don't show. -pub fn has_extended_acl(file: &File) -> Result { - // SAFETY: The file descriptor is live and ACL_TYPE_EXTENDED is supported on macOS. - let raw = unsafe { acl_get_fd_np(file.as_raw_fd(), ACL_TYPE_EXTENDED) }; - let Some(raw) = NonNull::new(raw) else { +/// The sticky bit doesn't limit what an ACL entry allows, and the check counts an entry for any account, the rule's +/// included. +pub(crate) fn acl_lets_replace(acl: &AncestorAcl, _sticky: bool, _allowed: &Allowed) -> bool { + acl.replace_allowed +} + +/// The mode's group bits are the owning group's access on macOS. +pub(crate) fn owning_group_writes(_acl: &AncestorAcl) -> Option { + None +} + +pub(crate) fn rename_exclusive(fd: libc::c_int, from: &CStr, to: &CStr) -> io::Result<()> { + // SAFETY: The descriptor is open and both names are NUL-terminated. + if unsafe { libc::renameatx_np(fd, from.as_ptr(), fd, to.as_ptr(), libc::RENAME_EXCL) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +pub(crate) fn clear_errno() { + // SAFETY: __error has no preconditions and returns the calling thread's errno. + let errno = unsafe { libc::__error() }; + // SAFETY: The pointer is valid for the calling thread. + unsafe { *errno = 0 }; +} + +fn acl_of_file(file: &File) -> io::Result { + // SAFETY: The descriptor is open. + let acl = unsafe { acl_get_fd_np(file.as_raw_fd(), ACL_TYPE_EXTENDED) }; + summarize(acl) +} + +/// Summarizes an ACL that `acl_get_fd_np` or `acl_get_link_np` returned, taking ownership of it. +fn summarize(acl: *mut c_void) -> io::Result { + let Some(acl) = NonNull::new(acl) else { let error = io::Error::last_os_error(); + // An item without an extended ACL reads as ENOENT. return match error.raw_os_error() { - Some(libc::ENOENT | libc::ENOATTR) => Ok(false), - _ => Err(ExtendedAclError::Inspect(error)), + Some(libc::ENOENT | libc::ENOATTR) => Ok(AclSummary::default()), + _ => Err(error), }; }; - let acl = Acl(raw); - // SAFETY: The ACL remains live and was returned by acl_get_fd_np. - if unsafe { acl_valid(acl.0.as_ptr()) } != 0 { - return Err(ExtendedAclError::Validate(io::Error::last_os_error())); + let acl = Acl(acl); + let mut summary = AclSummary::default(); + let mut entry_id = ACL_FIRST_ENTRY; + loop { + let mut entry = std::ptr::null_mut(); + // SAFETY: The ACL is live and the output pointer is writable. + if unsafe { acl_get_entry(acl.0.as_ptr(), entry_id, &mut entry) } != 0 { + let error = io::Error::last_os_error(); + // Reading past the last entry fails with EINVAL. + if error.raw_os_error() == Some(libc::EINVAL) { + return Ok(summary); + } + return Err(error); + } + entry_id = ACL_NEXT_ENTRY; + summary.has_entries = true; + + let mut flagset = std::ptr::null_mut(); + // SAFETY: The entry belongs to the live ACL and the output pointer is writable. + if unsafe { acl_get_flagset_np(entry, &mut flagset) } != 0 { + return Err(io::Error::last_os_error()); + } + if flag(flagset, ACL_ENTRY_FILE_INHERIT)? || flag(flagset, ACL_ENTRY_DIRECTORY_INHERIT)? { + summary.inheritable = true; + } + + let mut tag = 0; + // SAFETY: The entry belongs to the live ACL and the output pointer is writable. + if unsafe { acl_get_tag_type(entry, &mut tag) } != 0 { + return Err(io::Error::last_os_error()); + } + if tag != ACL_EXTENDED_ALLOW { + continue; + } + let mut permset = std::ptr::null_mut(); + // SAFETY: The entry belongs to the live ACL and the output pointer is writable. + if unsafe { acl_get_permset(entry, &mut permset) } != 0 { + return Err(io::Error::last_os_error()); + } + for perm in [ACL_DELETE, ACL_DELETE_CHILD, ACL_WRITE_SECURITY, ACL_CHANGE_OWNER] { + // SAFETY: The permission set belongs to the live ACL. + match unsafe { acl_get_perm_np(permset, perm) } { + 0 => {} + 1 => summary.replace_allowed = true, + _ => return Err(io::Error::last_os_error()), + } + } } - let mut first_entry = std::ptr::null_mut(); - // SAFETY: The ACL is valid and the output pointer is writable. - match unsafe { acl_get_entry(acl.0.as_ptr(), ACL_FIRST_ENTRY, &mut first_entry) } { - 0 => Ok(true), - -1 if io::Error::last_os_error().raw_os_error() == Some(libc::EINVAL) => Ok(false), - _ => Err(ExtendedAclError::ReadEntries(io::Error::last_os_error())), +} + +fn flag(flagset: *mut c_void, flag: libc::c_int) -> io::Result { + // SAFETY: The flag set belongs to a live ACL. + match unsafe { acl_get_flag_np(flagset, flag) } { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(io::Error::last_os_error()), + } +} + +#[cfg(test)] +impl AncestorAcl { + pub(crate) fn empty() -> Self { + Self { replace_allowed: false } } } diff --git a/crates/private-fs/src/unix.rs b/crates/private-fs/src/unix.rs index fa0d7e79d9..014b506832 100644 --- a/crates/private-fs/src/unix.rs +++ b/crates/private-fs/src/unix.rs @@ -1,28 +1,167 @@ -//! Backend contract, implemented per platform. +//! Linux and macOS backend. +//! +//! The owner of a file or directory can always change its mode, so it always has full access, and root can access +//! everything whatever the mode says. +//! A rule therefore reduces to an owner, mode `0700` for a directory or `0600` for a file, and no extended ACL. -use std::fs::File; +use std::ffi::{CStr, CString, OsString}; +use std::fs::{File, Metadata, OpenOptions, Permissions}; +use std::io; +use std::mem::MaybeUninit; +use std::os::fd::{AsRawFd as _, FromRawFd as _, IntoRawFd as _, OwnedFd}; +use std::os::unix::ffi::OsStringExt as _; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::ptr::NonNull; -use camino::Utf8Path; +use camino::{Utf8Component, Utf8Path, Utf8PathBuf}; -use crate::{Entry, Existing, FileOptions, Replace, Result, Rule, Share}; +#[cfg(target_os = "linux")] +use crate::linux as os; +#[cfg(target_os = "macos")] +use crate::macos as os; +use crate::rule::Grant; +use crate::{ + Access, Account, Entry, EntryKind, Error, Existing, Fallback, FileOptions, Refusal, Replace, Result, Rule, Share, +}; + +/// The mode of a directory, owner-only. +const DIR_MODE: u32 = 0o700; +/// [`DIR_MODE`] as `mkdirat` takes it. +const MKDIR_MODE: libc::mode_t = 0o700; +/// The mode of a file, owner-only, as `openat` takes it. +const FILE_MODE: libc::c_uint = 0o600; +/// The permission bits of the group and other classes. +const GROUP_OTHER_BITS: u32 = 0o077; +const GROUP_WRITE: u32 = 0o020; +const OTHER_WRITE: u32 = 0o002; +const STICKY: u32 = 0o1000; #[derive(Debug)] -pub(crate) struct Dir; +pub(crate) struct Dir { + file: File, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Directory, + File, +} + +/// The accounts that may own a directory above a base directory, or be able to rename or replace it. +pub(crate) struct Allowed { + /// The effective user ID, when the rule names [`Account::CurrentUser`]. + current_user: Option, +} + +impl Allowed { + fn new(rule: &Rule) -> Self { + Self { + current_user: rule + .accounts() + .any(|account| account == Account::CurrentUser) + .then(effective_uid), + } + } + + /// Reports whether `uid` is root or an account of the rule; System and Administrators are root. + pub(crate) fn uid(&self, uid: libc::uid_t) -> bool { + uid == 0 || self.current_user == Some(uid) + } +} + +/// Reports whether `gid` is a group of administrators, whose write access to a directory above doesn't count. +pub(crate) fn admin_gid(gid: libc::gid_t) -> bool { + os::ADMIN_GIDS.contains(&gid) +} pub(crate) fn create_base(path: &Utf8Path, rule: &Rule) -> Result { - todo!() + let base = Base::open(path, rule)?; + let mut dir = base.dir; + let mut current = base.prefix; + for name in base.missing { + current.push(name); + dir = create_dir(&dir, ¤t, name, rule, Existing::Check)?; + } + Ok(dir) } pub(crate) fn open_base(path: &Utf8Path, rule: &Rule) -> Result> { - todo!() + let base = Base::open(path, rule)?; + let allowed = Allowed::new(rule); + let mut dir = base.dir; + let mut current = base.prefix; + for name in base.missing { + current.push(name); + let Some(file) = open_at( + &dir, + ¤t, + name, + libc::O_RDONLY | libc::O_DIRECTORY, + Kind::Directory, + )? + else { + return Ok(None); + }; + check_not_replaceable_handle(&file, ¤t, &allowed)?; + dir = Dir { file }; + } + Ok(Some(dir)) } pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, existing: Existing) -> Result { - todo!() + check_creatable(path, rule, Kind::Directory)?; + os::refuse_inheritable_acl(&parent.file, parent_path(path))?; + let c_name = c_name(path, name)?; + // SAFETY: The parent descriptor is open and the name is NUL-terminated. + let created = unsafe { libc::mkdirat(parent.file.as_raw_fd(), c_name.as_ptr(), MKDIR_MODE) } == 0; + if !created { + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::EEXIST) { + return Err(Error::io(path, "create directory", error)); + } + } + let Some(file) = open_at(parent, path, name, libc::O_RDONLY | libc::O_DIRECTORY, Kind::Directory)? else { + return Err(Error::io(path, "open directory", io::ErrorKind::NotFound.into())); + }; + let dir = Dir { file }; + if created { + if let Err(error) = protect_created_dir(&dir, path, rule) { + discard_entry(parent, &c_name, &dir.file, libc::AT_REMOVEDIR); + return Err(error); + } + } else { + match existing { + Existing::Check => check_dir(&dir, path, rule)?, + Existing::Repair => repair_dir(&dir, path, rule)?, + } + } + // UNTESTED: the new directory and its parent are synced before the directory is returned. + // Without it, a power cut can drop a directory whose files a caller already reported as written; no test can cut + // power. + // When changing this, keep syncing both, also for a directory another process created first. + dir.file + .sync_all() + .map_err(|error| Error::io(path, "sync directory", error))?; + parent + .file + .sync_all() + .map_err(|error| Error::io(parent_path(path), "sync directory", error))?; + Ok(dir) } -pub(crate) fn open_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, share: Share) -> Result> { - todo!() +pub(crate) fn open_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, _share: Share) -> Result> { + let Some(file) = open_at( + parent, + path, + name, + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NONBLOCK, + Kind::Directory, + )? + else { + return Ok(None); + }; + check_item(&file, path, rule, Kind::Directory)?; + Ok(Some(Dir { file })) } pub(crate) fn open_file( @@ -32,7 +171,13 @@ pub(crate) fn open_file( rule: &Rule, options: FileOptions, ) -> Result> { - todo!() + // O_NONBLOCK keeps a FIFO at the name from blocking the open; reads and writes of a regular file ignore it. + let flags = access_flags(options) | libc::O_NONBLOCK; + let Some(file) = open_at(parent, path, name, flags, Kind::File)? else { + return Ok(None); + }; + check_item(&file, path, rule, Kind::File)?; + Ok(Some(file)) } pub(crate) fn create_file( @@ -42,11 +187,47 @@ pub(crate) fn create_file( rule: &Rule, options: FileOptions, ) -> Result { - todo!() + check_creatable(path, rule, Kind::File)?; + os::refuse_inheritable_acl(&parent.file, parent_path(path))?; + let c_name = c_name(path, name)?; + let flags = + access_flags(options) | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NOCTTY; + // SAFETY: The parent descriptor is open, the name is NUL-terminated, and O_CREAT takes the mode argument. + let fd = unsafe { libc::openat(parent.file.as_raw_fd(), c_name.as_ptr(), flags, FILE_MODE) }; + if fd < 0 { + return Err(Error::io(path, "create file", io::Error::last_os_error())); + } + // SAFETY: openat returned a new descriptor that nothing else owns. + let file = File::from(unsafe { OwnedFd::from_raw_fd(fd) }); + if let Err(error) = check_item(&file, path, rule, Kind::File) { + discard_entry(parent, &c_name, &file, 0); + return Err(error); + } + Ok(file) } pub(crate) fn rename(parent: &Dir, path: &Utf8Path, from: &str, to: &str, replace: Replace) -> Result<()> { - todo!() + let from = c_name(path, from)?; + let to = c_name(path, to)?; + let fd = parent.file.as_raw_fd(); + let renamed = match replace { + // SAFETY: The descriptor is open and both names are NUL-terminated. + Replace::Allow => match unsafe { libc::renameat(fd, from.as_ptr(), fd, to.as_ptr()) } { + 0 => Ok(()), + _ => Err(io::Error::last_os_error()), + }, + Replace::Refuse => match os::rename_exclusive(fd, &from, &to) { + Err(error) if error.raw_os_error().is_some_and(rename_exclusive_unsupported) => { + // UNTESTED: without an exclusive rename, the entry is linked under its new name, which fails when + // the name exists, and then unlinked under its old one. + // The kernels and file systems CI runs on support the exclusive rename. + // When changing this, keep the link step failing on an existing name rather than replacing it. + link_then_unlink(fd, &from, &to) + } + renamed => renamed, + }, + }; + renamed.map_err(|error| Error::io(path, "rename", error)) } pub(crate) fn persist( @@ -57,29 +238,701 @@ pub(crate) fn persist( to: &str, replace: Replace, ) -> Result<()> { - todo!() + file.sync_all().map_err(|error| Error::io(path, "sync file", error))?; + rename(parent, path, from, to, replace)?; + // UNTESTED: the directory is synced after the rename, so the new name survives a power cut. + // No test can cut power; when changing this, keep the file sync before the rename and the directory sync after. + sync(parent, parent_path(path)) } pub(crate) fn remove_file(parent: &Dir, path: &Utf8Path, name: &str) -> Result { - todo!() + let c_name = c_name(path, name)?; + // SAFETY: The descriptor is open and the name is NUL-terminated. + if unsafe { libc::unlinkat(parent.file.as_raw_fd(), c_name.as_ptr(), 0) } != 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::NotFound { + return Ok(false); + } + return Err(Error::io(path, "remove file", error)); + } + sync(parent, parent_path(path))?; + Ok(true) +} + +/// Removes the file `name`, which `file` was created as, unless the name now holds another file. +#[expect(dead_code, reason = "Dir::discard is not wired yet")] +pub(crate) fn discard(parent: &Dir, path: &Utf8Path, file: &File, name: &str) -> Result<()> { + let c_name = c_name(path, name)?; + let created = fstat(file).map_err(|error| Error::io(path, "inspect file", error))?; + let current = match stat_at(&parent.file, &c_name) { + Ok(current) => current, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(Error::io(path, "inspect file", error)), + }; + if !same_file(&created, ¤t) { + return Ok(()); + } + // SAFETY: The descriptor is open and the name is NUL-terminated. + if unsafe { libc::unlinkat(parent.file.as_raw_fd(), c_name.as_ptr(), 0) } != 0 { + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::NotFound { + return Err(Error::io(path, "remove file", error)); + } + } + Ok(()) } pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { - todo!() + let list_error = |error| Error::io(path, "list directory", error); + // fdopendir takes over a descriptor and its position, so it reads a fresh descriptor of the same directory. + // SAFETY: The descriptor is open and the name is NUL-terminated. + let fd = unsafe { + libc::openat( + dir.file.as_raw_fd(), + c".".as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(list_error(io::Error::last_os_error())); + } + // SAFETY: openat returned a new descriptor that nothing else owns. + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + // SAFETY: The descriptor is an open directory. + let stream = unsafe { libc::fdopendir(fd.as_raw_fd()) }; + let Some(stream) = NonNull::new(stream) else { + return Err(list_error(io::Error::last_os_error())); + }; + // The stream owns the descriptor from here on, and closedir closes it. + let _ = fd.into_raw_fd(); + let stream = DirStream(stream); + let mut entries = Vec::new(); + loop { + os::clear_errno(); + // SAFETY: The stream is open, and no other reference to an entry it returned is alive. + let entry = unsafe { libc::readdir(stream.0.as_ptr()) }; + if entry.is_null() { + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(0) { + return Ok(entries); + } + return Err(list_error(error)); + } + // SAFETY: readdir returned an entry that stays valid until the next call on the stream. + let entry = unsafe { &*entry }; + // SAFETY: d_name is a NUL-terminated name inside the entry. + let name = unsafe { CStr::from_ptr(entry.d_name.as_ptr()) }; + if name == c"." || name == c".." { + continue; + } + let kind = match entry.d_type { + libc::DT_REG => EntryKind::File, + libc::DT_DIR => EntryKind::Directory, + libc::DT_LNK => EntryKind::Link, + libc::DT_UNKNOWN => match stat_at(&dir.file, name) { + Ok(stat) => entry_kind(stat.st_mode), + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => return Err(list_error(error)), + }, + _ => EntryKind::Other, + }; + entries.push(Entry { + name: OsString::from_vec(name.to_bytes().to_vec()), + kind, + }); + } } pub(crate) fn sync(dir: &Dir, path: &Utf8Path) -> Result<()> { - todo!() + dir.file + .sync_all() + .map_err(|error| Error::io(path, "sync directory", error)) } pub(crate) fn check_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { - todo!() + check_item(&dir.file, path, rule, Kind::Directory) } pub(crate) fn repair_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { - todo!() + let euid = effective_uid(); + let metadata = dir + .file + .metadata() + .map_err(|error| Error::io(path, "inspect directory", error))?; + let owner = if owner_allowed(rule, metadata.uid(), euid) { + metadata.uid() + } else { + euid + }; + check_rule(path, rule, owner, euid, Kind::Directory)?; + if owner != metadata.uid() { + std::os::unix::fs::fchown(&dir.file, Some(owner), None) + .map_err(|error| Error::io(path, "change owner", error))?; + } + os::remove_extended_acl(&dir.file, path)?; + dir.file + .set_permissions(Permissions::from_mode(DIR_MODE)) + .map_err(|error| Error::io(path, "change mode", error))?; + check_item(&dir.file, path, rule, Kind::Directory) } pub(crate) fn check_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { - todo!() + check_item(file, path, rule, Kind::File) +} + +/// The first existing directory of a path, open and checked with the directories above it, and the names of the +/// directories below it that didn't exist. +struct Base<'a> { + dir: Dir, + /// The path of the directory as the caller wrote it, empty for the current directory. + prefix: Utf8PathBuf, + missing: Vec<&'a str>, +} + +impl<'a> Base<'a> { + fn open(path: &'a Utf8Path, rule: &Rule) -> Result { + let mut missing = Vec::new(); + let mut components = path.components(); + let (prefix, candidate) = loop { + let prefix = components.as_path(); + let candidate = if prefix.as_str().is_empty() { + Utf8Path::new(".") + } else { + prefix + }; + match std::fs::symlink_metadata(candidate) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(Error::refused(candidate, Refusal::Link)); + } + Ok(metadata) if !metadata.is_dir() => return Err(Error::refused(candidate, Refusal::NotADirectory)), + Ok(_) => break (prefix.to_owned(), candidate), + Err(error) + if error.kind() == io::ErrorKind::NotFound || error.raw_os_error() == Some(libc::ENOTDIR) => + { + match components.next_back() { + Some(Utf8Component::Normal(name)) => missing.push(name), + Some(_) => { + return Err(Error::io( + candidate, + "use directory path", + io::ErrorKind::InvalidInput.into(), + )); + } + None => return Err(Error::io(candidate, "inspect directory", error)), + } + } + Err(error) => return Err(Error::io(candidate, "inspect directory", error)), + } + }; + missing.reverse(); + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW) + .open(candidate) + .map_err(|error| match std::fs::symlink_metadata(candidate) { + Ok(metadata) if metadata.file_type().is_symlink() => Error::refused(candidate, Refusal::Link), + Ok(metadata) if !metadata.is_dir() => Error::refused(candidate, Refusal::NotADirectory), + _ => Error::io(candidate, "open directory", error), + })?; + check_ancestors(&file, candidate, rule)?; + Ok(Self { + dir: Dir { file }, + prefix, + missing, + }) + } +} + +/// Refuses when an account outside the system account, administrators and the accounts of `rule` owns the open +/// directory `dir`, at `path`, or a directory above it on its resolved path, or can rename or replace one of them. +fn check_ancestors(dir: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { + let resolved = std::fs::canonicalize(path).map_err(|error| Error::io(path, "resolve directory", error))?; + let resolved = Utf8PathBuf::from_path_buf(resolved) + .map_err(|_| Error::io(path, "resolve directory", io::ErrorKind::InvalidData.into()))?; + let opened = dir + .metadata() + .map_err(|error| Error::io(path, "inspect directory", error))?; + // UNTESTED: the resolved path must name the directory that was opened. + // Another account that swaps a directory on the path between the open and the resolution would otherwise get a + // different directory checked; no test runs that race. + // When changing this, keep comparing the device and inode of the resolved path with those of the handle. + match std::fs::symlink_metadata(&resolved) { + Ok(found) if found.dev() == opened.dev() && found.ino() == opened.ino() => {} + Ok(_) => return Err(Error::refused(&resolved, Refusal::Replaceable)), + Err(error) => return Err(Error::refused(&resolved, Refusal::Unreadable(error))), + } + let allowed = Allowed::new(rule); + check_not_replaceable_handle(dir, &resolved, &allowed)?; + resolved + .ancestors() + .skip(1) + .try_for_each(|ancestor| check_not_replaceable_path(ancestor, &allowed)) +} + +fn check_not_replaceable_handle(dir: &File, path: &Utf8Path, allowed: &Allowed) -> Result<()> { + let metadata = dir + .metadata() + .map_err(|error| Error::refused(path, Refusal::Unreadable(error)))?; + let acl = os::ancestor_acl_of_file(dir).map_err(|error| Error::refused(path, Refusal::Unreadable(error)))?; + check_not_replaceable(path, &metadata, &acl, allowed) +} + +fn check_not_replaceable_path(path: &Utf8Path, allowed: &Allowed) -> Result<()> { + let unreadable = |error| Error::refused(path, Refusal::Unreadable(error)); + let before = std::fs::symlink_metadata(path).map_err(unreadable)?; + if !before.is_dir() { + return Err(Error::refused(path, Refusal::Replaceable)); + } + let acl = os::ancestor_acl_of_path(path).map_err(unreadable)?; + let after = std::fs::symlink_metadata(path).map_err(unreadable)?; + // UNTESTED: the ACL read by path must belong to the directory whose owner and mode are checked. + // A directory swapped in between would lend its ACL to another; no test runs that race. + // When changing this, keep the second look at the path and its comparison with the first. + if !(after.is_dir() && after.dev() == before.dev() && after.ino() == before.ino()) { + return Err(Error::refused(path, Refusal::Replaceable)); + } + check_not_replaceable(path, &after, &acl, allowed) +} + +fn check_not_replaceable(path: &Utf8Path, metadata: &Metadata, acl: &os::AncestorAcl, allowed: &Allowed) -> Result<()> { + if !allowed.uid(metadata.uid()) || replaceable(metadata.mode(), metadata.gid(), acl, allowed) { + return Err(Error::refused(path, Refusal::Replaceable)); + } + Ok(()) +} + +/// Reports whether an account that `allowed` doesn't name can rename or replace the entries of a directory with +/// `mode`, group `gid` and `acl`. +/// +/// Write access to a directory with the sticky bit only lets an account add entries and remove its own. +fn replaceable(mode: u32, gid: libc::gid_t, acl: &os::AncestorAcl, allowed: &Allowed) -> bool { + let sticky = mode & STICKY != 0; + if os::acl_lets_replace(acl, sticky, allowed) { + return true; + } + if sticky { + return false; + } + let group_write = os::owning_group_writes(acl).unwrap_or(mode & GROUP_WRITE != 0); + mode & OTHER_WRITE != 0 || (group_write && !admin_gid(gid)) +} + +fn protect_created_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { + let mode = dir + .file + .metadata() + .map_err(|error| Error::io(path, "inspect directory", error))? + .mode(); + // UNTESTED: a umask that removes owner bits doesn't leave the new directory unusable. + // The umask is process-wide, so a test can't set one without racing every other test. + // When changing this, keep the mode change before the check. + if mode & 0o777 != DIR_MODE { + dir.file + .set_permissions(Permissions::from_mode(DIR_MODE)) + .map_err(|error| Error::io(path, "change mode", error))?; + } + check_item(&dir.file, path, rule, Kind::Directory) +} + +fn check_item(file: &File, path: &Utf8Path, rule: &Rule, kind: Kind) -> Result<()> { + let step = match kind { + Kind::Directory => "inspect directory", + Kind::File => "inspect file", + }; + let metadata = file.metadata().map_err(|error| Error::io(path, step, error))?; + match kind { + Kind::Directory if !metadata.is_dir() => return Err(Error::refused(path, Refusal::NotADirectory)), + Kind::File if !metadata.is_file() => return Err(Error::refused(path, Refusal::NotAFile)), + Kind::File if 1 < metadata.nlink() => return Err(Error::refused(path, Refusal::Link)), + Kind::Directory | Kind::File => {} + } + os::check_volume(file, path)?; + let euid = effective_uid(); + if !owner_allowed(rule, metadata.uid(), euid) { + return Err(Error::refused(path, Refusal::Owner)); + } + check_rule(path, rule, metadata.uid(), euid, kind)?; + if metadata.mode() & GROUP_OTHER_BITS != 0 { + return Err(Error::refused(path, Refusal::Access)); + } + os::check_no_extended_acl(file, path, kind == Kind::Directory) +} + +/// Fails when `rule` can't create an item: private-fs creates items owned by the current user. +fn check_creatable(path: &Utf8Path, rule: &Rule, kind: Kind) -> Result<()> { + if !rule.owners.contains(&Account::CurrentUser) { + return Err(Error::refused(path, Refusal::Owner)); + } + let euid = effective_uid(); + check_rule(path, rule, euid, euid, kind) +} + +/// Fails when this platform can't enforce a grant of `rule` on an item owned by `owner`, and the grant states no +/// usable fallback. +fn check_rule(path: &Utf8Path, rule: &Rule, owner: libc::uid_t, euid: libc::uid_t, kind: Kind) -> Result<()> { + let mut owner_access = None; + for grant in &rule.grants { + let uid = account_uid(grant.account, euid); + let Some(access) = effective_access(path, grant, uid, owner, kind)? else { + continue; + }; + if uid == owner { + owner_access = Some(owner_access.map_or(access, |current| broader(current, access))); + } + } + if owner == 0 || owner_access == Some(Access::Full) { + return Ok(()); + } + // The owner keeps full access whatever the rule gives it, which only an owner fallback to full access accepts. + // An owner other than root is the current user, the only other account a rule can name here. + match rule.owner_fallback { + Some(Fallback::Access(Access::Full)) => Ok(()), + Some(Fallback::Access(_) | Fallback::NoAccess) | None => Err(Error::unsupported( + path, + Account::CurrentUser, + owner_access.unwrap_or(Access::Full), + )), + } +} + +/// Returns the access `grant` gives on this platform, after its fallback, or `None` when it falls back to none. +fn effective_access( + path: &Utf8Path, + grant: &Grant, + uid: libc::uid_t, + owner: libc::uid_t, + kind: Kind, +) -> Result> { + if supported(grant.access, uid, owner, kind) { + return Ok(Some(grant.access)); + } + match grant.fallback { + None => Err(Error::unsupported(path, grant.account, grant.access)), + Some(Fallback::NoAccess) => Ok(None), + Some(Fallback::Access(access)) if supported(access, uid, owner, kind) => Ok(Some(access)), + Some(Fallback::Access(access)) => Err(Error::unsupported(path, grant.account, access)), + } +} + +/// Reports whether this platform can give `uid` exactly `access` on an item owned by `owner`. +/// +/// Root has every access, so a grant to root needs nothing on disk, and an owner always has full access. +fn supported(access: Access, uid: libc::uid_t, owner: libc::uid_t, kind: Kind) -> bool { + if access == Access::AddOnly && kind == Kind::File { + return false; + } + uid == 0 || (uid == owner && access == Access::Full) +} + +fn broader(left: Access, right: Access) -> Access { + let rank = |access| match access { + Access::AddOnly => 0, + Access::ReadWrite => 1, + Access::Full => 2, + }; + if rank(left) < rank(right) { right } else { left } +} + +fn owner_allowed(rule: &Rule, owner: libc::uid_t, euid: libc::uid_t) -> bool { + rule.owners.iter().any(|&account| account_uid(account, euid) == owner) +} + +fn account_uid(account: Account, euid: libc::uid_t) -> libc::uid_t { + match account { + Account::CurrentUser => euid, + Account::System | Account::Administrators => 0, + } +} + +fn effective_uid() -> libc::uid_t { + // SAFETY: geteuid has no preconditions. + unsafe { libc::geteuid() } +} + +fn access_flags(options: FileOptions) -> libc::c_int { + match (options.read, options.write) { + (true, true) => libc::O_RDWR, + (false, true) => libc::O_WRONLY, + (_, false) => libc::O_RDONLY, + } +} + +/// Opens the entry `name` of `parent` without following a link, and returns `None` when it doesn't exist. +fn open_at(parent: &Dir, path: &Utf8Path, name: &str, flags: libc::c_int, kind: Kind) -> Result> { + let c_name = c_name(path, name)?; + let flags = flags | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NOCTTY; + // SAFETY: The parent descriptor is open, the name is NUL-terminated, and the flags don't include O_CREAT. + let fd = unsafe { libc::openat(parent.file.as_raw_fd(), c_name.as_ptr(), flags) }; + if 0 <= fd { + // SAFETY: openat returned a new descriptor that nothing else owns. + return Ok(Some(File::from(unsafe { OwnedFd::from_raw_fd(fd) }))); + } + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::NotFound { + return Ok(None); + } + // A link, or an entry of the wrong type, fails the open in ways that differ between platforms and flags. + let (refusal, step) = match kind { + Kind::Directory => (Refusal::NotADirectory, "open directory"), + Kind::File => (Refusal::NotAFile, "open file"), + }; + Err(match stat_at(&parent.file, &c_name) { + Ok(stat) => match (entry_kind(stat.st_mode), kind) { + (EntryKind::Link, _) => Error::refused(path, Refusal::Link), + (EntryKind::Directory, Kind::Directory) | (EntryKind::File, Kind::File) => Error::io(path, step, error), + _ => Error::refused(path, refusal), + }, + Err(_) => Error::io(path, step, error), + }) +} + +/// Removes the entry `name` of `parent` if it is still `file`, after a creation whose check failed. +fn discard_entry(parent: &Dir, name: &CStr, file: &File, flags: libc::c_int) { + let (Ok(created), Ok(current)) = (fstat(file), stat_at(&parent.file, name)) else { + return; + }; + if same_file(&created, ¤t) { + // SAFETY: The descriptor is open and the name is NUL-terminated. + let _ = unsafe { libc::unlinkat(parent.file.as_raw_fd(), name.as_ptr(), flags) }; + } +} + +fn link_then_unlink(fd: libc::c_int, from: &CStr, to: &CStr) -> io::Result<()> { + // SAFETY: The descriptor is open and both names are NUL-terminated. + if unsafe { libc::linkat(fd, from.as_ptr(), fd, to.as_ptr(), 0) } != 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: The descriptor is open and the name is NUL-terminated. + if unsafe { libc::unlinkat(fd, from.as_ptr(), 0) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +fn rename_exclusive_unsupported(code: libc::c_int) -> bool { + code == libc::ENOSYS || code == libc::EINVAL || code == libc::EOPNOTSUPP || code == libc::ENOTSUP +} + +fn stat_at(dir: &File, name: &CStr) -> io::Result { + let mut stat = MaybeUninit::::uninit(); + // SAFETY: The descriptor is open, the name is NUL-terminated and the output buffer is writable. + if unsafe { + libc::fstatat( + dir.as_raw_fd(), + name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + } != 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: fstatat initialized the output buffer on success. + Ok(unsafe { stat.assume_init() }) +} + +fn fstat(file: &File) -> io::Result { + let mut stat = MaybeUninit::::uninit(); + // SAFETY: The descriptor is open and the output buffer is writable. + if unsafe { libc::fstat(file.as_raw_fd(), stat.as_mut_ptr()) } != 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: fstat initialized the output buffer on success. + Ok(unsafe { stat.assume_init() }) +} + +fn same_file(left: &libc::stat, right: &libc::stat) -> bool { + left.st_dev == right.st_dev && left.st_ino == right.st_ino +} + +fn entry_kind(mode: libc::mode_t) -> EntryKind { + match mode & libc::S_IFMT { + libc::S_IFREG => EntryKind::File, + libc::S_IFDIR => EntryKind::Directory, + libc::S_IFLNK => EntryKind::Link, + _ => EntryKind::Other, + } +} + +fn c_name(path: &Utf8Path, name: &str) -> Result { + CString::new(name).map_err(|_| Error::io(path, "use entry name", io::ErrorKind::InvalidInput.into())) +} + +fn parent_path(path: &Utf8Path) -> &Utf8Path { + path.parent().unwrap_or(path) +} + +struct DirStream(NonNull); + +impl Drop for DirStream { + fn drop(&mut self) { + // SAFETY: fdopendir returned this stream, and it is closed only here. + let _ = unsafe { libc::closedir(self.0.as_ptr()) }; + } +} + +#[cfg(test)] +impl Allowed { + pub(crate) fn new_for_tests(current_user: Option) -> Self { + Self { current_user } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const USER: libc::uid_t = 1000; + + fn check(rule: &Rule, owner: libc::uid_t, euid: libc::uid_t, kind: Kind) -> Result<()> { + check_rule(Utf8Path::new("item"), rule, owner, euid, kind) + } + + fn unsupported(result: Result<()>) -> Option<(Account, Access)> { + match result.err()?.kind() { + crate::ErrorKind::Unsupported { account, access } => Some((*account, *access)), + _ => None, + } + } + + // A backend that let the owner keep less than full access, or chose a broader access than the rule, would leave + // the owner, who can always change the mode, with access the rule withholds. + // oracle: intent crates/private-fs/INTENT.md §rules + #[test] + fn a_grant_this_platform_cannot_enforce_needs_a_fallback() { + let rule = Rule::new() + .owner(Account::CurrentUser) + .grant(Account::CurrentUser, Access::ReadWrite); + assert_eq!( + unsupported(check(&rule, USER, USER, Kind::Directory)), + Some((Account::CurrentUser, Access::ReadWrite)) + ); + + let rule = Rule::new().owner(Account::CurrentUser).grant_or( + Account::CurrentUser, + Access::ReadWrite, + Fallback::Access(Access::Full), + ); + assert!(check(&rule, USER, USER, Kind::Directory).is_ok()); + + // A fallback is checked as the grant itself is. + let rule = Rule::new().owner(Account::CurrentUser).grant_or( + Account::CurrentUser, + Access::ReadWrite, + Fallback::Access(Access::AddOnly), + ); + assert_eq!( + unsupported(check(&rule, USER, USER, Kind::Directory)), + Some((Account::CurrentUser, Access::AddOnly)) + ); + } + + // oracle: intent crates/private-fs/INTENT.md §rules + #[test] + fn an_owner_without_full_access_needs_an_owner_fallback() { + let rule = Rule::new().owner(Account::CurrentUser).grant_or( + Account::CurrentUser, + Access::ReadWrite, + Fallback::NoAccess, + ); + assert_eq!( + unsupported(check(&rule, USER, USER, Kind::Directory)), + Some((Account::CurrentUser, Access::Full)) + ); + assert!( + check( + &rule.owner_fallback(Fallback::Access(Access::Full)), + USER, + USER, + Kind::Directory + ) + .is_ok() + ); + + let rule = Rule::new() + .owner(Account::CurrentUser) + .owner_fallback(Fallback::NoAccess); + assert!(unsupported(check(&rule, USER, USER, Kind::Directory)).is_some()); + } + + // Root has every access whatever the mode says, so a rule may give root, or a root owner, any access. + // oracle: intent crates/private-fs/INTENT.md §mechanisms + #[test] + fn grants_to_root_need_nothing_on_disk() { + let rule = Rule::new() + .owner(Account::System) + .grant(Account::System, Access::ReadWrite) + .grant(Account::Administrators, Access::AddOnly); + assert!(check(&rule, 0, USER, Kind::Directory).is_ok()); + + // Running as root, the current user is root. + let rule = Rule::new() + .owner(Account::CurrentUser) + .grant(Account::CurrentUser, Access::ReadWrite); + assert!(check(&rule, 0, 0, Kind::Directory).is_ok()); + } + + // An account other than root that doesn't own the item gets no access from mode 0700 or 0600. + // oracle: intent crates/private-fs/INTENT.md §rules + #[test] + fn a_grant_to_a_user_who_does_not_own_the_item_is_unsupported() { + let rule = Rule::new() + .owner(Account::System) + .owner(Account::CurrentUser) + .grant(Account::CurrentUser, Access::Full); + assert_eq!( + unsupported(check(&rule, 0, USER, Kind::Directory)), + Some((Account::CurrentUser, Access::Full)) + ); + + let rule = Rule::new().owner(Account::System).owner(Account::CurrentUser).grant_or( + Account::CurrentUser, + Access::Full, + Fallback::NoAccess, + ); + assert!(check(&rule, 0, USER, Kind::Directory).is_ok()); + } + + // oracle: intent crates/private-fs/INTENT.md §rules + #[test] + fn add_only_access_to_a_file_is_unsupported_even_for_root() { + let rule = Rule::new() + .owner(Account::CurrentUser) + .grant(Account::CurrentUser, Access::Full) + .grant(Account::System, Access::AddOnly); + assert_eq!( + unsupported(check(&rule, USER, USER, Kind::File)), + Some((Account::System, Access::AddOnly)) + ); + assert!(check(&rule, USER, USER, Kind::Directory).is_ok()); + } + + // oracle: intent crates/private-fs/INTENT.md §mechanisms + #[test] + fn only_root_and_the_accounts_a_rule_names_may_own_an_item() { + let current_user_only = Rule::new().owner(Account::CurrentUser); + assert!(owner_allowed(¤t_user_only, USER, USER)); + assert!(!owner_allowed(¤t_user_only, 0, USER)); + assert!(!owner_allowed(¤t_user_only, USER + 1, USER)); + + let system_only = Rule::new().owner(Account::System); + assert!(owner_allowed(&system_only, 0, USER)); + assert!(!owner_allowed(&system_only, USER, USER)); + } + + // StrictModes: write access to a directory above lets an account rename or replace its entries, unless the sticky + // bit limits it to adding entries and removing its own, or the account is root or in an administrators group. + // oracle: intent crates/private-fs/INTENT.md §paths + #[test] + fn write_access_for_others_makes_a_directory_above_replaceable() { + let acl = os::AncestorAcl::empty(); + let allowed = Allowed::new_for_tests(Some(USER)); + let staff = 20; + assert!(replaceable(0o777, staff, &acl, &allowed)); + assert!(replaceable(0o775, staff, &acl, &allowed)); + assert!(replaceable(0o757, 0, &acl, &allowed)); + assert!(!replaceable(0o1777, staff, &acl, &allowed)); + assert!(!replaceable(0o775, 0, &acl, &allowed)); + assert!(!replaceable(0o755, staff, &acl, &allowed)); + } } diff --git a/testsuite/tests/private_fs/unix.rs b/testsuite/tests/private_fs/unix.rs index e69de29bb2..6ea0b32e87 100644 --- a/testsuite/tests/private_fs/unix.rs +++ b/testsuite/tests/private_fs/unix.rs @@ -0,0 +1,580 @@ +//! private-fs on Linux and macOS, through its public API on real directories. +//! +//! Each test works in its own directory under the system temporary directory, whose ancestors are root's. + +use std::fs; +use std::io::{Read as _, Write as _}; +use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _, symlink}; +use std::os::unix::net::UnixListener; +use std::process::Command; + +use camino::{Utf8Path, Utf8PathBuf}; +use private_fs::{ + Access, Account, Dir, EntryKind, Error, ErrorKind, Existing, Fallback, FileOptions, Refusal, Replace, Rule, Share, +}; +use rstest::rstest; + +/// A directory owned by the test's account, with mode 0700, removed when dropped. +struct Scratch { + _dir: tempfile::TempDir, + path: Utf8PathBuf, +} + +impl Scratch { + fn new() -> Self { + let dir = tempfile::tempdir().expect("create a temporary directory"); + let path = Utf8PathBuf::from_path_buf(dir.path().to_owned()).expect("UTF-8 temporary directory"); + Self { _dir: dir, path } + } + + fn base(&self) -> Dir { + Dir::create_base(&self.path, &rule()).expect("open the scratch directory") + } + + /// Reports whether the test runs as root, which owns what it creates. + fn as_root(&self) -> bool { + fs::metadata(&self.path).expect("inspect the scratch directory").uid() == 0 + } +} + +/// The rule of a directory or file only the current user and root may own and access. +fn rule() -> Rule { + Rule::new() + .owner(Account::CurrentUser) + .owner(Account::System) + .grant(Account::CurrentUser, Access::Full) + .grant(Account::System, Access::Full) +} + +fn refusal(error: &Error) -> Option<&Refusal> { + match error.kind() { + ErrorKind::Refused(refusal) => Some(refusal), + ErrorKind::Io { .. } | ErrorKind::Unsupported { .. } => None, + } +} + +fn unsupported(error: &Error) -> Option<(Account, Access)> { + match error.kind() { + ErrorKind::Unsupported { account, access } => Some((*account, *access)), + ErrorKind::Io { .. } | ErrorKind::Refused(_) => None, + } +} + +fn mode(path: &Utf8Path) -> u32 { + fs::symlink_metadata(path).expect("inspect a test path").mode() & 0o7777 +} + +fn set_mode(path: &Utf8Path, mode: u32) { + fs::set_permissions(path, fs::Permissions::from_mode(mode)).expect("set a test path's mode"); +} + +fn canonical(path: &Utf8Path) -> Utf8PathBuf { + Utf8PathBuf::from_path_buf(fs::canonicalize(path).expect("resolve a test path")).expect("UTF-8 test path") +} + +fn skip(test: &str, reason: &str) { + // The test harness captures `eprintln!` output from a passing test, but not direct writes to stderr. + let _ = writeln!(std::io::stderr(), "SKIP: {test} {reason}"); +} + +/// Runs a platform command that changes a test path's ACL, and returns `false` when the platform can't run it. +fn run_acl_command(program: &str, args: &[&str]) -> bool { + Command::new(program) + .args(args) + .status() + .is_ok_and(|status| status.success()) +} + +/// Gives another account read access to `path` through an extended ACL. +fn grant_others_read(path: &Utf8Path) -> bool { + if cfg!(target_os = "macos") { + run_acl_command("chmod", &["+a", "everyone allow read", path.as_str()]) + } else { + run_acl_command("setfacl", &["-m", "u:65534:r", path.as_str()]) + } +} + +/// Gives the directory `path` an ACL that new entries inherit. +fn make_inheritable(path: &Utf8Path) -> bool { + if cfg!(target_os = "macos") { + run_acl_command( + "chmod", + &[ + "+a", + "everyone allow read,file_inherit,directory_inherit", + path.as_str(), + ], + ) + } else { + run_acl_command("setfacl", &["-d", "-m", "u::rwx", path.as_str()]) + } +} + +/// Lets another account remove or rename the entries of the directory `path` through an extended ACL. +fn let_others_replace(path: &Utf8Path) -> bool { + if cfg!(target_os = "macos") { + run_acl_command("chmod", &["+a", "everyone allow delete_child", path.as_str()]) + } else { + run_acl_command("setfacl", &["-m", "u:65534:rwx", path.as_str()]) + } +} + +// A backend that created directories with the process umask's mode, or only the last one, would leave a new +// directory open to other accounts. +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn create_base_creates_each_missing_directory_owner_only() { + let scratch = Scratch::new(); + let path = scratch.path.join("data").join("keys"); + Dir::create_base(&path, &rule()).unwrap(); + assert_eq!(mode(&scratch.path.join("data")), 0o700); + assert_eq!(mode(&path), 0o700); + assert!(Dir::open_base(&path, &rule()).unwrap().is_some()); +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn open_base_returns_none_for_a_missing_directory() { + let scratch = Scratch::new(); + assert!( + Dir::open_base(&scratch.path.join("missing"), &rule()) + .unwrap() + .is_none() + ); + assert!(!scratch.path.join("missing").exists()); +} + +// A directory another process created first, open to other accounts, must not count as created. +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn an_existing_directory_open_to_others_is_refused_until_repaired() { + let scratch = Scratch::new(); + let shared = scratch.path.join("shared"); + fs::create_dir(&shared).unwrap(); + set_mode(&shared, 0o755); + let base = scratch.base(); + + let error = base.create_dir("shared", &rule(), Existing::Check).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); + assert_eq!(mode(&shared), 0o755, "a check changed the directory"); + + base.create_dir("shared", &rule(), Existing::Repair).unwrap(); + assert_eq!(mode(&shared), 0o700); + assert!(base.open_dir("shared", &rule(), Share::Shared).unwrap().is_some()); +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn a_symbolic_link_from_the_first_existing_directory_down_is_refused() { + let scratch = Scratch::new(); + fs::create_dir(scratch.path.join("real")).unwrap(); + set_mode(&scratch.path.join("real"), 0o700); + let link = scratch.path.join("link"); + symlink(scratch.path.join("real"), &link).unwrap(); + + let error = Dir::create_base(&link.join("new"), &rule()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Link)), "{error:?}"); + assert_eq!(error.path(), link); + assert!(!scratch.path.join("real").join("new").exists()); + + let error = Dir::open_base(&link, &rule()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Link)), "{error:?}"); +} + +// oracle: intent crates/private-fs/INTENT.md §mechanisms +#[test] +fn an_entry_that_is_a_symbolic_link_is_refused() { + let scratch = Scratch::new(); + let base = scratch.base(); + let real = base.create_dir("real", &rule(), Existing::Check).unwrap(); + drop(real.create_file("file", &rule(), FileOptions::new()).unwrap()); + symlink("real", scratch.path.join("dir-link")).unwrap(); + symlink("real/file", scratch.path.join("file-link")).unwrap(); + + let error = base.open_dir("dir-link", &rule(), Share::Shared).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Link)), "{error:?}"); + let error = base + .open_file("file-link", &rule(), FileOptions::new().read(true)) + .unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Link)), "{error:?}"); +} + +// Another hard link lets whoever can reach it in another directory open the file. +// oracle: intent crates/private-fs/INTENT.md §mechanisms +#[test] +fn a_file_with_another_hard_link_is_refused() { + let scratch = Scratch::new(); + let base = scratch.base(); + drop(base.create_file("key", &rule(), FileOptions::new()).unwrap()); + fs::hard_link(scratch.path.join("key"), scratch.path.join("other")).unwrap(); + + let error = base.open_file("key", &rule(), FileOptions::new()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Link)), "{error:?}"); +} + +// oracle: intent crates/private-fs/INTENT.md §mechanisms +#[test] +fn a_file_readable_by_others_is_refused() { + let scratch = Scratch::new(); + let base = scratch.base(); + drop(base.create_file("key", &rule(), FileOptions::new()).unwrap()); + assert_eq!(mode(&scratch.path.join("key")), 0o600); + set_mode(&scratch.path.join("key"), 0o644); + + let error = base.open_file("key", &rule(), FileOptions::new()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); +} + +#[derive(Debug)] +enum Special { + Fifo, + Socket, + Directory, +} + +// Opening a FIFO for reading blocks until a writer comes, unless the open doesn't wait. +// oracle: intent crates/private-fs/INTENT.md §mechanisms +#[rstest] +#[case::fifo(Special::Fifo)] +#[case::socket(Special::Socket)] +#[case::directory(Special::Directory)] +fn an_entry_that_is_not_a_regular_file_is_refused_without_blocking(#[case] special: Special) { + let scratch = Scratch::new(); + let path = scratch.path.join("entry"); + let _listener = match special { + Special::Fifo => { + assert!(Command::new("mkfifo").arg(&path).status().unwrap().success()); + None + } + Special::Socket => Some(UnixListener::bind(&path).unwrap()), + Special::Directory => { + fs::create_dir(&path).unwrap(); + None + } + }; + set_mode(&path, 0o600); + let base = scratch.base(); + + let error = base + .open_file("entry", &rule(), FileOptions::new().read(true)) + .unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::NotAFile)), "{error:?}"); +} + +// StrictModes: another account that can write to a directory above can rename it and put its own in its place. +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn a_directory_above_that_others_can_write_is_refused() { + let scratch = Scratch::new(); + let shared = scratch.path.join("shared"); + fs::create_dir(&shared).unwrap(); + set_mode(&shared, 0o777); + + let error = Dir::create_base(&shared.join("base"), &rule()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Replaceable)), "{error:?}"); + assert_eq!(error.path(), canonical(&shared)); + assert!(!shared.join("base").exists()); +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn a_directory_above_that_its_group_can_write_is_refused() { + let scratch = Scratch::new(); + let shared = scratch.path.join("shared"); + fs::create_dir(&shared).unwrap(); + set_mode(&shared, 0o770); + // Root's group, and `admin` on macOS, are administrators, whose write access doesn't count. + if [0, 80].contains(&fs::metadata(&shared).unwrap().gid()) { + skip( + "a_directory_above_that_its_group_can_write_is_refused", + "needs a directory group other than administrators", + ); + return; + } + + let error = Dir::open_base(&shared, &rule()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Replaceable)), "{error:?}"); + assert_eq!(error.path(), canonical(&shared)); +} + +// With the sticky bit, other accounts can only add entries and remove their own, as in /tmp. +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn a_directory_above_where_others_can_only_add_entries_is_accepted() { + let scratch = Scratch::new(); + let shared = scratch.path.join("shared"); + fs::create_dir(&shared).unwrap(); + set_mode(&shared, 0o1777); + + Dir::create_base(&shared.join("base"), &rule()).unwrap(); + assert_eq!(mode(&shared.join("base")), 0o700); +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn a_directory_above_that_an_acl_lets_others_replace_is_refused() { + let scratch = Scratch::new(); + let shared = scratch.path.join("shared"); + fs::create_dir(&shared).unwrap(); + set_mode(&shared, 0o755); + if !let_others_replace(&shared) { + skip( + "a_directory_above_that_an_acl_lets_others_replace_is_refused", + "needs ACL support and its tools", + ); + return; + } + + let error = Dir::create_base(&shared.join("base"), &rule()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Replaceable)), "{error:?}"); + assert_eq!(error.path(), canonical(&shared)); +} + +// A directory above whose entries can't be read could hide a replaceable directory from the check. +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn a_path_through_a_directory_that_cannot_be_searched_fails() { + let scratch = Scratch::new(); + if scratch.as_root() { + skip( + "a_path_through_a_directory_that_cannot_be_searched_fails", + "needs an account other than root", + ); + return; + } + let locked = scratch.path.join("locked"); + fs::create_dir_all(locked.join("inner")).unwrap(); + set_mode(&locked, 0o000); + + let result = Dir::create_base(&locked.join("inner").join("base"), &rule()); + set_mode(&locked, 0o700); + assert!(result.is_err()); + assert!(!locked.join("inner").join("base").exists()); +} + +// oracle: intent crates/private-fs/INTENT.md §mechanisms +#[test] +fn a_file_with_an_extended_acl_is_refused() { + let scratch = Scratch::new(); + let base = scratch.base(); + drop(base.create_file("key", &rule(), FileOptions::new()).unwrap()); + if !grant_others_read(&scratch.path.join("key")) { + skip( + "a_file_with_an_extended_acl_is_refused", + "needs ACL support and its tools", + ); + return; + } + + let error = base.open_file("key", &rule(), FileOptions::new()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); +} + +// A new entry would inherit the parent's inheritable ACL, and so not have exactly its rule from the moment it exists. +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn nothing_is_created_under_an_inheritable_acl() { + let scratch = Scratch::new(); + let base = scratch.base(); + let parent = base.create_dir("parent", &rule(), Existing::Check).unwrap(); + if !make_inheritable(&scratch.path.join("parent")) { + skip( + "nothing_is_created_under_an_inheritable_acl", + "needs ACL support and its tools", + ); + return; + } + + let error = parent.create_dir("dir", &rule(), Existing::Check).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); + let error = parent.create_file("file", &rule(), FileOptions::new()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); + assert_eq!(parent.entries().unwrap().len(), 0); +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn repair_removes_an_extended_acl() { + let scratch = Scratch::new(); + let base = scratch.base(); + drop(base.create_dir("dir", &rule(), Existing::Check).unwrap()); + if !grant_others_read(&scratch.path.join("dir")) || !make_inheritable(&scratch.path.join("dir")) { + skip("repair_removes_an_extended_acl", "needs ACL support and its tools"); + return; + } + let error = base.create_dir("dir", &rule(), Existing::Check).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); + + base.create_dir("dir", &rule(), Existing::Repair).unwrap(); + base.create_dir("dir", &rule(), Existing::Check).unwrap(); +} + +// Root can access everything, so only root can give the test a directory or file that another account owns. +// oracle: intent crates/private-fs/INTENT.md §mechanisms +#[test] +fn root_only_an_item_owned_by_another_account_is_refused_until_repaired() { + let scratch = Scratch::new(); + if !scratch.as_root() { + skip( + "root_only_an_item_owned_by_another_account_is_refused_until_repaired", + "needs root", + ); + return; + } + let other = 12345; + let base = scratch.base(); + drop(base.create_dir("dir", &rule(), Existing::Check).unwrap()); + drop(base.create_file("file", &rule(), FileOptions::new()).unwrap()); + std::os::unix::fs::chown(scratch.path.join("dir"), Some(other), None).unwrap(); + std::os::unix::fs::chown(scratch.path.join("file"), Some(other), None).unwrap(); + + let error = base.open_dir("dir", &rule(), Share::Shared).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Owner)), "{error:?}"); + let error = base.open_file("file", &rule(), FileOptions::new()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Owner)), "{error:?}"); + + base.create_dir("dir", &rule(), Existing::Repair).unwrap(); + assert_eq!(fs::metadata(scratch.path.join("dir")).unwrap().uid(), 0); +} + +// private-fs never chooses a broader access than the rule: an owner other than root can always change the mode. +// oracle: intent crates/private-fs/INTENT.md §rules +#[test] +fn a_grant_the_platform_cannot_enforce_fails_without_a_fallback() { + let scratch = Scratch::new(); + if scratch.as_root() { + skip( + "a_grant_the_platform_cannot_enforce_fails_without_a_fallback", + "needs an account other than root, which can enforce every grant", + ); + return; + } + let read_write = Rule::new() + .owner(Account::CurrentUser) + .grant(Account::CurrentUser, Access::ReadWrite); + let error = Dir::create_base(&scratch.path.join("dir"), &read_write).unwrap_err(); + assert_eq!(unsupported(&error), Some((Account::CurrentUser, Access::ReadWrite))); + assert!(!scratch.path.join("dir").exists()); + + let with_fallback = Rule::new().owner(Account::CurrentUser).grant_or( + Account::CurrentUser, + Access::ReadWrite, + Fallback::Access(Access::Full), + ); + Dir::create_base(&scratch.path.join("dir"), &with_fallback).unwrap(); +} + +// oracle: intent crates/private-fs/INTENT.md §rules +#[test] +fn add_only_access_to_a_file_fails_without_a_fallback() { + let scratch = Scratch::new(); + let add_only = rule().grant(Account::System, Access::AddOnly); + let error = scratch + .base() + .create_file("file", &add_only, FileOptions::new()) + .unwrap_err(); + assert_eq!(unsupported(&error), Some((Account::System, Access::AddOnly))); + assert!(!scratch.path.join("file").exists()); +} + +// private-fs creates what it creates owned by the current user, which the rule must allow. +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn a_rule_that_does_not_let_the_current_user_own_cannot_create() { + let scratch = Scratch::new(); + let system_only = Rule::new().owner(Account::System).grant(Account::System, Access::Full); + let error = scratch + .base() + .create_dir("dir", &system_only, Existing::Check) + .unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Owner)), "{error:?}"); + assert!(!scratch.path.join("dir").exists()); +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn persist_installs_the_written_file_under_its_name() { + let scratch = Scratch::new(); + let base = scratch.base(); + let mut file = base.create_file("key.tmp", &rule(), FileOptions::new()).unwrap(); + file.write_all(b"secret").unwrap(); + base.persist(&file, "key.tmp", "key", Replace::Refuse).unwrap(); + + let mut content = String::new(); + base.open_file("key", &rule(), FileOptions::new().read(true)) + .unwrap() + .expect("persisted file") + .read_to_string(&mut content) + .unwrap(); + assert_eq!(content, "secret"); + assert!( + base.open_file("key.tmp", &rule(), FileOptions::new()) + .unwrap() + .is_none() + ); +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn a_rename_that_refuses_replacement_keeps_the_existing_entry() { + let scratch = Scratch::new(); + let base = scratch.base(); + base.create_file("old", &rule(), FileOptions::new()) + .unwrap() + .write_all(b"old") + .unwrap(); + base.create_file("new", &rule(), FileOptions::new()) + .unwrap() + .write_all(b"new") + .unwrap(); + + let error = base.rename("new", "old", Replace::Refuse).unwrap_err(); + assert!( + matches!(error.kind(), ErrorKind::Io { source, .. } if source.kind() == std::io::ErrorKind::AlreadyExists), + "{error:?}" + ); + assert_eq!(fs::read(scratch.path.join("old")).unwrap(), b"old"); + + base.rename("new", "old", Replace::Allow).unwrap(); + assert_eq!(fs::read(scratch.path.join("old")).unwrap(), b"new"); + assert!(!scratch.path.join("new").exists()); +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn remove_file_reports_whether_the_file_existed() { + let scratch = Scratch::new(); + let base = scratch.base(); + drop(base.create_file("key", &rule(), FileOptions::new()).unwrap()); + assert!(base.remove_file("key").unwrap()); + assert!(!base.remove_file("key").unwrap()); +} + +// A listing that followed links would report a link to a directory as a directory. +// oracle: intent crates/private-fs/INTENT.md §mechanisms +#[test] +fn entries_report_kinds_without_following_links() { + let scratch = Scratch::new(); + let base = scratch.base(); + drop(base.create_file("file", &rule(), FileOptions::new()).unwrap()); + drop(base.create_dir("dir", &rule(), Existing::Check).unwrap()); + symlink("dir", scratch.path.join("link")).unwrap(); + let _listener = UnixListener::bind(scratch.path.join("socket")).unwrap(); + + let mut entries: Vec<_> = base + .entries() + .unwrap() + .into_iter() + .map(|entry| (entry.name.into_string().unwrap(), entry.kind)) + .collect(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + assert_eq!( + entries, + [ + ("dir".to_owned(), EntryKind::Directory), + ("file".to_owned(), EntryKind::File), + ("link".to_owned(), EntryKind::Link), + ("socket".to_owned(), EntryKind::Other), + ] + ); +} From 81289c63ed6a162c83d677e04b8f7e8f50965214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 16:06:15 +0900 Subject: [PATCH 06/60] feat(private-fs): remove a created file through its own handle A file can be created under a rule that gives its creator no access once it exists, as an administrator's drop-box file does, so removing it by name again can be refused. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/lib.rs | 9 +++++++++ crates/private-fs/src/unix.rs | 1 - crates/private-fs/src/windows/mod.rs | 1 - 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/private-fs/src/lib.rs b/crates/private-fs/src/lib.rs index 3d26cac50b..c61552efdf 100644 --- a/crates/private-fs/src/lib.rs +++ b/crates/private-fs/src/lib.rs @@ -215,6 +215,15 @@ impl Dir { sys::persist(&self.inner, &path, file, from, to, replace) } + /// Removes the file `name` that this handle created as `file`, through `file` itself where the platform allows. + /// + /// A file can be created with a rule that gives its creator no access once created, so removing it by name again + /// could be refused. + pub fn discard(&self, file: &File, name: &str) -> Result<()> { + let path = self.child(name)?; + sys::discard(&self.inner, &path, file, name) + } + /// Removes the file `name`, and returns `false` when it doesn't exist. pub fn remove_file(&self, name: &str) -> Result { let path = self.child(name)?; diff --git a/crates/private-fs/src/unix.rs b/crates/private-fs/src/unix.rs index 014b506832..f22caf2603 100644 --- a/crates/private-fs/src/unix.rs +++ b/crates/private-fs/src/unix.rs @@ -260,7 +260,6 @@ pub(crate) fn remove_file(parent: &Dir, path: &Utf8Path, name: &str) -> Result Result<()> { let c_name = c_name(path, name)?; let created = fstat(file).map_err(|error| Error::io(path, "inspect file", error))?; diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index a1a8af5df9..a3a344176c 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -283,7 +283,6 @@ pub(crate) fn remove_file(parent: &Dir, path: &Utf8Path, name: &str) -> Result Result<()> { nt::delete_on_close(file.as_handle()).map_err(|error| Error::io(path, "remove file", error)) } From 1f458cc461d87d3612962b3f398adfa1261355fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 16:21:07 +0900 Subject: [PATCH 07/60] feat(agent): manage device key files with private-fs The file backend of device-key now creates, opens, renames and removes its key directory, key files and lock file through private-fs handles checked against one rule each, instead of its own permission, ACL, link and volume code for each platform. Key files and the lock file may be owned only by the caller's account or SYSTEM (root), and no other account may access them. A key directory the crate creates gives access only to those two accounts; an existing one, and every directory above it, must not be replaceable by another account, as OpenSSH's StrictModes requires. Linux key files with POSIX ACL entries, key stores on volumes that ignore ownership, Windows lock files with a broader DACL and Windows key directories that are junctions are now refused. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/device-key/Cargo.toml | 3 +- crates/device-key/clippy.toml | 1 - crates/device-key/src/file.rs | 795 +++++++++-------------------- crates/device-key/src/lib.rs | 2 - crates/device-key/src/macos_acl.rs | 44 -- 5 files changed, 253 insertions(+), 592 deletions(-) delete mode 100644 crates/device-key/src/macos_acl.rs diff --git a/crates/device-key/Cargo.toml b/crates/device-key/Cargo.toml index 874a8025c1..48fa8fc8f1 100644 --- a/crates/device-key/Cargo.toml +++ b/crates/device-key/Cargo.toml @@ -13,6 +13,7 @@ workspace = true anyhow = "1" camino = "1" p256 = { version = "=0.14.0-rc.14", features = ["ecdsa", "pkcs8"] } +private-fs = { path = "../private-fs" } rand = "0.10" secure-memory = { path = "../secure-memory" } sha2 = "0.11" @@ -26,7 +27,6 @@ serde_json = "1" [target.'cfg(unix)'.dependencies] libc = "0.2" -private-fs = { path = "../private-fs" } [target.'cfg(windows)'.dependencies] win-api-wrappers = { path = "../win-api-wrappers" } @@ -35,7 +35,6 @@ windows = { version = "0.61", features = [ "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", - "Win32_Storage_FileSystem", "Win32_System_Memory", "Win32_System_Threading", ] } diff --git a/crates/device-key/clippy.toml b/crates/device-key/clippy.toml index 286c233484..61d07ff185 100644 --- a/crates/device-key/clippy.toml +++ b/crates/device-key/clippy.toml @@ -5,5 +5,4 @@ accept-comment-above-attributes = true # Device-key signing can fail (for example in the Windows software provider); sign would panic, so callers use try_sign. disallowed-methods = [ { path = "signature::Signer::sign", reason = "use try_sign: device key signing is fallible" }, - { path = "libc::renameat2", reason = "needs glibc 2.28; the Linux release baseline is glibc 2.27, so call the renameat2 syscall directly", allow-invalid = true }, ] diff --git a/crates/device-key/src/file.rs b/crates/device-key/src/file.rs index 04c91d9c46..e715a7c70b 100644 --- a/crates/device-key/src/file.rs +++ b/crates/device-key/src/file.rs @@ -1,20 +1,20 @@ -use std::fs::{self, File, OpenOptions}; -use std::io::{ErrorKind, Read as _, Write as _}; +use std::fs::File; +use std::io::{Read as _, Write as _}; use std::time::{Duration, Instant}; use anyhow::{Context as _, ensure}; -use camino::{Utf8Path, Utf8PathBuf}; +use camino::Utf8Path; use p256::ecdsa::{Signature, SigningKey, VerifyingKey}; use p256::elliptic_curve::Generate as _; use p256::pkcs8::{DecodePrivateKey as _, EncodePrivateKey as _}; +use private_fs::{Access, Account, Dir, EntryKind, FileOptions, Replace, Rule, Share}; use secure_memory::ProtectedBytes; use uuid::Uuid; -#[cfg(windows)] -use win_api_wrappers::identity::sid::Sid; use zeroize::Zeroizing; use crate::{DeviceKey, name_matches_prefix, validate_name}; +const LOCK_NAME: &str = ".device-key.lock"; const LOCK_TIMEOUT: Duration = Duration::from_secs(5); const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(25); /// A P-256 key file takes about 140 bytes, so a file past this bound is not a key the crate wrote. @@ -41,22 +41,42 @@ impl DeviceKey for FileKey { } } -fn key_path(dir: &Utf8Path, name: &str) -> Utf8PathBuf { - dir.join(format!("{name}.p8")) +/// The rule of the directories the crate creates for keys: the caller's account owns them, and only it and SYSTEM +/// (root on Linux and macOS) can access them. +fn key_dir_rule() -> Rule { + Rule::new() + .owner(Account::CurrentUser) + .grant(Account::CurrentUser, Access::Full) + .grant(Account::System, Access::Full) } -struct PendingFile(Utf8PathBuf); +/// The rule of key files and of the lock file: only the caller's account and SYSTEM (root on Linux and macOS) can own +/// or access them. +/// +/// A check accepts a file that gives these accounts less access, since the access rule asks only that no other account +/// can access a key. +fn key_file_rule() -> Rule { + Rule::new() + .owner(Account::CurrentUser) + .owner(Account::System) + .grant(Account::CurrentUser, Access::Full) + .grant(Account::System, Access::Full) +} -impl Drop for PendingFile { - fn drop(&mut self) { - let _ = fs::remove_file(&self.0); - } +fn key_file_name(name: &str) -> String { + format!("{name}.p8") +} + +#[cfg(windows)] +fn tombstone_name(name: &str) -> String { + format!(".{name}-{}.deleted", Uuid::new_v4()) } pub(super) fn generate(dir: &Utf8Path, name: &str) -> anyhow::Result> { - create_key_directory(dir)?; - let _lock = lock_and_reconcile(dir)?; - let destination = key_path(dir, name); + // An empty path would name the current directory. + ensure!(!dir.as_str().is_empty(), "key directory is empty"); + let dir = Dir::create_base(dir, &key_dir_rule()).context("create key directory")?; + let _lock = lock_and_reconcile(&dir)?; let key = SigningKey::generate_from_rng(&mut rand::rng()); let pkcs8 = key.to_pkcs8_der().context("encode device key")?; let public_key = *key.verifying_key(); @@ -64,24 +84,21 @@ pub(super) fn generate(dir: &Utf8Path, name: &str) -> anyhow::Result anyhow::Result anyhow::Result>> { - if !directory_exists(dir)? { + let Some(dir) = open_key_directory(dir)? else { + return Ok(None); + }; + let _lock = lock_and_reconcile(&dir)?; + let Some(file) = dir + .open_file(&key_file_name(name), &key_file_rule(), FileOptions::new().read(true)) + .context("open key file")? + else { return Ok(None); - } - let _lock = lock_and_reconcile(dir)?; - let path = key_path(dir, name); - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt as _; - - // O_NONBLOCK keeps a FIFO or device at the key's name from blocking the open. - // Reads from a regular file ignore it. - options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); - } - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt as _; - - use windows::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; - - options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0); - } - let file = match options.open(&path) { - Ok(file) => file, - Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error).with_context(|| format!("open key file {path}")), }; - let metadata = file.metadata().with_context(|| format!("inspect key file {path}"))?; - ensure!(metadata.file_type().is_file(), "key file is not a regular file"); - ensure!(link_count(&file)? == 1, "key file has other hard links"); - #[cfg(unix)] - ensure_owner_only(&metadata)?; - #[cfg(target_os = "macos")] - crate::macos_acl::ensure_private_file(&file)?; - #[cfg(windows)] - ensure_private_file(&file)?; // UNTESTED: reading stops one byte past the bound, into a buffer that never reallocates. // An unbounded read lets a large file exhaust memory. // A reallocation leaves an unzeroized copy of the key in freed memory. @@ -134,7 +124,7 @@ pub(super) fn open(dir: &Utf8Path, name: &str) -> anyhow::Result anyhow::Result anyhow::Result<()> { - if !directory_exists(dir)? { + let Some(dir) = open_key_directory(dir)? else { + return Ok(()); + }; + let _lock = lock_and_reconcile(&dir)?; + let file_name = key_file_name(name); + if !listed_with_exact_name(&dir, &file_name)? { return Ok(()); } - let _lock = lock_and_reconcile(dir)?; - delete_key(dir, name) + // On Windows, a removed file keeps its name until every handle on it is closed, so the key file is first renamed to + // free its name at once. + #[cfg(windows)] + let file_name = { + let tombstone = tombstone_name(name); + dir.rename(&file_name, &tombstone, Replace::Refuse) + .context("retire key file")?; + tombstone + }; + dir.remove_file(&file_name).context("delete key file")?; + Ok(()) } pub(super) fn list(dir: &Utf8Path, prefix: &str) -> anyhow::Result> { - if !directory_exists(dir)? { + let Some(dir) = open_key_directory(dir)? else { return Ok(Vec::new()); - } - let _lock = lock_and_reconcile(dir)?; - let entries = fs::read_dir(dir).with_context(|| format!("list key directory {dir}"))?; - let mut names = Vec::new(); - for entry in entries { - let entry = entry.context("read key directory entry")?; - if !entry.file_type().context("inspect key directory entry")?.is_file() { - continue; - } - let file_name = entry.file_name(); - if let Some(name) = file_name - .to_str() - .and_then(|file_name| file_name.strip_suffix(".p8")) - .filter(|name| name_matches_prefix(name, prefix)) - { - names.push(name.to_owned()); - } - } + }; + let _lock = lock_and_reconcile(&dir)?; + let mut names = dir + .entries() + .context("list key directory")? + .into_iter() + .filter(|entry| entry.kind == EntryKind::File) + .filter_map(|entry| { + let name = entry.name.to_str()?.strip_suffix(".p8")?; + name_matches_prefix(name, prefix).then(|| name.to_owned()) + }) + .collect::>(); names.sort_unstable(); Ok(names) } -fn directory_exists(dir: &Utf8Path) -> anyhow::Result { - #[cfg(unix)] - let metadata = fs::symlink_metadata(dir); - #[cfg(not(unix))] - let metadata = fs::metadata(dir); - match metadata { - Ok(metadata) => { - ensure!(metadata.is_dir(), "key directory is not a directory"); - Ok(true) - } - Err(error) if error.kind() == ErrorKind::NotFound => Ok(false), - Err(error) => Err(error).with_context(|| format!("inspect key directory {dir}")), +/// Opens the key directory, or returns `None` when it doesn't exist. +fn open_key_directory(dir: &Utf8Path) -> anyhow::Result> { + // An empty path would name the current directory, which holds no keys. + if dir.as_str().is_empty() { + return Ok(None); } + Dir::open_base(dir, &key_dir_rule()).context("open key directory") } -#[cfg(unix)] -fn create_key_directory(dir: &Utf8Path) -> anyhow::Result<()> { - use private_fs::DirChainError; - - ensure!(!dir.as_str().is_empty(), "key directory is empty"); - private_fs::create_dir_chain(dir).map_err(|error| match error { - DirChainError::NotADirectory(_) => anyhow::anyhow!("key directory is not a directory"), - DirChainError::Inspect(path, error) => { - anyhow::Error::new(error).context(format!("inspect key directory {path}")) - } - DirChainError::Create(path, error) => anyhow::Error::new(error).context(format!("create key directory {path}")), - DirChainError::Sync(path, error) => anyhow::Error::new(error).context(format!("sync key directory {path}")), - }) -} - -#[cfg(not(unix))] -fn create_key_directory(dir: &Utf8Path) -> anyhow::Result<()> { - fs::create_dir_all(dir).with_context(|| format!("create key directory {dir}")) -} - -fn lock_and_reconcile(dir: &Utf8Path) -> anyhow::Result { - #[cfg(target_os = "macos")] - { - let directory = File::open(dir).with_context(|| format!("open key directory {dir}"))?; - crate::macos_acl::ensure_private_directory(&directory)?; - } +fn lock_and_reconcile(dir: &Dir) -> anyhow::Result { let lock = lock_directory(dir)?; cleanup_abandoned(dir)?; Ok(lock) @@ -250,14 +217,13 @@ fn abandoned_name(file_name: &str) -> Option<&str> { .then_some(name) } -fn cleanup_abandoned(dir: &Utf8Path) -> anyhow::Result<()> { - #[cfg(unix)] - let mut removed = false; +fn cleanup_abandoned(dir: &Dir) -> anyhow::Result<()> { // A creator holds this lock until it removes its temporary, so every remaining temporary is abandoned. - for entry in fs::read_dir(dir).with_context(|| format!("inspect abandoned key files in {dir}"))? { - let entry = entry.context("read abandoned key entry")?; - let file_name = entry.file_name(); - let Some(file_name) = file_name.to_str() else { + for entry in dir.entries().context("inspect abandoned key files")? { + if entry.kind == EntryKind::Directory { + continue; + } + let Some(file_name) = entry.name.to_str() else { continue; }; let Some(name) = abandoned_name(file_name) else { @@ -265,57 +231,32 @@ fn cleanup_abandoned(dir: &Utf8Path) -> anyhow::Result<()> { }; #[cfg(not(windows))] let _ = name; - if entry.file_type().context("inspect abandoned key entry")?.is_dir() { - continue; - } #[cfg(windows)] - let path = { - let path = dir.join(file_name); - if file_name.ends_with(".tmp") { - let tombstone = dir.join(format!(".{name}-{}.deleted", Uuid::new_v4())); - rename_new(&path, &tombstone).context("retire abandoned key file")?; - tombstone - } else { - path - } + let file_name = &if file_name.ends_with(".tmp") { + let tombstone = tombstone_name(name); + dir.rename(file_name, &tombstone, Replace::Refuse) + .context("retire abandoned key file")?; + tombstone + } else { + file_name.to_owned() }; - #[cfg(not(windows))] - let path = entry.path(); - fs::remove_file(&path).with_context(|| format!("remove abandoned key file in {dir}"))?; - #[cfg(unix)] - { - removed = true; - } - } - #[cfg(unix)] - if removed { - sync_directory(dir)?; + dir.remove_file(file_name).context("remove abandoned key file")?; } Ok(()) } +fn lock_options() -> FileOptions { + FileOptions::new().read(true).write(true).share(Share::Exclusive) +} + #[cfg(unix)] -fn lock_directory(dir: &Utf8Path) -> anyhow::Result { +fn lock_directory(dir: &Dir) -> anyhow::Result { + use std::io::ErrorKind; use std::os::fd::AsRawFd as _; - use std::os::unix::fs::OpenOptionsExt as _; - - let path = dir.join(".device-key.lock"); - let mut options = OpenOptions::new(); - options - .read(true) - .write(true) - .create(true) - .mode(0o600) - .custom_flags(libc::O_NOFOLLOW); - let file = options - .open(&path) - .with_context(|| format!("open key-directory lock {path}"))?; - let metadata = file.metadata().context("inspect key-directory lock")?; - ensure!( - metadata.file_type().is_file(), - "key-directory lock is not a regular file" - ); - ensure_owner_only(&metadata)?; + + let file = dir + .open_or_create_file(LOCK_NAME, &key_file_rule(), lock_options()) + .context("open key-directory lock")?; let deadline = Instant::now() + LOCK_TIMEOUT; loop { // SAFETY: The file descriptor stays open until this lock guard is dropped. @@ -330,43 +271,25 @@ fn lock_directory(dir: &Utf8Path) -> anyhow::Result { } } +/// Opens the lock file with no sharing, which holds the lock until the handle is closed. #[cfg(windows)] -fn lock_directory(dir: &Utf8Path) -> anyhow::Result { - use std::os::windows::fs::OpenOptionsExt as _; - +fn lock_directory(dir: &Dir) -> anyhow::Result { use windows::Win32::Foundation::ERROR_SHARING_VIOLATION; - use windows::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; - - let path = dir.join(".device-key.lock"); - let mut options = OpenOptions::new(); - options - .read(true) - .write(true) - .share_mode(0) - .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0); + let sharing_violation = i32::try_from(ERROR_SHARING_VIOLATION.0)?; let deadline = Instant::now() + LOCK_TIMEOUT; loop { - match options.open(&path) { - Ok(file) => { - ensure!( - file.metadata()?.file_type().is_file(), - "key-directory lock is not a regular file" - ); - return Ok(file); - } - Err(error) if error.kind() == ErrorKind::NotFound => match create_restricted_file(&path) { - Ok(file) => return Ok(file), - Err(create_error) if fs::symlink_metadata(&path).is_ok() => { - let _ = create_error; - wait_for_lock(deadline)?; - } - Err(error) => return Err(error).context("create key-directory lock"), - }, - Err(error) if error.raw_os_error() == Some(sharing_violation) => { + match dir.open_or_create_file(LOCK_NAME, &key_file_rule(), lock_options()) { + Ok(file) => return Ok(file), + Err(error) + if matches!( + error.kind(), + private_fs::ErrorKind::Io { source, .. } if source.raw_os_error() == Some(sharing_violation) + ) => + { wait_for_lock(deadline)?; } - Err(error) => return Err(error).with_context(|| format!("open key-directory lock {path}")), + Err(error) => return Err(error).context("open key-directory lock"), } } } @@ -381,265 +304,23 @@ fn wait_for_lock(deadline: Instant) -> anyhow::Result<()> { Ok(()) } -#[cfg(unix)] -fn link_count(file: &File) -> anyhow::Result { - use std::os::unix::fs::MetadataExt as _; - - Ok(file.metadata().context("inspect key file links")?.nlink()) -} - -#[cfg(windows)] -fn link_count(file: &File) -> anyhow::Result { - use std::os::windows::io::AsRawHandle as _; - - use windows::Win32::Foundation::HANDLE; - use windows::Win32::Storage::FileSystem::{BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle}; - - let mut information = BY_HANDLE_FILE_INFORMATION::default(); - // SAFETY: The file handle is live and the output pointer is writable. - unsafe { GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information) } - .context("inspect key file links")?; - Ok(u64::from(information.nNumberOfLinks)) -} - -#[cfg(unix)] -fn delete_key(dir: &Utf8Path, name: &str) -> anyhow::Result<()> { - let path = key_path(dir, name); - if !listed_with_exact_name(dir, &path)? { - return Ok(()); - } - match fs::remove_file(&path) { - Ok(()) => sync_directory(dir), - Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), - Err(error) => Err(error).with_context(|| format!("delete key file {path}")), - } -} - -#[cfg(windows)] -fn delete_key(dir: &Utf8Path, name: &str) -> anyhow::Result<()> { - let path = key_path(dir, name); - if !listed_with_exact_name(dir, &path)? { - return Ok(()); - } - let tombstone = dir.join(format!(".{name}-{}.deleted", Uuid::new_v4())); - rename_new(&path, &tombstone).with_context(|| format!("retire key file {path}"))?; - fs::remove_file(&tombstone).with_context(|| format!("delete retired key file {tombstone}")) -} - -/// Reports whether `dir` lists `path`'s file name spelled exactly as given. -/// A case-insensitive file system, such as NTFS or APFS, also resolves `path` to a name that differs in case. -fn listed_with_exact_name(dir: &Utf8Path, path: &Utf8Path) -> anyhow::Result { - let file_name = path.file_name().context("key path has no file name")?; - for entry in fs::read_dir(dir).with_context(|| format!("list key directory {dir}"))? { - if entry.context("read key directory entry")?.file_name() == file_name { - return Ok(true); - } - } - Ok(false) -} - -#[cfg(unix)] -fn sync_directory(dir: &Utf8Path) -> anyhow::Result<()> { - private_fs::sync_directory(dir).with_context(|| format!("sync key directory {dir}")) -} - -#[cfg(any(target_os = "linux", target_os = "macos"))] -fn rename_new(from: &Utf8Path, to: &Utf8Path) -> anyhow::Result<()> { - use std::ffi::CString; - - let from_c = CString::new(from.as_str()).context("invalid source key path")?; - let to_c = CString::new(to.as_str()).context("invalid destination key path")?; - // The raw syscall avoids glibc's renameat2 wrapper, which requires glibc 2.28 (the release baseline is 2.27). - // SAFETY: Both paths are NUL-terminated and point to live buffers. - #[cfg(target_os = "linux")] - let status = unsafe { - libc::syscall( - libc::SYS_renameat2, - libc::AT_FDCWD, - from_c.as_ptr(), - libc::AT_FDCWD, - to_c.as_ptr(), - libc::RENAME_NOREPLACE, - ) - }; - // SAFETY: Both paths are NUL-terminated and point to live buffers. - #[cfg(target_os = "macos")] - let status = unsafe { libc::renamex_np(from_c.as_ptr(), to_c.as_ptr(), libc::RENAME_EXCL) }; - if status == 0 { - return Ok(()); - } - let error = std::io::Error::last_os_error(); - if matches!( - error.raw_os_error(), - Some(libc::ENOSYS | libc::EINVAL | libc::EOPNOTSUPP) - ) { - return link_new(from, to); - } - Err(error).context("rename key without replacement") -} - -#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))] -fn rename_new(from: &Utf8Path, to: &Utf8Path) -> anyhow::Result<()> { - link_new(from, to) -} - -#[cfg(unix)] -fn link_new(from: &Utf8Path, to: &Utf8Path) -> anyhow::Result<()> { - fs::hard_link(from, to).context("install key without replacement")?; - fs::remove_file(from).context("remove installed key's temporary name") -} - -#[cfg(windows)] -fn rename_new(from: &Utf8Path, to: &Utf8Path) -> anyhow::Result<()> { - use windows::Win32::Storage::FileSystem::{MOVEFILE_WRITE_THROUGH, MoveFileExW}; - use windows::core::PCWSTR; - - let from = wide(from); - let to = wide(to); - // SAFETY: The two path buffers are valid NUL-terminated UTF-16 strings. - unsafe { - MoveFileExW( - PCWSTR::from_raw(from.as_ptr()), - PCWSTR::from_raw(to.as_ptr()), - MOVEFILE_WRITE_THROUGH, - ) - } - .context("rename key without replacement") -} - -#[cfg(windows)] -fn wide(path: &Utf8Path) -> Vec { - path.as_str().encode_utf16().chain(std::iter::once(0)).collect() -} - -#[cfg(unix)] -fn ensure_owner_only(metadata: &fs::Metadata) -> anyhow::Result<()> { - use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; - - ensure!(metadata.permissions().mode() & 0o077 == 0, "key file is not owner-only"); - let user = private_fs::effective_uid(); - ensure!( - owner_is_trusted(metadata.uid(), user), - "key file owner is neither the process user nor root" - ); - Ok(()) -} - -#[cfg(unix)] -pub(crate) fn owner_is_trusted(owner: libc::uid_t, user: libc::uid_t) -> bool { - owner == user || owner == 0 -} - -#[cfg(windows)] -fn ensure_private_file(file: &File) -> anyhow::Result<()> { - use std::os::windows::io::AsRawHandle as _; - - use win_api_wrappers::security::descriptor::SecurityDescriptor; - use windows::Win32::Foundation::{HANDLE, WIN32_ERROR}; - use windows::Win32::Security; - use windows::Win32::Security::Authorization::SE_FILE_OBJECT; - use windows::Win32::Storage::FileSystem::GetVolumeInformationByHandleW; - - let handle = HANDLE(file.as_raw_handle()); - let mut volume_flags = 0; - // SAFETY: The file handle is live and the volume-flags output pointer is writable. - unsafe { GetVolumeInformationByHandleW(handle, None, None, None, Some(&mut volume_flags), None) } - .context("inspect key file volume")?; - ensure!( - volume_supports_acls(volume_flags), - "key file volume does not enforce ACLs" - ); - - let descriptor = SecurityDescriptor::of_handle(handle, SE_FILE_OBJECT).map_err(|error| { - let status = WIN32_ERROR::from_error(&error).map_or_else(|| error.code().0.cast_unsigned(), |status| status.0); - anyhow::anyhow!("read key file ACL failed ({status})") - })?; - let owner = descriptor - .owner() - .context("read key file owner")? - .context("key file has no owner")?; - let user = win_api_wrappers::token::Token::current_process_token() - .sid_and_attributes() - .context("get process user SID")? - .sid; - let system = Sid::from_well_known(Security::WinLocalSystemSid, None).context("get SYSTEM SID")?; - ensure!(trusted_owner(&owner, &user, &system), "key file owner is not trusted"); - let mut expected = vec![system.clone()]; - if user != system { - expected.push(user); - } - crate::windows_acl::require_protected_dacl(descriptor.as_raw(), expected) -} - -#[cfg(windows)] -fn trusted_owner(owner: &Sid, user: &Sid, system: &Sid) -> bool { - owner == user || owner == system -} - -#[cfg(windows)] -fn volume_supports_acls(flags: u32) -> bool { - use win_api_wrappers::raw::Win32::System::SystemServices::FILE_PERSISTENT_ACLS; - - flags & FILE_PERSISTENT_ACLS != 0 -} - -#[cfg(not(windows))] -fn create_restricted_file(path: &Utf8Path) -> anyhow::Result { - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt as _; - - options.mode(0o600); - } - options.open(path).with_context(|| format!("create key file {path}")) -} - -#[cfg(windows)] -fn create_restricted_file(path: &Utf8Path) -> anyhow::Result { - use win_api_wrappers::raw::Win32::Security::Authorization::GRANT_ACCESS; - use win_api_wrappers::raw::Win32::Security::{self}; - use win_api_wrappers::raw::Win32::Storage::FileSystem::FILE_ALL_ACCESS; - use win_api_wrappers::security::acl::{Acl, ExplicitAccess, InheritableAcl, InheritableAclKind, Trustee}; - use win_api_wrappers::security::attributes::SecurityAttributesInit; - use win_api_wrappers::token::Token; - - let system = Sid::from_well_known(Security::WinLocalSystemSid, None).context("get SYSTEM SID")?; - let user = Token::current_process_token() - .sid_and_attributes() - .context("get process user SID")? - .sid; - let entry = |sid| ExplicitAccess { - access_permissions: FILE_ALL_ACCESS.0, - access_mode: GRANT_ACCESS, - inheritance: Security::NO_INHERITANCE, - trustee: Trustee::Sid(sid), - }; - let mut entries = vec![entry(system.clone())]; - if user != system { - entries.push(entry(user.clone())); - } - let dacl = InheritableAcl { - kind: InheritableAclKind::Protected, - acl: Acl::new() - .and_then(|acl| acl.set_entries(&entries)) - .context("build key file DACL")?, - }; - let attributes = SecurityAttributesInit { - owner: Some(user), - dacl: Some(dacl), - ..Default::default() - } - .init(); - win_api_wrappers::fs::create_file(path.as_std_path(), Some(&attributes)) - .with_context(|| format!("create key file {path}")) +/// Reports whether `dir` lists `file_name` spelled exactly as given. +/// A case-insensitive file system, such as NTFS or APFS, also resolves `file_name` to a name that differs in case. +fn listed_with_exact_name(dir: &Dir, file_name: &str) -> anyhow::Result { + Ok(dir + .entries() + .context("list key directory")? + .iter() + .any(|entry| entry.name == file_name)) } #[cfg(test)] mod tests { + use std::fs; + use base64::Engine as _; use base64::engine::general_purpose::STANDARD; + use camino::Utf8PathBuf; use p256::ecdsa::signature::Verifier as _; use super::*; @@ -650,11 +331,14 @@ mod tests { struct KeyDirectory(Utf8PathBuf); impl KeyDirectory { + /// Creates an empty key directory under the system's temporary directory. + /// + /// The key directory's ancestors must pass the crate's checks, which a build directory on a network or WSL + /// volume may not. fn new() -> anyhow::Result { - let target = std::env::var("CARGO_TARGET_DIR") - .map(Utf8PathBuf::from) - .unwrap_or_else(|_| Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("target")); - let dir = target.join("key-tests").join(Uuid::new_v4().to_string()); + let temp = Utf8PathBuf::from_path_buf(std::env::temp_dir()) + .map_err(|path| anyhow::anyhow!("temporary directory {} is not UTF-8", path.display()))?; + let dir = temp.join(format!("device-key-test-{}", Uuid::new_v4())); fs::create_dir_all(&dir)?; Ok(Self(dir)) } @@ -662,6 +346,21 @@ mod tests { fn backend(&self) -> KeyBackend { KeyBackend::File { dir: self.0.clone() } } + + fn open(&self) -> anyhow::Result { + Dir::open_base(&self.0, &key_dir_rule())?.context("missing key directory") + } + + /// Creates the file `file_name` in the key directory as the crate creates a key file. + fn create_file(&self, file_name: &str) -> anyhow::Result { + Ok(self + .open()? + .create_file(file_name, &key_file_rule(), FileOptions::new())?) + } + + fn key_path(&self, name: &str) -> Utf8PathBuf { + self.0.join(key_file_name(name)) + } } impl Drop for KeyDirectory { @@ -672,12 +371,11 @@ mod tests { /// Leaves the temporary of a creator that stopped before installing its key. fn create_leftover(directory: &KeyDirectory) -> anyhow::Result { - let leftover = directory - .0 - .join(format!(".{}-{}.tmp", new_key_name(TEST_PREFIX), Uuid::new_v4())); - let mut file = create_restricted_file(&leftover)?; - file.write_all(b"unfinalized private key")?; - Ok(leftover) + let file_name = format!(".{}-{}.tmp", new_key_name(TEST_PREFIX), Uuid::new_v4()); + directory + .create_file(&file_name)? + .write_all(b"unfinalized private key")?; + Ok(directory.0.join(file_name)) } // oracle: intent crates/device-key/INTENT.md §Operations @@ -710,7 +408,7 @@ mod tests { { use std::os::unix::fs::PermissionsExt as _; - let mode = fs::metadata(key_path(&dir.0, &name))?.permissions().mode(); + let mode = fs::metadata(dir.key_path(&name))?.permissions().mode(); // oracle: intent crates/device-key/INTENT.md §Access assert_eq!(mode & 0o077, 0); } @@ -735,8 +433,7 @@ mod tests { .as_str() .context("missing vector private key")?; let pkcs8 = Zeroizing::new(STANDARD.decode(encoded)?); - let path = key_path(&dir.0, &name); - let mut file = create_restricted_file(&path)?; + let mut file = dir.create_file(&key_file_name(&name))?; file.write_all(&pkcs8)?; file.sync_all()?; drop(file); @@ -809,22 +506,22 @@ mod tests { let directory = KeyDirectory::new()?; let backend = directory.backend(); let name = new_key_name(TEST_PREFIX); - let orphan = directory.0.join(format!(".{name}-{}.tmp", Uuid::new_v4())); - let mut file = create_restricted_file(&orphan)?; + let orphan = format!(".{name}-{}.tmp", Uuid::new_v4()); + let mut file = directory.create_file(&orphan)?; file.write_all(b"unfinalized private key")?; file.sync_all()?; drop(file); let other_name = new_key_name("other"); - let other_orphan = directory.0.join(format!(".{other_name}-{}.deleted", Uuid::new_v4())); - let mut file = create_restricted_file(&other_orphan)?; + let other_orphan = format!(".{other_name}-{}.deleted", Uuid::new_v4()); + let mut file = directory.create_file(&other_orphan)?; file.write_all(b"abandoned key material")?; file.sync_all()?; drop(file); assert!(list(&backend, TEST_PREFIX)?.is_empty()); - assert!(!orphan.exists()); - assert!(!other_orphan.exists()); + assert!(!directory.0.join(orphan).exists()); + assert!(!directory.0.join(other_orphan).exists()); Ok(()) } @@ -869,7 +566,7 @@ mod tests { format!("{name}-{}.tmp", uuid()), ]; for file_name in &outside_files { - create_restricted_file(&directory.0.join(file_name))?; + directory.create_file(file_name)?; } // An operation may skip an unexpected entry or fail, so only what it leaves behind is checked. @@ -895,7 +592,7 @@ mod tests { format!("{TEST_PREFIX}.p8"), ]; for stray_name in &stray_names { - create_restricted_file(&directory.0.join(stray_name))?; + directory.create_file(stray_name)?; } // An operation may skip an unexpected entry or fail; a panic fails this test. @@ -952,7 +649,7 @@ mod tests { let other_name = new_key_name("scope-longer"); generate(&backend, &name)?; generate(&backend, &other_name)?; - create_restricted_file(&key_path(&directory.0, "scope_INVALID"))?; + directory.create_file(&key_file_name("scope_INVALID"))?; assert_eq!(list(&backend, "scope")?, vec![name]); assert_eq!(list(&backend, "scope-longer")?, vec![other_name]); @@ -992,11 +689,10 @@ mod tests { let directory = KeyDirectory::new()?; let backend = directory.backend(); // The test holds the lock as a creator in another process would while it writes its temporary. - let lock = lock_directory(&directory.0)?; - let temporary = directory - .0 - .join(format!(".{}-{}.tmp", new_key_name(TEST_PREFIX), Uuid::new_v4())); - create_restricted_file(&temporary)?; + let lock = lock_directory(&directory.open()?)?; + let temporary = format!(".{}-{}.tmp", new_key_name(TEST_PREFIX), Uuid::new_v4()); + directory.create_file(&temporary)?; + let temporary = directory.0.join(temporary); let lister = std::thread::spawn(move || list(&backend, TEST_PREFIX)); std::thread::sleep(Duration::from_millis(500)); @@ -1010,7 +706,7 @@ mod tests { #[test] fn lock_wait_expires_and_a_later_operation_can_proceed() -> anyhow::Result<()> { let directory = KeyDirectory::new()?; - let lock = lock_directory(&directory.0)?; + let lock = lock_directory(&directory.open()?)?; let error = list(&directory.backend(), TEST_PREFIX) .err() .context("contended key-directory lock did not time out")?; @@ -1030,7 +726,7 @@ mod tests { let directory = KeyDirectory::new()?; // An earlier operation leaves the lock file behind, so the holder below opens it instead of creating it. list(&directory.backend(), TEST_PREFIX)?; - let _lock = lock_directory(&directory.0)?; + let _lock = lock_directory(&directory.open()?)?; assert!(list(&directory.backend(), TEST_PREFIX).is_err()); Ok(()) @@ -1046,7 +742,7 @@ mod tests { let backend = directory.backend(); let name = new_key_name(TEST_PREFIX); generate(&backend, &name)?; - let path = key_path(&directory.0, &name); + let path = directory.key_path(&name); fs::set_permissions(&path, fs::Permissions::from_mode(0o640))?; assert!(open(&backend, &name).is_err()); fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; @@ -1054,16 +750,6 @@ mod tests { Ok(()) } - // oracle: intent crates/device-key/INTENT.md §Access - #[cfg(unix)] - #[test] - fn only_the_caller_or_root_can_own_a_key_file() { - assert!(owner_is_trusted(1000, 1000)); - assert!(owner_is_trusted(0, 1000)); - assert!(!owner_is_trusted(1001, 1000)); - assert!(!owner_is_trusted(1001, 0)); - } - // oracle: intent crates/device-key/INTENT.md §Access #[cfg(unix)] #[test] @@ -1102,7 +788,7 @@ mod tests { let other_name = new_key_name(TEST_PREFIX); generate(&backend, &name)?; generate(&backend, &other_name)?; - std::os::unix::fs::chown(key_path(&directory.0, &name), Some(nobody), None)?; + std::os::unix::fs::chown(directory.key_path(&name), Some(nobody), None)?; assert!(open(&backend, &name).is_err()); // oracle: intent crates/device-key/INTENT.md §Operations @@ -1115,34 +801,6 @@ mod tests { Ok(()) } - // oracle: intent crates/device-key/INTENT.md §Access - // oracle: spec MS-FSCC §2.5.1 - #[cfg(windows)] - #[test] - fn key_files_require_persistent_volume_acls() { - use win_api_wrappers::raw::Win32::System::SystemServices::FILE_PERSISTENT_ACLS; - - assert!(!volume_supports_acls(0)); - assert!(!volume_supports_acls(2)); - assert!(volume_supports_acls(FILE_PERSISTENT_ACLS)); - } - - // oracle: intent crates/device-key/INTENT.md §Access - #[cfg(windows)] - #[test] - fn key_file_owner_must_be_process_user_or_system() -> anyhow::Result<()> { - use win_api_wrappers::token::Token; - use windows::Win32::Security; - - let system = Sid::from_well_known(Security::WinLocalSystemSid, None)?; - let users = Sid::from_well_known(Security::WinBuiltinUsersSid, None)?; - let user = Token::current_process_token().sid_and_attributes()?.sid; - assert!(trusted_owner(&user, &user, &system)); - assert!(trusted_owner(&system, &user, &system)); - assert!(!trusted_owner(&users, &user, &system)); - Ok(()) - } - // oracle: intent crates/device-key/INTENT.md §File backend #[cfg(unix)] #[test] @@ -1194,11 +852,13 @@ mod tests { let backend = directory.backend(); let name = new_key_name(TEST_PREFIX); generate(&backend, &name)?; - let retired = directory.0.join(format!(".{name}-{}.deleted", Uuid::new_v4())); - rename_new(&key_path(&directory.0, &name), &retired)?; + let retired = format!(".{name}-{}.deleted", Uuid::new_v4()); + directory + .open()? + .rename(&key_file_name(&name), &retired, Replace::Refuse)?; assert!(open(&backend, &name)?.is_none()); - assert!(!retired.exists()); + assert!(!directory.0.join(retired).exists()); Ok(()) } @@ -1210,10 +870,10 @@ mod tests { let real_name = new_key_name(TEST_PREFIX); let alias = new_key_name(TEST_PREFIX); generate(&backend, &real_name)?; - fs::hard_link(key_path(&directory.0, &real_name), key_path(&directory.0, &alias))?; + fs::hard_link(directory.key_path(&real_name), directory.key_path(&alias))?; assert!(open(&backend, &alias).is_err()); assert!(open(&backend, &real_name).is_err()); - fs::remove_file(key_path(&directory.0, &alias))?; + fs::remove_file(directory.key_path(&alias))?; assert!(open(&backend, &real_name)?.is_some()); Ok(()) } @@ -1228,10 +888,10 @@ mod tests { let backend = directory.backend(); let name = new_key_name(TEST_PREFIX); generate(&backend, &name)?; - let _holder = OpenOptions::new() + let _holder = fs::OpenOptions::new() .read(true) .share_mode(0) - .open(key_path(&directory.0, &name))?; + .open(directory.key_path(&name))?; assert!(!matches!(open(&backend, &name), Ok(None))); Ok(()) @@ -1241,6 +901,7 @@ mod tests { #[cfg(windows)] #[test] fn a_key_file_another_account_can_read_cannot_be_opened() -> anyhow::Result<()> { + use win_api_wrappers::identity::sid::Sid; use win_api_wrappers::raw::Win32::Security::Authorization::{GRANT_ACCESS, SE_FILE_OBJECT}; use win_api_wrappers::raw::Win32::Security::{self, WinBuiltinUsersSid, WinLocalSystemSid}; use win_api_wrappers::raw::Win32::Storage::FileSystem::{FILE_ALL_ACCESS, FILE_GENERIC_READ}; @@ -1269,7 +930,7 @@ mod tests { entry(Sid::from_well_known(WinBuiltinUsersSid, None)?, FILE_GENERIC_READ.0), ])?, }; - let path = U16CString::from_str(key_path(&directory.0, &name).as_str())?; + let path = U16CString::from_str(directory.key_path(&name).as_str())?; set_named_security_info(&path, SE_FILE_OBJECT, None, None, Some(&dacl), None)?; assert!(open(&backend, &name).is_err()); @@ -1280,6 +941,54 @@ mod tests { Ok(()) } + // oracle: intent crates/device-key/INTENT.md §Access + #[cfg(windows)] + #[test] + fn elevated_only_a_key_file_owned_by_administrators_cannot_be_opened() -> anyhow::Result<()> { + use win_api_wrappers::identity::sid::Sid; + use win_api_wrappers::raw::Win32::Security::Authorization::SE_FILE_OBJECT; + use win_api_wrappers::raw::Win32::Security::WinBuiltinAdministratorsSid; + use win_api_wrappers::security::acl::set_named_security_info; + use win_api_wrappers::str::U16CString; + use win_api_wrappers::token::Token; + + // Only an elevated token can make BUILTIN\Administrators the owner without the restore privilege. + if !Token::current_process_token().is_elevated()? { + // The test harness captures `eprintln!` output from a passing test, but not direct writes to stderr. + writeln!( + std::io::stderr(), + "SKIP: elevated_only_a_key_file_owned_by_administrators_cannot_be_opened needs elevation" + )?; + return Ok(()); + } + let directory = KeyDirectory::new()?; + let backend = directory.backend(); + let name = new_key_name(TEST_PREFIX); + let other_name = new_key_name(TEST_PREFIX); + generate(&backend, &name)?; + generate(&backend, &other_name)?; + // The owner of a file can change its DACL, so an owner other than the caller's account or SYSTEM breaks the + // access rule even when the DACL holds. + let administrators = Sid::from_well_known(WinBuiltinAdministratorsSid, None)?; + set_named_security_info( + &U16CString::from_str(directory.key_path(&name).as_str())?, + SE_FILE_OBJECT, + Some(&administrators), + None, + None, + None, + )?; + + assert!(open(&backend, &name).is_err()); + // oracle: intent crates/device-key/INTENT.md §Operations + assert_eq!(list(&backend, TEST_PREFIX)?.len(), 2); + delete(&backend, &name)?; + // oracle: intent crates/device-key/INTENT.md §Operations + assert_eq!(list(&backend, TEST_PREFIX)?, vec![other_name.clone()]); + assert!(open(&backend, &other_name)?.is_some()); + Ok(()) + } + // oracle: intent crates/device-key/INTENT.md §Operations #[cfg(unix)] #[test] @@ -1289,7 +998,7 @@ mod tests { let directory = KeyDirectory::new()?; let backend = directory.backend(); let name = new_key_name(TEST_PREFIX); - let fifo = CString::new(key_path(&directory.0, &name).as_str())?; + let fifo = CString::new(directory.key_path(&name).as_str())?; // SAFETY: The path is NUL-terminated. let status = unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }; ensure!(status == 0, "create FIFO: {}", std::io::Error::last_os_error()); @@ -1324,8 +1033,8 @@ mod tests { fn case_alias_survives_delete_and_list(name: &str, alias: &str) -> anyhow::Result<()> { let directory = KeyDirectory::new()?; let backend = directory.backend(); + directory.create_file(alias)?; let alias = directory.0.join(alias); - create_restricted_file(&alias)?; // An operation may skip an unexpected entry or fail, so only what it leaves behind is checked. let _ = delete(&backend, name); @@ -1344,7 +1053,7 @@ mod tests { let pkcs8 = key.to_pkcs8_der()?; let at_bound = pad_pkcs8(pkcs8.as_bytes(), 64 * 1024)?; let name = new_key_name(TEST_PREFIX); - create_restricted_file(&key_path(&directory.0, &name))?.write_all(&at_bound)?; + directory.create_file(&key_file_name(&name))?.write_all(&at_bound)?; let opened = open(&backend, &name)?.context("a 64 KiB key file was refused")?; // oracle: property round-trip assert_eq!(opened.public_key(), *key.verifying_key()); @@ -1356,7 +1065,7 @@ mod tests { [at_bound.as_slice(), &[0]].concat(), ] { let name = new_key_name(TEST_PREFIX); - create_restricted_file(&key_path(&directory.0, &name))?.write_all(&past_bound)?; + directory.create_file(&key_file_name(&name))?.write_all(&past_bound)?; // An operation may skip an unexpected entry or fail, but never returns it as a key. assert!(!matches!(open(&backend, &name), Ok(Some(_)))); } @@ -1423,7 +1132,7 @@ mod tests { let real_name = new_key_name(TEST_PREFIX); let alias = new_key_name(TEST_PREFIX); generate(&backend, &real_name)?; - std::os::unix::fs::symlink(key_path(&directory.0, &real_name), key_path(&directory.0, &alias))?; + std::os::unix::fs::symlink(directory.key_path(&real_name), directory.key_path(&alias))?; assert!(open(&backend, &alias).is_err()); assert_eq!(list(&backend, TEST_PREFIX)?, vec![real_name.clone()]); delete(&backend, &alias)?; diff --git a/crates/device-key/src/lib.rs b/crates/device-key/src/lib.rs index 5d4550f409..b66487f796 100644 --- a/crates/device-key/src/lib.rs +++ b/crates/device-key/src/lib.rs @@ -1,8 +1,6 @@ mod file; #[cfg(windows)] mod keystore; -#[cfg(target_os = "macos")] -mod macos_acl; #[cfg(windows)] mod windows_acl; diff --git a/crates/device-key/src/macos_acl.rs b/crates/device-key/src/macos_acl.rs deleted file mode 100644 index bcb5b6add6..0000000000 --- a/crates/device-key/src/macos_acl.rs +++ /dev/null @@ -1,44 +0,0 @@ -use std::fs::File; -use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; - -use anyhow::{Context as _, ensure}; -use private_fs::ExtendedAclError; - -fn ensure_private_volume(file: &File) -> anyhow::Result<()> { - let ignores_ownership = private_fs::volume_ignores_ownership(file).context("inspect key volume")?; - ensure!(!ignores_ownership, "key volume ignores file ownership"); - Ok(()) -} - -fn ensure_no_extended_acl(file: &File) -> anyhow::Result<()> { - let has_entries = private_fs::has_extended_acl(file).map_err(|error| match error { - ExtendedAclError::Inspect(error) => anyhow::Error::new(error).context("inspect key path ACL"), - ExtendedAclError::Validate(error) => anyhow::Error::new(error).context("validate key path ACL"), - ExtendedAclError::ReadEntries(error) => anyhow::Error::new(error).context("read key path ACL entries"), - })?; - ensure!(!has_entries, "key path has extended ACL entries"); - Ok(()) -} - -pub(crate) fn ensure_private_file(file: &File) -> anyhow::Result<()> { - ensure_private_volume(file)?; - let user = private_fs::effective_uid(); - let owner = file.metadata()?.uid(); - ensure!( - crate::file::owner_is_trusted(owner, user), - "key file owner is neither the process user nor root" - ); - ensure_no_extended_acl(file) -} - -pub(crate) fn ensure_private_directory(file: &File) -> anyhow::Result<()> { - ensure_private_volume(file)?; - let metadata = file.metadata()?; - let user = private_fs::effective_uid(); - ensure!(metadata.uid() == user, "key directory owner is not the process user"); - ensure!( - metadata.permissions().mode() & 0o022 == 0, - "key directory is writable by other users" - ); - ensure_no_extended_acl(file) -} From 2622b4a5fc04f9db90c79e22e463d1dfdfde236c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 16:39:01 +0900 Subject: [PATCH 08/60] fix(private-fs): harden the Windows backend Renames and removals use POSIX semantics, falling back to the legacy information classes where the file system lacks them, so a reader that shares deletion no longer blocks a replacement and a removed name can be created again at once. Dir::sync flushes the directory, entries lists through a handle of its own, and a created directory that fails its rule is removed as a file is. Only name-surrogate reparse points count as links, so cloud placeholders and deduplicated files are not refused. Entry names that Win32 path functions reach differently, with a trailing dot or space or a reserved device name, are refused, and a thread holds one backup privilege guard at a time. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/windows/mod.rs | 174 +++++++++++++++++---- crates/private-fs/src/windows/nt.rs | 151 ++++++++++++------ crates/private-fs/src/windows/security.rs | 75 +++++++-- testsuite/tests/private_fs/windows.rs | 177 ++++++++++++++++------ 4 files changed, 450 insertions(+), 127 deletions(-) diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index a3a344176c..4e7c983c12 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -7,6 +7,7 @@ mod nt; mod security; +use std::cell::Cell; use std::fs::{File, OpenOptions}; use std::io; use std::marker::PhantomData; @@ -25,6 +26,7 @@ use windows::Win32::Security::{ SetKernelObjectSecurity, TOKEN_ADJUST_PRIVILEGES, TOKEN_DUPLICATE, TOKEN_IMPERSONATE, TOKEN_PRIVILEGES, TOKEN_QUERY, TokenImpersonation, }; +use windows::Win32::Security::{GetTokenInformation, TOKEN_STATISTICS, TokenStatistics}; use windows::Win32::Storage::FileSystem::{ DELETE, FILE_ACCESS_RIGHTS, FILE_ADD_FILE, FILE_ATTRIBUTE_DEVICE, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAGS_AND_ATTRIBUTES, @@ -113,8 +115,9 @@ pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, let descriptor = Descriptor::new(&policy.accounts.user, &policy.dacl(&policy.accounts.user)) .map_err(|error| Error::io(path, "build security descriptor", error))?; let request = Request { - // FILE_ADD_FILE lets the new directory's handle be flushed. - access: FILE_ACCESS_RIGHTS(DIRECTORY_ACCESS.0 | FILE_ADD_FILE.0), + // FILE_ADD_FILE lets the new directory's handle be flushed, and DELETE lets a directory that fails its rule be + // removed. + access: FILE_ACCESS_RIGHTS(DIRECTORY_ACCESS.0 | FILE_ADD_FILE.0 | DELETE.0), share: SHARE_ALL, disposition: FILE_CREATE, options: NTCREATEFILE_CREATE_OPTIONS(OPEN_OPTIONS.0 | FILE_DIRECTORY_FILE.0), @@ -123,7 +126,11 @@ pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, }; match nt::create(parent.handle(), name, &request) { Ok(handle) => { - check_item(handle.as_handle(), path, Kind::Directory, &policy)?; + if let Err(error) = check_item(handle.as_handle(), path, Kind::Directory, &policy) { + // A directory that fails its rule, as on a volume that drops DACLs, is not left behind. + let _ = nt::delete(handle.as_handle()); + return Err(error); + } // UNTESTED: once this returns, a power cut leaves the new directory's entry in its parent. // No test can cut the power; NTFS logs the entry's creation, and flushing the new directory commits the // log up to it. @@ -219,7 +226,7 @@ pub(crate) fn create_file( let handle = nt::create(parent.handle(), name, &request).map_err(|error| Error::io(path, "create file", error))?; if let Err(error) = check_item(handle.as_handle(), path, Kind::File, &policy) { // A file that fails its rule, as on a volume that drops DACLs, is not left behind. - let _ = nt::delete_on_close(handle.as_handle()); + let _ = nt::delete(handle.as_handle()); return Err(error); } Ok(File::from(handle)) @@ -278,17 +285,20 @@ pub(crate) fn remove_file(parent: &Dir, path: &Utf8Path, name: &str) -> Result return Ok(false), Err(error) => return Err(Error::io(path, "open file", error)), }; - nt::delete_on_close(handle.as_handle()).map_err(|error| Error::io(path, "remove file", error))?; + nt::delete(handle.as_handle()).map_err(|error| Error::io(path, "remove file", error))?; Ok(true) } /// Removes `file`, which this directory's handle created as `name`, through the file's own handle. pub(crate) fn discard(_parent: &Dir, path: &Utf8Path, file: &File, _name: &str) -> Result<()> { - nt::delete_on_close(file.as_handle()).map_err(|error| Error::io(path, "remove file", error)) + nt::delete(file.as_handle()).map_err(|error| Error::io(path, "remove file", error)) } pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { - let listed = nt::list(dir.handle()).map_err(|error| Error::io(path, "list directory", error))?; + // A handle of its own, so that listings on the same directory at once don't share one enumeration cursor. + let listing = reopen(dir.handle(), FILE_ACCESS_RIGHTS(FILE_LIST_DIRECTORY.0 | SYNCHRONIZE.0)) + .map_err(|error| Error::io(path, "open directory", error))?; + let listed = nt::list(listing.as_handle()).map_err(|error| Error::io(path, "list directory", error))?; Ok(listed .into_iter() .map(|(name, attributes)| Entry { @@ -306,9 +316,15 @@ pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { .collect()) } -/// Windows has no directory fsync: [`create_dir`] and [`persist`] flush what they change. -pub(crate) fn sync(_dir: &Dir, _path: &Utf8Path) -> Result<()> { - Ok(()) +pub(crate) fn sync(dir: &Dir, path: &Utf8Path) -> Result<()> { + // FlushFileBuffers needs write access, which on a directory is FILE_ADD_FILE. + let flushing = reopen(dir.handle(), FILE_ACCESS_RIGHTS(FILE_ADD_FILE.0 | SYNCHRONIZE.0)) + .map_err(|error| Error::io(path, "open directory", error))?; + // UNTESTED: once this returns, a power cut leaves the entries created, renamed and removed in the directory. + // No test can cut the power; Windows has no directory fsync, but NTFS logs every change of an entry, and flushing a + // handle on the directory commits the log up to the last change. + // When changing this, keep a flush of a handle on the directory itself. + nt::flush(flushing.as_handle()).map_err(|error| Error::io(path, "flush directory", error)) } pub(crate) fn check_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { @@ -340,17 +356,8 @@ pub(crate) fn repair_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> access |= WRITE_OWNER.0; information |= OWNER_SECURITY_INFORMATION; } - // An empty name relative to a handle opens the object that handle refers to. - let request = Request { - access: FILE_ACCESS_RIGHTS(access), - share: SHARE_ALL, - disposition: FILE_OPEN, - options: OPEN_OPTIONS, - attributes: FILE_FLAGS_AND_ATTRIBUTES(0), - descriptor: None, - }; - let reopened = - nt::create(dir.handle(), "", &request).map_err(|error| Error::io(path, "open directory for repair", error))?; + let reopened = reopen(dir.handle(), FILE_ACCESS_RIGHTS(access)) + .map_err(|error| Error::io(path, "open directory for repair", error))?; let original = nt::file_id(dir.handle()).map_err(|error| Error::io(path, "read file ID", error))?; let current = nt::file_id(reopened.as_handle()).map_err(|error| Error::io(path, "read file ID", error))?; if original != current { @@ -378,10 +385,13 @@ pub(crate) fn check_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<() /// /// The current thread impersonates a copy of the process token with the privilege enabled, and goes back to its earlier /// token when this is dropped, so it is tied to the thread that enabled it. +/// A thread holds at most one at a time. #[derive(Debug)] pub struct BackupPrivilege { /// The thread's token before this one, which it impersonates again on drop. previous: Option, + /// The ID of the token this sets on the thread, so that a drop restores the earlier token only over it. + token_id: LUID, _token: OwnedHandle, /// Impersonation is a property of the thread, so this stays on the thread that created it. _thread: PhantomData<*const ()>, @@ -391,6 +401,14 @@ impl BackupPrivilege { pub fn enable() -> Result { let path = Utf8Path::new(""); let error = |step, error| Error::io(path, step, nt::win32_error(error)); + // Guards dropped out of order would restore each other's earlier token, so nesting is refused. + if BACKUP_PRIVILEGE_HELD.get() { + return Err(Error::io( + path, + "enable backup privilege", + io::Error::other("this thread already holds the backup privilege"), + )); + } // SAFETY: Getting the pseudo-handle of the current thread has no preconditions. let thread = unsafe { GetCurrentThread() }; let mut previous = HANDLE::default(); @@ -453,19 +471,72 @@ impl BackupPrivilege { if last.raw_os_error() == Some(ERROR_NOT_ALL_ASSIGNED.0.cast_signed()) { return Err(Error::io(path, "enable backup privilege", last)); } + let token_id = + token_id(token.as_handle()).map_err(|cause| Error::io(path, "read backup token statistics", cause))?; // SAFETY: The token is an impersonation token open with TOKEN_IMPERSONATE. unsafe { SetThreadToken(None, Some(raw(token.as_handle()))) } .map_err(|cause| error("impersonate backup token", cause))?; + BACKUP_PRIVILEGE_HELD.set(true); Ok(Self { previous, + token_id, _token: token, _thread: PhantomData, }) } } +thread_local! { + /// Whether a `BackupPrivilege` of this thread is alive. + static BACKUP_PRIVILEGE_HELD: Cell = const { Cell::new(false) }; +} + +/// Returns the ID of a token, which tells apart two tokens even when they are copies of one. +fn token_id(token: BorrowedHandle<'_>) -> io::Result { + let mut statistics = TOKEN_STATISTICS::default(); + let mut length = 0; + // SAFETY: The token is open with TOKEN_QUERY, and the output is a writable TOKEN_STATISTICS of the size passed. + unsafe { + GetTokenInformation( + raw(token), + TokenStatistics, + Some((&raw mut statistics).cast()), + u32::try_from(size_of::()).expect("TOKEN_STATISTICS is a few dozen bytes"), + &raw mut length, + ) + } + .map_err(nt::win32_error)?; + Ok(statistics.TokenId) +} + +/// Returns the ID of the token the current thread impersonates, or `None` when it impersonates none. +fn thread_token_id() -> io::Result> { + // SAFETY: Getting the pseudo-handle of the current thread has no preconditions. + let thread = unsafe { GetCurrentThread() }; + let mut token = HANDLE::default(); + // SAFETY: The pseudo-handle of the current thread is always valid and the output is writable. + match unsafe { OpenThreadToken(thread, TOKEN_QUERY, true, &raw mut token) } { + Ok(()) => { + // SAFETY: OpenThreadToken succeeded, so the handle is open and nothing else owns it. + let token = unsafe { OwnedHandle::from_raw_handle(token.0) }; + token_id(token.as_handle()).map(Some) + } + Err(cause) if cause.code() == ERROR_NO_TOKEN.to_hresult() => Ok(None), + Err(cause) => Err(nt::win32_error(cause)), + } +} + impl Drop for BackupPrivilege { fn drop(&mut self) { + BACKUP_PRIVILEGE_HELD.set(false); + // A thread that impersonates another token since is left with it: restoring the earlier token over it would + // take away an impersonation this guard didn't set. + let current = thread_token_id().ok().flatten(); + if current.is_none_or(|current| { + (current.LowPart, current.HighPart) != (self.token_id.LowPart, self.token_id.HighPart) + }) { + return; + } // A failure leaves the thread with the backup privilege, and there is no caller to report it to. match &self.previous { Some(previous) => { @@ -497,9 +568,14 @@ fn creation_policy(rule: &Rule, kind: Kind, path: &Utf8Path) -> Result { Ok(policy) } +/// Refuses a name that Win32 path functions would reach as another entry or as no entry at all, so that what +/// private-fs creates and opens is what other programs see under that name. fn validate_name(path: &Utf8Path, name: &str) -> Result<()> { // A colon names an alternate data stream of a file, not an entry of the directory. - if name.contains(':') { + let stream = name.contains(':'); + // Win32 path functions strip a trailing dot or space. + let stripped = name.ends_with(['.', ' ']); + if stream || stripped || is_reserved_device_name(name) { return Err(Error::io( path, "use entry name", @@ -509,6 +585,38 @@ fn validate_name(path: &Utf8Path, name: &str) -> Result<()> { Ok(()) } +/// Reports whether Win32 path functions take `name` for a device rather than an entry, with or without an extension. +fn is_reserved_device_name(name: &str) -> bool { + const DEVICES: [&str; 6] = ["CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"]; + const NUMBERED: [&str; 2] = ["COM", "LPT"]; + const DIGITS: [&str; 13] = [ + "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "\u{b9}", "\u{b2}", "\u{b3}", + ]; + let stem = name + .split('.') + .next() + .unwrap_or(name) + .trim_end_matches(' ') + .to_ascii_uppercase(); + DEVICES.contains(&stem.as_str()) + || NUMBERED + .iter() + .any(|prefix| stem.strip_prefix(prefix).is_some_and(|digit| DIGITS.contains(&digit))) +} + +/// Opens the object `handle` refers to again, with `access`: an empty name relative to a handle names its object. +fn reopen(handle: BorrowedHandle<'_>, access: FILE_ACCESS_RIGHTS) -> io::Result { + let request = Request { + access, + share: SHARE_ALL, + disposition: FILE_OPEN, + options: OPEN_OPTIONS, + attributes: FILE_FLAGS_AND_ATTRIBUTES(0), + descriptor: None, + }; + nt::create(handle, "", &request) +} + fn share_mode(share: Share) -> FILE_SHARE_MODE { match share { Share::Shared => SHARE_ALL, @@ -555,9 +663,13 @@ fn open_entry( } /// Refuses an item that is a link, isn't of `kind`, or is on a volume that doesn't enforce ACLs. +/// +/// A reparse point that stands for another name, such as a symbolic link or a junction, is a link; one that only +/// changes how its content is stored, such as a cloud placeholder, is not. fn classify(handle: BorrowedHandle<'_>, path: &Utf8Path, kind: Kind) -> Result<()> { - let attributes = nt::attributes(handle).map_err(|error| Error::io(path, "read attributes", error))?; - if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { + let (attributes, tag) = + nt::attributes_and_tag(handle).map_err(|error| Error::io(path, "read attributes", error))?; + if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 && nt::is_name_surrogate(tag) { return Err(Error::refused(path, Refusal::Link)); } let is_directory = attributes & FILE_ATTRIBUTE_DIRECTORY.0 != 0; @@ -596,10 +708,9 @@ fn first_existing(path: &Utf8Path) -> Result<(Utf8PathBuf, Vec)> { loop { match std::fs::symlink_metadata(&candidate) { Ok(metadata) => { - if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { - return Err(Error::refused(&candidate, Refusal::Link)); - } - if !metadata.is_dir() { + // A reparse point is opened without following it, and refused as a link once its tag is read. + let reparse_point = metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0; + if !reparse_point && !metadata.is_dir() { return Err(Error::refused(&candidate, Refusal::NotADirectory)); } missing.reverse(); @@ -646,9 +757,12 @@ fn open_first_existing(path: &Utf8Path, policy: &Policy) -> Result { fn check_above(path: &Utf8Path, policy: &Policy) -> Result<()> { let unreadable = |error| Error::refused(path, Refusal::Unreadable(error)); let handle = open_path(path, CHECK_ACCESS).map_err(unreadable)?; - let attributes = nt::attributes(handle.as_handle()).map_err(unreadable)?; + let (attributes, tag) = nt::attributes_and_tag(handle.as_handle()).map_err(unreadable)?; // The path was resolved through every link, so a link on it now was put there since. - if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 || attributes & FILE_ATTRIBUTE_DIRECTORY.0 == 0 { + // A volume mounted on a folder is a name surrogate on the resolved path too, and is refused although nobody put it + // there since. + let link = attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 && nt::is_name_surrogate(tag); + if link || attributes & FILE_ATTRIBUTE_DIRECTORY.0 == 0 { return Err(Error::refused(path, Refusal::Replaceable)); } let security = OwnerAndDacl::of_handle(handle.as_handle()).map_err(unreadable)?; diff --git a/crates/private-fs/src/windows/nt.rs b/crates/private-fs/src/windows/nt.rs index c6b4788d0c..49b4e0a89b 100644 --- a/crates/private-fs/src/windows/nt.rs +++ b/crates/private-fs/src/windows/nt.rs @@ -9,19 +9,22 @@ use std::os::windows::io::{AsRawHandle as _, BorrowedHandle, FromRawHandle as _, use windows::Wdk::Foundation::OBJECT_ATTRIBUTES; use windows::Wdk::Storage::FileSystem::{ - FILE_RENAME_INFORMATION, FileRenameInformation, NTCREATEFILE_CREATE_DISPOSITION, NTCREATEFILE_CREATE_OPTIONS, - NtCreateFile, NtSetInformationFile, + FILE_DISPOSITION_DELETE, FILE_DISPOSITION_POSIX_SEMANTICS, FILE_INFORMATION_CLASS, FILE_RENAME_INFORMATION, + FILE_RENAME_POSIX_SEMANTICS, FILE_RENAME_REPLACE_IF_EXISTS, FileDispositionInformation, + FileDispositionInformationEx, FileRenameInformation, FileRenameInformationEx, NTCREATEFILE_CREATE_DISPOSITION, + NTCREATEFILE_CREATE_OPTIONS, NtCreateFile, NtSetInformationFile, }; use windows::Win32::Foundation::{ - ERROR_NO_MORE_FILES, HANDLE, NTSTATUS, OBJ_CASE_INSENSITIVE, RtlNtStatusToDosError, UNICODE_STRING, + ERROR_NO_MORE_FILES, HANDLE, NTSTATUS, OBJ_CASE_INSENSITIVE, RtlNtStatusToDosError, STATUS_INVALID_INFO_CLASS, + STATUS_INVALID_PARAMETER, STATUS_NOT_SUPPORTED, UNICODE_STRING, }; use windows::Win32::Security::SECURITY_DESCRIPTOR; use windows::Win32::Storage::FileSystem::{ - FILE_ACCESS_RIGHTS, FILE_BASIC_INFO, FILE_DISPOSITION_INFO, FILE_FLAGS_AND_ATTRIBUTES, FILE_ID_BOTH_DIR_INFO, - FILE_ID_INFO, FILE_INFO_BY_HANDLE_CLASS, FILE_NAME_NORMALIZED, FILE_SHARE_MODE, FILE_STANDARD_INFO, FileBasicInfo, - FileDispositionInfo, FileIdBothDirectoryInfo, FileIdBothDirectoryRestartInfo, FileIdInfo, FileStandardInfo, - FlushFileBuffers, GETFINALPATHNAMEBYHANDLE_FLAGS, GetFileInformationByHandleEx, GetFinalPathNameByHandleW, - GetVolumeInformationByHandleW, SetFileInformationByHandle, VOLUME_NAME_DOS, + FILE_ACCESS_RIGHTS, FILE_ATTRIBUTE_TAG_INFO, FILE_FLAGS_AND_ATTRIBUTES, FILE_ID_BOTH_DIR_INFO, FILE_ID_INFO, + FILE_INFO_BY_HANDLE_CLASS, FILE_NAME_NORMALIZED, FILE_SHARE_MODE, FILE_STANDARD_INFO, FileAttributeTagInfo, + FileIdBothDirectoryInfo, FileIdBothDirectoryRestartInfo, FileIdInfo, FileStandardInfo, FlushFileBuffers, + GETFINALPATHNAMEBYHANDLE_FLAGS, GetFileInformationByHandleEx, GetFinalPathNameByHandleW, + GetVolumeInformationByHandleW, VOLUME_NAME_DOS, }; use windows::Win32::System::IO::IO_STATUS_BLOCK; use windows::Win32::System::SystemServices::FILE_PERSISTENT_ACLS; @@ -130,9 +133,19 @@ fn information(handle: BorrowedHandle<'_>, class: FILE_INFO_BY_HANDL Ok(info) } -/// Returns the file attributes, such as `FILE_ATTRIBUTE_DIRECTORY` and `FILE_ATTRIBUTE_REPARSE_POINT`. -pub(super) fn attributes(handle: BorrowedHandle<'_>) -> io::Result { - Ok(information::(handle, FileBasicInfo)?.FileAttributes) +/// Returns the file attributes, such as `FILE_ATTRIBUTE_DIRECTORY` and `FILE_ATTRIBUTE_REPARSE_POINT`, and the reparse +/// tag, which is meaningful only with `FILE_ATTRIBUTE_REPARSE_POINT`. +pub(super) fn attributes_and_tag(handle: BorrowedHandle<'_>) -> io::Result<(u32, u32)> { + let info = information::(handle, FileAttributeTagInfo)?; + Ok((info.FileAttributes, info.ReparseTag)) +} + +/// Reports whether a reparse tag makes its file or directory stand for another name, as symbolic links, junctions and +/// volume mount points do, rather than only change how its content is stored, as cloud placeholders and deduplicated +/// files do. +pub(super) fn is_name_surrogate(tag: u32) -> bool { + // IsReparseTagNameSurrogate: the name surrogate bit of a reparse tag. + tag & 0x2000_0000 != 0 } pub(super) fn link_count(handle: BorrowedHandle<'_>) -> io::Result { @@ -191,6 +204,8 @@ pub(super) fn flush(handle: BorrowedHandle<'_>) -> io::Result<()> { /// Renames the file or directory `handle` refers to, to the entry `name` of the directory `root`. /// +/// With POSIX semantics, a replaced destination that another process holds open, sharing deletion, doesn't make the +/// rename fail. /// The handle needs `DELETE` access. pub(super) fn rename( handle: BorrowedHandle<'_>, @@ -198,16 +213,46 @@ pub(super) fn rename( name: &str, replace: bool, ) -> io::Result<()> { + let mut flags = FILE_RENAME_POSIX_SEMANTICS; + if replace { + flags |= FILE_RENAME_REPLACE_IF_EXISTS; + } + let mut status = set_rename(handle, root, name, flags, FileRenameInformationEx)?; + if needs_legacy_class(status) { + // The legacy class reads ReplaceIfExists, a BOOLEAN, from the first byte of the same field. + status = set_rename(handle, root, name, u32::from(replace), FileRenameInformation)?; + } + if status.0 < 0 { + return Err(status_error(status)); + } + Ok(()) +} + +/// Reports whether a failure of an extended information class means the file system knows only the legacy class, as +/// before Windows 10 1709 or on FAT. +fn needs_legacy_class(status: NTSTATUS) -> bool { + [ + STATUS_INVALID_PARAMETER, + STATUS_NOT_SUPPORTED, + STATUS_INVALID_INFO_CLASS, + ] + .contains(&status) +} + +fn set_rename( + handle: BorrowedHandle<'_>, + root: BorrowedHandle<'_>, + name: &str, + flags: u32, + class: FILE_INFORMATION_CLASS, +) -> io::Result { + let invalid_name = || io::Error::from(io::ErrorKind::InvalidFilename); let wide: Vec = name.encode_utf16().collect(); - let name_bytes = wide - .len() - .checked_mul(size_of::()) - .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidFilename))?; + let name_bytes = wide.len().checked_mul(size_of::()).ok_or_else(invalid_name)?; let name_offset = offset_of!(FILE_RENAME_INFORMATION, FileName); let size = size_of::() .checked_add(name_bytes) - .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidFilename))?; - let invalid_name = || io::Error::from(io::ErrorKind::InvalidFilename); + .ok_or_else(invalid_name)?; // A u64 buffer is aligned for FILE_RENAME_INFORMATION, whose largest field is a handle. let mut buffer = vec![0u64; size.div_ceil(size_of::())]; { @@ -217,8 +262,7 @@ pub(super) fn rename( std::slice::from_raw_parts_mut(buffer.as_mut_ptr().cast::(), buffer.len() * size_of::()) }; let mut put = |offset: usize, value: &[u8]| bytes[offset..][..value.len()].copy_from_slice(value); - // ReplaceIfExists is a BOOLEAN at the start of the union. - put(offset_of!(FILE_RENAME_INFORMATION, Anonymous), &[u8::from(replace)]); + put(offset_of!(FILE_RENAME_INFORMATION, Anonymous), &flags.to_le_bytes()); put( offset_of!(FILE_RENAME_INFORMATION, RootDirectory), &raw(root).0.addr().to_ne_bytes(), @@ -231,43 +275,64 @@ pub(super) fn rename( put(name_offset + index * size_of::(), &unit.to_ne_bytes()); } } - let mut status_block = IO_STATUS_BLOCK::default(); + let size = u32::try_from(size).map_err(|_| invalid_name())?; // NtSetInformationFile takes the name relative to RootDirectory, which SetFileInformationByHandle refuses. - // SAFETY: The handle is open, the rename structure, with its root handle and name, stays live for the call, and the - // status block is writable. - let status = unsafe { - NtSetInformationFile( - raw(handle), - &raw mut status_block, - buffer.as_ptr().cast(), - u32::try_from(size).map_err(|_| invalid_name())?, - FileRenameInformation, + // SAFETY: The rename structure, with its root handle and name, stays live for the call and is `size` bytes long. + Ok(unsafe { set_information(handle, buffer.as_ptr().cast(), size, class) }) +} + +/// Deletes the file `handle` refers to. +/// +/// With POSIX semantics its name goes at once, even while other handles to it stay open, so the name can be created +/// again; with the legacy semantics of older systems and FAT, it goes once every handle to it is closed. +/// The handle needs `DELETE` access. +pub(super) fn delete(handle: BorrowedHandle<'_>) -> io::Result<()> { + let flags = FILE_DISPOSITION_DELETE.0 | FILE_DISPOSITION_POSIX_SEMANTICS.0; + // SAFETY: The flags, a FILE_DISPOSITION_INFORMATION_EX, stay live for the call. + let mut status = unsafe { + set_information( + handle, + (&raw const flags).cast(), + 4, /* Flags */ + FileDispositionInformationEx, ) }; + if needs_legacy_class(status) { + let delete_file = 1u8; + // SAFETY: The BOOLEAN DeleteFile, a FILE_DISPOSITION_INFORMATION, stays live for the call. + status = unsafe { + set_information( + handle, + (&raw const delete_file).cast(), + 1, /* DeleteFile */ + FileDispositionInformation, + ) + }; + } if status.0 < 0 { return Err(status_error(status)); } Ok(()) } -/// Marks the file `handle` refers to for deletion once every handle to it is closed. +/// # Safety /// -/// The handle needs `DELETE` access. -pub(super) fn delete_on_close(handle: BorrowedHandle<'_>) -> io::Result<()> { - let info = FILE_DISPOSITION_INFO { DeleteFile: true }; - // SAFETY: The handle is open and the disposition structure stays live for the call. - unsafe { - SetFileInformationByHandle( - raw(handle), - FileDispositionInfo, - (&raw const info).cast(), - u32::try_from(size_of::()).expect("FILE_DISPOSITION_INFO is one byte"), - ) - } - .map_err(win32_error) +/// `information` points to `length` bytes, live for the call, holding the structure `class` takes. +unsafe fn set_information( + handle: BorrowedHandle<'_>, + information: *const core::ffi::c_void, + length: u32, + class: FILE_INFORMATION_CLASS, +) -> NTSTATUS { + let mut status_block = IO_STATUS_BLOCK::default(); + // SAFETY: The handle is open, the status block is writable, and per the function's contract the information is + // valid for the call. + unsafe { NtSetInformationFile(raw(handle), &raw mut status_block, information, length, class) } } /// Lists the entries of the directory `handle` refers to, with their file attributes, without `.` and `..`. +/// +/// The listing moves the handle's enumeration cursor, so callers pass a handle that nothing else lists through. pub(super) fn list(handle: BorrowedHandle<'_>) -> io::Result> { // A u64 buffer is aligned for FILE_ID_BOTH_DIR_INFO, whose largest fields are 64-bit. let mut buffer = vec![0u64; 8 * 1024]; diff --git a/crates/private-fs/src/windows/security.rs b/crates/private-fs/src/windows/security.rs index baa2fdb41b..94e3e4eb30 100644 --- a/crates/private-fs/src/windows/security.rs +++ b/crates/private-fs/src/windows/security.rs @@ -484,19 +484,29 @@ unsafe fn read_acl(acl: *mut ACL) -> io::Result> { ); let (mask, sid) = if has_sid { // These four types share the layout of ACCESS_ALLOWED_ACE: the header, the mask, then the SID. - if usize::from(header.AceSize) < offset_of!(ACCESS_ALLOWED_ACE, SidStart) + 8 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "access control entry too short", - )); + let too_short = || io::Error::new(io::ErrorKind::InvalidData, "access control entry too short"); + let sid_offset = offset_of!(ACCESS_ALLOWED_ACE, SidStart); + let ace_size = usize::from(header.AceSize); + if ace_size < sid_offset + 1 /* Revision */ + 1 + /* SubAuthorityCount */ + { + return Err(too_short()); + } + let count_pointer = pointer.cast::().wrapping_add(sid_offset + 1 /* Revision */); + // SAFETY: The entry is at least as long as the byte read, per its size checked above. + let sub_authority_count = usize::from(unsafe { count_pointer.read() }); + let sid_size = 1 /* Revision */ + 1 /* SubAuthorityCount */ + 6 /* IdentifierAuthority */ + + 4 /* SubAuthority */ * sub_authority_count; + if ace_size < sid_offset + sid_size { + return Err(too_short()); } let mask_pointer = pointer.cast::().wrapping_add(offset_of!(ACCESS_ALLOWED_ACE, Mask)); - // SAFETY: The entry is at least as long as the fields read, per its size checked above. + // SAFETY: The entry is at least as long as the fields read, per its sizes checked above. let mask = unsafe { mask_pointer.cast::().read_unaligned() }; let sid_start = pointer .cast::() .wrapping_add(offset_of!(ACCESS_ALLOWED_ACE, SidStart)); - // SAFETY: The entry holds a SID starting at SidStart, which from_psid validates before copying. + // SAFETY: The entry holds a whole SID starting at SidStart, per the size checked above. let sid = unsafe { Sid::from_psid(Security::PSID(sid_start.cast())) }.map_err(io::Error::other)?; (mask, Some(sid)) } else { @@ -524,9 +534,11 @@ pub(super) struct Descriptor { impl Descriptor { pub(super) fn new(owner: &Sid, entries: &[Entry]) -> io::Result { let too_large = || io::Error::new(io::ErrorKind::InvalidInput, "the access control list is too large"); - // A SID is 8 bytes of header and 4 bytes per subauthority; an entry is its header and mask, then its SID. + // An entry is its header and mask, then its SID. let size = entries.iter().try_fold(size_of::(), |size, (sid, _, _)| { - size.checked_add(offset_of!(ACCESS_ALLOWED_ACE, SidStart) + 8 + 4 * sid.sub_authorities().len()) + let sid_size = 1 /* Revision */ + 1 /* SubAuthorityCount */ + 6 /* IdentifierAuthority */ + + 4 /* SubAuthority */ * sid.sub_authorities().len(); + size.checked_add(offset_of!(ACCESS_ALLOWED_ACE, SidStart) + sid_size) }); // The ACL size is a 16-bit field. let size = size.and_then(|size| u16::try_from(size).ok()).ok_or_else(too_large)?; @@ -755,9 +767,10 @@ mod tests { assert_eq!(refusal(checked), "Access"); } - // oracle: intent crates/private-fs/INTENT.md §mechanisms + // oracle: reference crates/private-fs/src/rule.rs Rule #[test] fn a_check_refuses_a_deny_entry() { + // A rule names only accounts with access, so its DACL is allow entries only, and a deny entry is not one. // A check that skipped entries it can't compare would accept any other entry type. let security = security("O:{user}D:P(A;;FA;;;SY)(A;;;;;OW)(D;;FA;;;BU)"); let checked = policy(&system_rule(), Kind::File).check(&security, Utf8Path::new("item"), &no_member); @@ -782,7 +795,7 @@ mod tests { // oracle: intent crates/private-fs/INTENT.md §vocabulary #[test] - fn an_exact_check_refuses_owner_rights_wider_than_the_owner_s_access() { + fn a_check_refuses_owner_rights_wider_than_the_owner_s_access() { // The rule gives the owner no access, so OWNER RIGHTS must grant nothing; a narrower check also refuses more. let security = security("O:{user}D:P(A;;FA;;;SY)(A;;FR;;;OW)"); let checked = policy(&system_rule(), Kind::File).check(&security, Utf8Path::new("item"), &no_member); @@ -846,6 +859,46 @@ mod tests { assert_eq!(refusal(checked), "accepted"); } + // oracle: spec MS-DTYP §2.4.4.6 ACCESS_ALLOWED_CALLBACK_ACE + #[test] + fn an_ancestor_callback_allow_entry_counts_as_an_allow_entry() { + // A callback entry grants its access whenever its condition holds, which the check can't evaluate. + let security = security("O:SYD:P(A;;FA;;;SY)(XA;;FA;;;BU;(Member_of {SID(BA)}))"); + let checked = + policy(&system_rule(), Kind::Directory).check_ancestor(&security, Utf8Path::new("item"), false, &no_member); + assert_eq!(refusal(checked), "Replaceable"); + } + + // oracle: intent crates/private-fs/INTENT.md §paths + #[test] + fn an_ancestor_entry_of_an_unknown_type_is_unreadable() { + // An object entry, which file systems don't use, has a layout the check doesn't read. + let security = security("O:SYD:P(A;;FA;;;SY)(OA;;FA;bf967aba-0de6-11d0-a285-00aa003049e2;;BU)"); + let checked = + policy(&system_rule(), Kind::Directory).check_ancestor(&security, Utf8Path::new("item"), false, &no_member); + assert!(refusal(checked).starts_with("Unreadable")); + } + + // oracle: spec MS-DTYP §2.4.2.4 Well-Known SID Structures + #[test] + fn an_ancestor_owner_rights_entry_is_the_allowed_owner_s() { + // OWNER RIGHTS stands for the owner, which the check has already allowed. + let security = security("O:SYD:P(A;;FA;;;SY)(A;;FA;;;OW)"); + let checked = + policy(&system_rule(), Kind::Directory).check_ancestor(&security, Utf8Path::new("item"), false, &no_member); + assert_eq!(refusal(checked), "accepted"); + } + + // oracle: spec Microsoft Learn "AppContainer isolation" + #[test] + fn an_ancestor_entry_for_all_application_packages_is_ignored() { + // ALL APPLICATION PACKAGES, S-1-15-2-1, a package SID. + let security = security("O:SYD:P(A;;FA;;;SY)(A;;FA;;;AC)"); + let checked = + policy(&system_rule(), Kind::Directory).check_ancestor(&security, Utf8Path::new("item"), false, &no_member); + assert_eq!(refusal(checked), "accepted"); + } + // oracle: intent crates/private-fs/INTENT.md §paths #[test] fn an_ancestor_with_a_null_dacl_is_replaceable() { diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index 6323d536e4..2054f6fb5a 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -6,6 +6,7 @@ use std::io::{Read as _, Write as _}; use std::os::windows::fs::OpenOptionsExt as _; use std::os::windows::io::AsRawHandle as _; +use std::process::{Command, Stdio}; use anyhow::Context as _; use camino::{Utf8Path, Utf8PathBuf}; @@ -13,13 +14,16 @@ use private_fs::{ Access, Account, BackupPrivilege, Dir, EntryKind, Error, ErrorKind, Existing, Fallback, FileOptions, Refusal, Replace, Rule, Share, }; +use rstest::rstest; use win_api_wrappers::identity::sid::Sid; use win_api_wrappers::process::Process; -use win_api_wrappers::raw::Win32::Foundation::HANDLE; -use win_api_wrappers::raw::Win32::Security::Authorization::{GRANT_ACCESS, SE_FILE_OBJECT}; +use win_api_wrappers::raw::Win32::Foundation::{HANDLE, HLOCAL, LocalFree}; +use win_api_wrappers::raw::Win32::Security::Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, GRANT_ACCESS, SDDL_REVISION_1, SE_FILE_OBJECT, +}; use win_api_wrappers::raw::Win32::Security::{ - self, ACE_FLAGS, SecurityImpersonation, TOKEN_ADJUST_PRIVILEGES, TOKEN_DUPLICATE, TOKEN_IMPERSONATE, TOKEN_QUERY, - TokenImpersonation, + self, ACE_FLAGS, DACL_SECURITY_INFORMATION, SecurityImpersonation, TOKEN_ADJUST_PRIVILEGES, TOKEN_DUPLICATE, + TOKEN_IMPERSONATE, TOKEN_QUERY, TokenImpersonation, }; use win_api_wrappers::raw::Win32::Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT}; use win_api_wrappers::security::acl::{ @@ -29,6 +33,7 @@ use win_api_wrappers::security::descriptor::SecurityDescriptor; use win_api_wrappers::security::privilege::SE_RESTORE_NAME; use win_api_wrappers::str::U16CString; use win_api_wrappers::token::Token; +use windows::core::PWSTR; /// Full access: `STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE | 0x1FF`, the value of `FILE_ALL_ACCESS`. const FULL: u32 = 0x001F_01FF; @@ -106,16 +111,24 @@ fn user_rule() -> Rule { /// An allow entry: its SID, access mask and ACE flags. type AllowEntry = (Sid, u32, u32); -/// Reads the owner and the DACL entries of `path`, sorted, failing unless the DACL is protected and allow-only. -fn security(path: &Utf8Path) -> anyhow::Result<(Sid, Vec)> { - // The backup privilege, where the process token holds it, reads a DACL that gives the test's own token no access. - let _backup = BackupPrivilege::enable().ok(); - let file = std::fs::OpenOptions::new() +/// Opens `path` to read its owner and DACL, without following a reparse point. +fn open_for_security(path: &Utf8Path) -> anyhow::Result { + std::fs::OpenOptions::new() .access_mode(0x0002_0000) // READ_CONTROL .share_mode(0x7) .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) .open(path) - .with_context(|| format!("open {path} to read its security"))?; + .with_context(|| format!("open {path} to read its security")) +} + +/// Reads the owner and the DACL entries of `path`, sorted, failing unless the DACL is protected and allow-only. +fn security(path: &Utf8Path) -> anyhow::Result<(Sid, Vec)> { + security_of(&open_for_security(path)?) +} + +/// Reads the owner and the DACL entries of an open file or directory, sorted, failing unless the DACL is protected and +/// allow-only. +fn security_of(file: &std::fs::File) -> anyhow::Result<(Sid, Vec)> { let descriptor = SecurityDescriptor::of_handle(HANDLE(file.as_raw_handle()), SE_FILE_OBJECT)?; let owner = descriptor.owner()?.context("no owner")?; let mut entries: Vec<_> = descriptor @@ -127,6 +140,38 @@ fn security(path: &Utf8Path) -> anyhow::Result<(Sid, Vec)> { Ok((owner, entries)) } +/// Returns the whole DACL of `path` in SDDL, protected or not, inherited entries included. +fn dacl_sddl(path: &Utf8Path) -> anyhow::Result { + let file = open_for_security(path)?; + let descriptor = SecurityDescriptor::of_handle(HANDLE(file.as_raw_handle()), SE_FILE_OBJECT)?; + let mut sddl = PWSTR::null(); + // SAFETY: The descriptor stays live through the call and the output is writable. + unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor.as_raw(), + SDDL_REVISION_1, + DACL_SECURITY_INFORMATION, + &mut sddl, + None, + ) + }?; + // SAFETY: The conversion returned a NUL-terminated string. + let text = unsafe { sddl.to_string() }; + // SAFETY: The conversion allocated the string with LocalAlloc, and nothing uses it any more. + unsafe { LocalFree(Some(HLOCAL(sddl.0.cast()))) }; + Ok(text?) +} + +/// Makes `link` a directory junction to `target`, which, unlike a symbolic link, needs no privilege. +fn junction(target: &Utf8Path, link: &Utf8Path) -> anyhow::Result<()> { + let status = Command::new("cmd") + .args(["/C", "mklink", "/J", link.as_str(), target.as_str()]) + .stdout(Stdio::null()) + .status()?; + anyhow::ensure!(status.success(), "mklink /J exited with {status}"); + Ok(()) +} + fn sorted(mut entries: Vec) -> Vec { entries.sort(); entries @@ -226,24 +271,21 @@ fn create_base_gives_each_new_directory_exactly_its_rule() -> anyhow::Result<()> fn a_created_file_takes_its_owner_s_implicit_access_away() -> anyhow::Result<()> { // Catches a file whose owner keeps the READ_CONTROL and WRITE_DAC that Windows gives an owner without an OWNER // RIGHTS entry, through which the owner can grant itself anything. - if let Err(error) = BackupPrivilege::enable() { - println!("SKIP: reading a DACL that grants the test nothing needs the backup privilege: {error}"); - return Ok(()); - } let test = TestDir::new(); let base = Dir::create_base(test.path(), &user_rule())?; let rule = Rule::new() .owner(Account::CurrentUser) .grant(Account::System, Access::Full); - drop(base.create_file("file", &rule, FileOptions::new())?); + // The creating handle has READ_CONTROL whatever the DACL grants. + let file = base.create_file("file", &rule, FileOptions::new())?; assert_eq!( - security(&test.path().join("file"))?, + security_of(&file)?, (user(), sorted(vec![(system(), FULL, 0), (owner_rights(), 0, 0)])) ); Ok(()) } -// oracle: spec Microsoft Learn "Privilege Constants" SE_BACKUP_NAME +// oracle: spec Microsoft Learn "Privilege Constants" §SE_BACKUP_NAME #[test] fn the_backup_privilege_opens_a_file_the_token_cannot() -> anyhow::Result<()> { // Catches a backup privilege that is not enabled on the thread, or opens that don't ask for backup intent. @@ -379,14 +421,10 @@ fn a_repair_restores_the_rule_without_changing_what_lies_below() -> anyhow::Resu let base = Dir::create_base(test.path(), &user_rule())?; let child = base.create_dir("child", &user_rule(), Existing::Check)?; drop(child.create_file("file", &user_rule(), FileOptions::new())?); - let file = test.path().join("child").join("file"); - set_dacl( - &file, - &[(user(), FULL, 0), (users(), READ, 0)], - InheritableAclKind::Protected, - )?; - let file_before = security(&file)?; drop(child); + let file = test.path().join("child").join("file"); + // The file takes inherited entries from the directory, which a propagating repair would rewrite. + set_dacl(&file, &[(user(), FULL, 0)], InheritableAclKind::Inherit)?; set_dacl( &test.path().join("child"), &[ @@ -395,13 +433,15 @@ fn a_repair_restores_the_rule_without_changing_what_lies_below() -> anyhow::Resu ], InheritableAclKind::Protected, )?; + let file_before = dacl_sddl(&file)?; + assert!(file_before.contains("(A;ID;FA;;;BU)"), "{file_before}"); let rule = user_rule().exact(); drop(base.create_dir("child", &rule, Existing::Repair)?); assert_eq!( security(&test.path().join("child"))?, (user(), vec![(user(), FULL, INHERITED_BY_CHILDREN)]) ); - assert_eq!(security(&file)?, file_before); + assert_eq!(dacl_sddl(&file)?, file_before); Ok(()) } @@ -448,15 +488,12 @@ fn administrators_as_an_owner_allow_a_member_of_the_group() -> anyhow::Result<() // oracle: intent crates/private-fs/INTENT.md §paths #[test] -fn open_dir_refuses_a_symbolic_link() -> anyhow::Result<()> { +fn open_dir_refuses_a_junction() -> anyhow::Result<()> { // Catches an open that follows a link out of the checked directory. let test = TestDir::new(); let base = Dir::create_base(test.path(), &user_rule())?; drop(base.create_dir("target", &user_rule(), Existing::Check)?); - if let Err(error) = std::os::windows::fs::symlink_dir(test.path().join("target"), test.path().join("link")) { - println!("SKIP: cannot create a symbolic link: {error}"); - return Ok(()); - } + junction(&test.path().join("target"), &test.path().join("link"))?; let error = refusal(base.open_dir("link", &user_rule(), Share::Shared)); assert!( is_refused(&error, |refusal| matches!(refusal, Refusal::Link)), @@ -471,10 +508,7 @@ fn create_base_refuses_a_link_as_the_first_existing_directory() -> anyhow::Resul // Catches a base directory resolved through a link that someone else can point elsewhere. let test = TestDir::new(); Dir::create_base(&test.path().join("target"), &user_rule())?; - if let Err(error) = std::os::windows::fs::symlink_dir(test.path().join("target"), test.path().join("link")) { - println!("SKIP: cannot create a symbolic link: {error}"); - return Ok(()); - } + junction(&test.path().join("target"), &test.path().join("link"))?; let error = refusal(Dir::create_base(&test.path().join("link").join("new"), &user_rule())); assert!( is_refused(&error, |refusal| matches!(refusal, Refusal::Link)), @@ -614,13 +648,12 @@ fn entries_lists_each_entry_with_its_kind() -> anyhow::Result<()> { let base = Dir::create_base(test.path(), &user_rule())?; drop(base.create_file("file", &user_rule(), FileOptions::new())?); drop(base.create_dir("directory", &user_rule(), Existing::Check)?); - let mut expected = vec![ + junction(&test.path().join("directory"), &test.path().join("link"))?; + let expected = vec![ ("directory".to_owned(), EntryKind::Directory), ("file".to_owned(), EntryKind::File), + ("link".to_owned(), EntryKind::Link), ]; - if std::os::windows::fs::symlink_dir(test.path().join("directory"), test.path().join("link")).is_ok() { - expected.push(("link".to_owned(), EntryKind::Link)); - } // The second listing checks that each listing starts from the first entry. for _ in 0..2 { let mut entries: Vec<_> = base @@ -662,17 +695,75 @@ fn an_exclusive_open_makes_another_open_fail_with_a_sharing_violation() -> anyho Ok(()) } +#[rstest] // oracle: spec MS-FSCC §2.1.5 Pathname -#[test] -fn a_name_with_a_stream_separator_is_refused() -> anyhow::Result<()> { - // Catches a name that opens an alternate data stream of another entry, which has that entry's access. +#[case::stream("file:stream")] +// oracle: spec Microsoft Learn "Naming Files, Paths, and Namespaces" §Naming Conventions +#[case::trailing_dot("file.")] +// oracle: spec Microsoft Learn "Naming Files, Paths, and Namespaces" §Naming Conventions +#[case::trailing_space("file ")] +// oracle: spec Microsoft Learn "Naming Files, Paths, and Namespaces" §Naming Conventions +#[case::device("nul")] +// oracle: spec Microsoft Learn "Naming Files, Paths, and Namespaces" §Naming Conventions +#[case::device_with_extension("COM1.txt")] +fn a_name_win32_paths_reach_differently_is_refused(#[case] name: &str) -> anyhow::Result<()> { + // Catches an entry that Win32 path functions reach as another entry, a stream of one, or a device. let test = TestDir::new(); let base = Dir::create_base(test.path(), &user_rule())?; - let error = refusal(base.create_file("file:stream", &user_rule(), FileOptions::new())); + let error = refusal(base.create_file(name, &user_rule(), FileOptions::new())); assert!( matches!(error.kind(), ErrorKind::Io { source, .. } if source.kind() == std::io::ErrorKind::InvalidInput), "{error:?}" ); - assert!(!test.path().join("file").exists()); + assert!(base.entries()?.is_empty()); + Ok(()) +} + +// oracle: reference crates/private-fs/src/unix.rs rename +#[test] +fn persist_replaces_a_destination_another_process_holds_open() -> anyhow::Result<()> { + // Catches a replacement that fails while a reader, sharing deletion as std does, holds the destination open. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + base.create_file("old", &user_rule(), FileOptions::new())? + .write_all(b"old")?; + let reader = std::fs::File::open(test.path().join("old"))?; + let mut new = base.create_file("new", &user_rule(), FileOptions::new())?; + new.write_all(b"new")?; + base.persist(&new, "new", "old", Replace::Allow)?; + drop((new, reader)); + assert_eq!(std::fs::read(test.path().join("old"))?, b"new"); + Ok(()) +} + +// oracle: reference crates/private-fs/src/unix.rs remove_file +#[test] +fn removing_a_file_another_process_holds_open_frees_its_name() -> anyhow::Result<()> { + // Catches a removal that leaves the name pending deletion until the reader closes it, so that creating it again + // fails. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + drop(base.create_file("file", &user_rule(), FileOptions::new())?); + let reader = std::fs::File::open(test.path().join("file"))?; + assert!(base.remove_file("file")?); + drop(base.create_file("file", &user_rule(), FileOptions::new())?); + drop(reader); + Ok(()) +} + +// oracle: reference crates/private-fs/src/windows/mod.rs BackupPrivilege +#[test] +fn a_thread_holds_one_backup_privilege_at_a_time() -> anyhow::Result<()> { + // Catches nested guards, whose drops out of order would leave the thread with the privilege. + let first = match BackupPrivilege::enable() { + Ok(first) => first, + Err(error) => { + println!("SKIP: the process token doesn't hold the backup privilege: {error}"); + return Ok(()); + } + }; + assert!(BackupPrivilege::enable().is_err()); + drop(first); + drop(BackupPrivilege::enable()?); Ok(()) } From 1234c960f10a67d735615379545db0a9d066ac5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 17:05:52 +0900 Subject: [PATCH 09/60] fix(private-fs): keep Windows directory handles shareable and refuse every reparse point A new directory's handle no longer holds DELETE, which made every open of the directory that doesn't share deletion fail while it lived; a directory that fails its rule is removed through a separate handle. Listings and flushes go through the directory's own handle, so they work on a directory opened exclusively; listings take turns on its one enumeration cursor. An item that is any reparse point is refused: one that stands for another name is a link, and any other is not a file private-fs can vouch for, since its content is not what is opened. Listings report the latter as other entries. A backup privilege guard restores the earlier thread token only over its own, and the legacy information classes are also used where the extended ones report an invalid device request or no implementation. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/windows/mod.rs | 189 +++++++++++++++++----- crates/private-fs/src/windows/nt.rs | 37 +++-- crates/private-fs/src/windows/security.rs | 28 ++-- testsuite/tests/private_fs/windows.rs | 15 +- 4 files changed, 198 insertions(+), 71 deletions(-) diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index 4e7c983c12..0e1615a167 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -13,6 +13,7 @@ use std::io; use std::marker::PhantomData; use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; use std::os::windows::io::{AsHandle as _, BorrowedHandle, FromRawHandle as _, OwnedHandle}; +use std::sync::{Mutex, PoisonError}; use camino::{Utf8Path, Utf8PathBuf}; use windows::Wdk::Storage::FileSystem::{ @@ -60,9 +61,21 @@ const SHARE_ALL: FILE_SHARE_MODE = FILE_SHARE_MODE(FILE_SHARE_READ.0 | FILE_SHAR #[derive(Debug)] pub(crate) struct Dir { handle: OwnedHandle, + /// Whether the handle has FILE_ADD_FILE, the write access a flush of a directory needs. + flushable: bool, + /// Serializes listings, which move the handle's one enumeration cursor. + listing: Mutex<()>, } impl Dir { + fn new(handle: OwnedHandle, flushable: bool) -> Self { + Self { + handle, + flushable, + listing: Mutex::new(()), + } + } + fn handle(&self) -> BorrowedHandle<'_> { self.handle.as_handle() } @@ -104,7 +117,7 @@ pub(crate) fn open_base(path: &Utf8Path, rule: &Rule) -> Result> { let security = OwnerAndDacl::of_handle(handle.as_handle()) .map_err(|error| Error::refused(¤t, Refusal::Unreadable(error)))?; policy.check_ancestor(&security, ¤t, false, &administrators_member)?; - dir = Dir { handle }; + dir = Dir::new(handle, false); } Ok(Some(dir)) } @@ -115,9 +128,9 @@ pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, let descriptor = Descriptor::new(&policy.accounts.user, &policy.dacl(&policy.accounts.user)) .map_err(|error| Error::io(path, "build security descriptor", error))?; let request = Request { - // FILE_ADD_FILE lets the new directory's handle be flushed, and DELETE lets a directory that fails its rule be - // removed. - access: FILE_ACCESS_RIGHTS(DIRECTORY_ACCESS.0 | FILE_ADD_FILE.0 | DELETE.0), + // FILE_ADD_FILE lets the new directory's handle be flushed. + // Not DELETE, which would make every open of the directory that doesn't share deletion fail while it lives. + access: FILE_ACCESS_RIGHTS(DIRECTORY_ACCESS.0 | FILE_ADD_FILE.0), share: SHARE_ALL, disposition: FILE_CREATE, options: NTCREATEFILE_CREATE_OPTIONS(OPEN_OPTIONS.0 | FILE_DIRECTORY_FILE.0), @@ -127,8 +140,15 @@ pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, match nt::create(parent.handle(), name, &request) { Ok(handle) => { if let Err(error) = check_item(handle.as_handle(), path, Kind::Directory, &policy) { - // A directory that fails its rule, as on a volume that drops DACLs, is not left behind. - let _ = nt::delete(handle.as_handle()); + // A directory that fails its rule, as on a volume that drops DACLs, is removed where the rule it got + // lets the current user delete it; otherwise it is left behind, empty, and the error still reported. + // UNTESTED: a new directory that fails its rule is removed. + // Tests run on NTFS, where a new directory always has its rule; a FAT volume, which drops DACLs, would + // show it. + // When changing this, keep the removal best effort, so that the check's error is the one reported. + if let Ok(deleting) = reopen(handle.as_handle(), FILE_ACCESS_RIGHTS(DELETE.0 | SYNCHRONIZE.0)) { + let _ = nt::delete(deleting.as_handle()); + } return Err(error); } // UNTESTED: once this returns, a power cut leaves the new directory's entry in its parent. @@ -136,7 +156,7 @@ pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, // log up to it. // When changing this, keep a flush of a handle on the new directory after its creation. nt::flush(handle.as_handle()).map_err(|error| Error::io(path, "flush directory", error))?; - Ok(Dir { handle }) + Ok(Dir::new(handle, true)) } Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { let handle = open_entry( @@ -148,7 +168,7 @@ pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, "open directory", )? .ok_or_else(|| Error::io(path, "open directory", io::Error::from(io::ErrorKind::NotFound)))?; - let dir = Dir { handle }; + let dir = Dir::new(handle, false); match existing { Existing::Check => check_dir(&dir, path, rule)?, Existing::Repair => repair_dir(&dir, path, rule)?, @@ -174,7 +194,7 @@ pub(crate) fn open_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, s return Ok(None); }; check_item(handle.as_handle(), path, Kind::Directory, &policy)?; - Ok(Some(Dir { handle })) + Ok(Some(Dir::new(handle, false))) } pub(crate) fn open_file( @@ -295,36 +315,55 @@ pub(crate) fn discard(_parent: &Dir, path: &Utf8Path, file: &File, _name: &str) } pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { - // A handle of its own, so that listings on the same directory at once don't share one enumeration cursor. - let listing = reopen(dir.handle(), FILE_ACCESS_RIGHTS(FILE_LIST_DIRECTORY.0 | SYNCHRONIZE.0)) - .map_err(|error| Error::io(path, "open directory", error))?; - let listed = nt::list(listing.as_handle()).map_err(|error| Error::io(path, "list directory", error))?; + let listed = { + // A listing restarts and moves the handle's one enumeration cursor, so listings take turns. + let _listing = dir.listing.lock().unwrap_or_else(PoisonError::into_inner); + nt::list(dir.handle()).map_err(|error| Error::io(path, "list directory", error))? + }; Ok(listed .into_iter() - .map(|(name, attributes)| Entry { + .map(|(name, attributes, tag)| Entry { name, - kind: if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { - EntryKind::Link - } else if attributes & FILE_ATTRIBUTE_DIRECTORY.0 != 0 { - EntryKind::Directory - } else if attributes & FILE_ATTRIBUTE_DEVICE.0 != 0 { - EntryKind::Other - } else { - EntryKind::File - }, + kind: entry_kind(attributes, tag), }) .collect()) } +/// Returns the kind of a listed entry: a reparse point that stands for another name is a link, and any other reparse +/// point is neither a plain file nor a plain directory. +fn entry_kind(attributes: u32, tag: u32) -> EntryKind { + if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { + if nt::is_name_surrogate(tag) { + EntryKind::Link + } else { + EntryKind::Other + } + } else if attributes & FILE_ATTRIBUTE_DIRECTORY.0 != 0 { + EntryKind::Directory + } else if attributes & FILE_ATTRIBUTE_DEVICE.0 != 0 { + EntryKind::Other + } else { + EntryKind::File + } +} + pub(crate) fn sync(dir: &Dir, path: &Utf8Path) -> Result<()> { // FlushFileBuffers needs write access, which on a directory is FILE_ADD_FILE. - let flushing = reopen(dir.handle(), FILE_ACCESS_RIGHTS(FILE_ADD_FILE.0 | SYNCHRONIZE.0)) - .map_err(|error| Error::io(path, "open directory", error))?; + // A directory this handle didn't create is opened again with it, which fails when the directory is open + // exclusively or the rule doesn't give the current user that access. + let reopened; + let flushing = if dir.flushable { + dir.handle() + } else { + reopened = reopen(dir.handle(), FILE_ACCESS_RIGHTS(FILE_ADD_FILE.0 | SYNCHRONIZE.0)) + .map_err(|error| Error::io(path, "open directory", error))?; + reopened.as_handle() + }; // UNTESTED: once this returns, a power cut leaves the entries created, renamed and removed in the directory. // No test can cut the power; Windows has no directory fsync, but NTFS logs every change of an entry, and flushing a // handle on the directory commits the log up to the last change. // When changing this, keep a flush of a handle on the directory itself. - nt::flush(flushing.as_handle()).map_err(|error| Error::io(path, "flush directory", error)) + nt::flush(flushing).map_err(|error| Error::io(path, "flush directory", error)) } pub(crate) fn check_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { @@ -529,13 +568,18 @@ fn thread_token_id() -> io::Result> { impl Drop for BackupPrivilege { fn drop(&mut self) { BACKUP_PRIVILEGE_HELD.set(false); - // A thread that impersonates another token since is left with it: restoring the earlier token over it would - // take away an impersonation this guard didn't set. - let current = thread_token_id().ok().flatten(); - if current.is_none_or(|current| { - (current.LowPart, current.HighPart) != (self.token_id.LowPart, self.token_id.HighPart) - }) { - return; + // A thread that impersonates another token since, or none, is left as it is: restoring the earlier token over + // it would take away an impersonation this guard didn't set. + // When the thread token can't be read, the earlier token is restored, since leaving the privilege enabled is + // the worse failure. + // UNTESTED: a thread that impersonates another token since this guard was created keeps it. + // Tests can't nest another impersonation inside this guard without the backup privilege, which CI's elevated + // runs hold but these checks can't rely on; when changing this, keep comparing token IDs, not handles, since + // OpenThreadToken returns a new handle each time. + match thread_token_id() { + Ok(Some(current)) if current != self.token_id => return, + Ok(None) => return, + Ok(Some(_)) | Err(_) => {} } // A failure leaves the thread with the backup privilege, and there is no caller to report it to. match &self.previous { @@ -593,9 +637,8 @@ fn is_reserved_device_name(name: &str) -> bool { "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "\u{b9}", "\u{b2}", "\u{b3}", ]; let stem = name - .split('.') - .next() - .unwrap_or(name) + .split_once('.') + .map_or(name, |(stem, _)| stem) .trim_end_matches(' ') .to_ascii_uppercase(); DEVICES.contains(&stem.as_str()) @@ -662,15 +705,12 @@ fn open_entry( } } -/// Refuses an item that is a link, isn't of `kind`, or is on a volume that doesn't enforce ACLs. -/// -/// A reparse point that stands for another name, such as a symbolic link or a junction, is a link; one that only -/// changes how its content is stored, such as a cloud placeholder, is not. +/// Refuses an item that is a reparse point, isn't of `kind`, or is on a volume that doesn't enforce ACLs. fn classify(handle: BorrowedHandle<'_>, path: &Utf8Path, kind: Kind) -> Result<()> { let (attributes, tag) = nt::attributes_and_tag(handle).map_err(|error| Error::io(path, "read attributes", error))?; - if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 && nt::is_name_surrogate(tag) { - return Err(Error::refused(path, Refusal::Link)); + if let Some(refusal) = reparse_refusal(attributes, tag) { + return Err(Error::refused(path, refusal)); } let is_directory = attributes & FILE_ATTRIBUTE_DIRECTORY.0 != 0; match kind { @@ -692,6 +732,21 @@ fn classify(handle: BorrowedHandle<'_>, path: &Utf8Path, kind: Kind) -> Result<( Ok(()) } +/// Returns why an item that is a reparse point is refused. +/// +/// One that stands for another name, such as a symbolic link or a junction, is a link. +/// Any other is opened as the reparse point itself rather than its content, as a cloud placeholder's or a deduplicated +/// file's, or stands for a FIFO, socket or device, as the WSL tags do, so it is not a file private-fs can vouch for. +fn reparse_refusal(attributes: u32, tag: u32) -> Option { + if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 == 0 { + None + } else if nt::is_name_surrogate(tag) { + Some(Refusal::Link) + } else { + Some(Refusal::NotAFile) + } +} + /// Checks an item's type, link status, volume, owner and DACL against `policy`. fn check_item(handle: BorrowedHandle<'_>, path: &Utf8Path, kind: Kind, policy: &Policy) -> Result<()> { classify(handle, path, kind)?; @@ -708,7 +763,7 @@ fn first_existing(path: &Utf8Path) -> Result<(Utf8PathBuf, Vec)> { loop { match std::fs::symlink_metadata(&candidate) { Ok(metadata) => { - // A reparse point is opened without following it, and refused as a link once its tag is read. + // A reparse point is opened without following it, and refused once its tag is read. let reparse_point = metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0; if !reparse_point && !metadata.is_dir() { return Err(Error::refused(&candidate, Refusal::NotADirectory)); @@ -750,7 +805,7 @@ fn open_first_existing(path: &Utf8Path, policy: &Policy) -> Result { check_above(directory, policy)?; above = directory.parent(); } - Ok(Dir { handle }) + Ok(Dir::new(handle, false)) } /// Checks a directory above the first existing one, at its resolved path. @@ -779,3 +834,49 @@ fn open_path(path: &Utf8Path, access: FILE_ACCESS_RIGHTS) -> io::Result String { + format!("{:?}", reparse_refusal(REPARSE_POINT, tag)) + } + + // oracle: spec MS-FSCC §2.1.2.1 Reparse Tags + #[test] + fn a_name_surrogate_item_is_a_link() { + assert_eq!(refusal(SYMLINK), "Some(Link)"); + assert_eq!(refusal(MOUNT_POINT), "Some(Link)"); + } + + // oracle: spec MS-FSCC §2.1.2.1 Reparse Tags + #[test] + fn any_other_reparse_point_item_is_not_a_file() { + // Opened without following the reparse point, a stub's content is not the file's, and a FIFO is not a file. + assert_eq!(refusal(CLOUD), "Some(NotAFile)"); + assert_eq!(refusal(DEDUP), "Some(NotAFile)"); + assert_eq!(refusal(LX_FIFO), "Some(NotAFile)"); + } + + // oracle: spec MS-FSCC §2.1.2.1 Reparse Tags + #[test] + fn a_listed_reparse_point_is_a_link_only_when_it_stands_for_another_name() { + assert_eq!(entry_kind(REPARSE_POINT, SYMLINK), EntryKind::Link); + assert_eq!( + entry_kind(REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY.0, MOUNT_POINT), + EntryKind::Link + ); + assert_eq!(entry_kind(REPARSE_POINT, CLOUD), EntryKind::Other); + assert_eq!(entry_kind(REPARSE_POINT, LX_FIFO), EntryKind::Other); + } +} diff --git a/crates/private-fs/src/windows/nt.rs b/crates/private-fs/src/windows/nt.rs index 49b4e0a89b..0e7b832f81 100644 --- a/crates/private-fs/src/windows/nt.rs +++ b/crates/private-fs/src/windows/nt.rs @@ -15,16 +15,16 @@ use windows::Wdk::Storage::FileSystem::{ NTCREATEFILE_CREATE_OPTIONS, NtCreateFile, NtSetInformationFile, }; use windows::Win32::Foundation::{ - ERROR_NO_MORE_FILES, HANDLE, NTSTATUS, OBJ_CASE_INSENSITIVE, RtlNtStatusToDosError, STATUS_INVALID_INFO_CLASS, - STATUS_INVALID_PARAMETER, STATUS_NOT_SUPPORTED, UNICODE_STRING, + ERROR_NO_MORE_FILES, HANDLE, NTSTATUS, OBJ_CASE_INSENSITIVE, RtlNtStatusToDosError, STATUS_INVALID_DEVICE_REQUEST, + STATUS_INVALID_INFO_CLASS, STATUS_INVALID_PARAMETER, STATUS_NOT_IMPLEMENTED, STATUS_NOT_SUPPORTED, UNICODE_STRING, }; use windows::Win32::Security::SECURITY_DESCRIPTOR; use windows::Win32::Storage::FileSystem::{ - FILE_ACCESS_RIGHTS, FILE_ATTRIBUTE_TAG_INFO, FILE_FLAGS_AND_ATTRIBUTES, FILE_ID_BOTH_DIR_INFO, FILE_ID_INFO, - FILE_INFO_BY_HANDLE_CLASS, FILE_NAME_NORMALIZED, FILE_SHARE_MODE, FILE_STANDARD_INFO, FileAttributeTagInfo, - FileIdBothDirectoryInfo, FileIdBothDirectoryRestartInfo, FileIdInfo, FileStandardInfo, FlushFileBuffers, - GETFINALPATHNAMEBYHANDLE_FLAGS, GetFileInformationByHandleEx, GetFinalPathNameByHandleW, - GetVolumeInformationByHandleW, VOLUME_NAME_DOS, + FILE_ACCESS_RIGHTS, FILE_ATTRIBUTE_REPARSE_POINT, FILE_ATTRIBUTE_TAG_INFO, FILE_FLAGS_AND_ATTRIBUTES, + FILE_ID_BOTH_DIR_INFO, FILE_ID_INFO, FILE_INFO_BY_HANDLE_CLASS, FILE_NAME_NORMALIZED, FILE_SHARE_MODE, + FILE_STANDARD_INFO, FileAttributeTagInfo, FileIdBothDirectoryInfo, FileIdBothDirectoryRestartInfo, FileIdInfo, + FileStandardInfo, FlushFileBuffers, GETFINALPATHNAMEBYHANDLE_FLAGS, GetFileInformationByHandleEx, + GetFinalPathNameByHandleW, GetVolumeInformationByHandleW, VOLUME_NAME_DOS, }; use windows::Win32::System::IO::IO_STATUS_BLOCK; use windows::Win32::System::SystemServices::FILE_PERSISTENT_ACLS; @@ -218,6 +218,9 @@ pub(super) fn rename( flags |= FILE_RENAME_REPLACE_IF_EXISTS; } let mut status = set_rename(handle, root, name, flags, FileRenameInformationEx)?; + // UNTESTED: where the file system lacks the extended class, the legacy class renames, with or without replacing. + // Tests run on NTFS of a Windows that has it; when changing this, check on FAT or an older Windows, or force the + // fallback, that every rename test still passes. if needs_legacy_class(status) { // The legacy class reads ReplaceIfExists, a BOOLEAN, from the first byte of the same field. status = set_rename(handle, root, name, u32::from(replace), FileRenameInformation)?; @@ -235,6 +238,8 @@ fn needs_legacy_class(status: NTSTATUS) -> bool { STATUS_INVALID_PARAMETER, STATUS_NOT_SUPPORTED, STATUS_INVALID_INFO_CLASS, + STATUS_INVALID_DEVICE_REQUEST, + STATUS_NOT_IMPLEMENTED, ] .contains(&status) } @@ -297,6 +302,9 @@ pub(super) fn delete(handle: BorrowedHandle<'_>) -> io::Result<()> { FileDispositionInformationEx, ) }; + // UNTESTED: where the file system lacks the extended class, the legacy class deletes once every handle is closed. + // Tests run on NTFS of a Windows that has it; when changing this, check on FAT or an older Windows, or force the + // fallback, that every removal test but the one needing POSIX semantics still passes. if needs_legacy_class(status) { let delete_file = 1u8; // SAFETY: The BOOLEAN DeleteFile, a FILE_DISPOSITION_INFORMATION, stays live for the call. @@ -330,10 +338,11 @@ unsafe fn set_information( unsafe { NtSetInformationFile(raw(handle), &raw mut status_block, information, length, class) } } -/// Lists the entries of the directory `handle` refers to, with their file attributes, without `.` and `..`. +/// Lists the entries of the directory `handle` refers to, with their file attributes and reparse tags, without `.` +/// and `..`. /// /// The listing moves the handle's enumeration cursor, so callers pass a handle that nothing else lists through. -pub(super) fn list(handle: BorrowedHandle<'_>) -> io::Result> { +pub(super) fn list(handle: BorrowedHandle<'_>) -> io::Result> { // A u64 buffer is aligned for FILE_ID_BOTH_DIR_INFO, whose largest fields are 64-bit. let mut buffer = vec![0u64; 8 * 1024]; let byte_length = buffer.len() * size_of::(); @@ -364,7 +373,7 @@ pub(super) fn list(handle: BorrowedHandle<'_>) -> io::Result) -> io::Result<()> { +fn parse_listing(bytes: &[u8], entries: &mut Vec<(OsString, u32, u32)>) -> io::Result<()> { let invalid = || io::Error::new(io::ErrorKind::InvalidData, "malformed directory listing"); let mut offset = 0usize; loop { @@ -375,6 +384,12 @@ fn parse_listing(bytes: &[u8], entries: &mut Vec<(OsString, u32)>) -> io::Result }; let next = field(offset_of!(FILE_ID_BOTH_DIR_INFO, NextEntryOffset))?; let attributes = field(offset_of!(FILE_ID_BOTH_DIR_INFO, FileAttributes))?; + // A reparse point's entry holds its reparse tag in EaSize, per [MS-FSCC] §2.4.17. + let tag = if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { + field(offset_of!(FILE_ID_BOTH_DIR_INFO, EaSize))? + } else { + 0 + }; let name_length = usize::try_from(field(offset_of!(FILE_ID_BOTH_DIR_INFO, FileNameLength))?).map_err(|_| invalid())?; let start = offset + offset_of!(FILE_ID_BOTH_DIR_INFO, FileName); @@ -387,7 +402,7 @@ fn parse_listing(bytes: &[u8], entries: &mut Vec<(OsString, u32)>) -> io::Result .collect(); let name = OsString::from_wide(&units); if name != "." && name != ".." { - entries.push((name, attributes)); + entries.push((name, attributes, tag)); } if next == 0 { return Ok(()); diff --git a/crates/private-fs/src/windows/security.rs b/crates/private-fs/src/windows/security.rs index 94e3e4eb30..6e48c25261 100644 --- a/crates/private-fs/src/windows/security.rs +++ b/crates/private-fs/src/windows/security.rs @@ -329,7 +329,8 @@ impl Policy { /// Reports whether `sid` is an AppContainer package SID (S-1-15-2-...) or capability SID (S-1-15-3-...). /// /// An entry for one never grants access by itself: Windows grants an AppContainer process only the access that both -/// its user and groups, and its package and capabilities, are granted, and other processes don't hold these SIDs. +/// its user and groups, and its package and capabilities, are granted, and other processes don't hold these SIDs, as +/// Microsoft Learn "Launch an AppContainer" states in "AppContainer overview". /// Windows puts such entries on user profile directories, such as `AppData\Local`. fn is_app_container(sid: &Sid) -> bool { // SECURITY_APP_PACKAGE_AUTHORITY. @@ -487,17 +488,16 @@ unsafe fn read_acl(acl: *mut ACL) -> io::Result> { let too_short = || io::Error::new(io::ErrorKind::InvalidData, "access control entry too short"); let sid_offset = offset_of!(ACCESS_ALLOWED_ACE, SidStart); let ace_size = usize::from(header.AceSize); - if ace_size < sid_offset + 1 /* Revision */ + 1 + let count_offset = sid_offset + 1 /* Revision */; + if ace_size < count_offset + 1 /* SubAuthorityCount */ { return Err(too_short()); } - let count_pointer = pointer.cast::().wrapping_add(sid_offset + 1 /* Revision */); + let count_pointer = pointer.cast::().wrapping_add(count_offset); // SAFETY: The entry is at least as long as the byte read, per its size checked above. let sub_authority_count = usize::from(unsafe { count_pointer.read() }); - let sid_size = 1 /* Revision */ + 1 /* SubAuthorityCount */ + 6 /* IdentifierAuthority */ - + 4 /* SubAuthority */ * sub_authority_count; - if ace_size < sid_offset + sid_size { + if ace_size < sid_offset + sid_size(sub_authority_count) { return Err(too_short()); } let mask_pointer = pointer.cast::().wrapping_add(offset_of!(ACCESS_ALLOWED_ACE, Mask)); @@ -522,6 +522,12 @@ unsafe fn read_acl(acl: *mut ACL) -> io::Result> { Ok(entries) } +/// Returns the size of a SID with `sub_authority_count` subauthorities, per [MS-DTYP] §2.4.2.2 SID. +fn sid_size(sub_authority_count: usize) -> usize { + let header = 1 /* Revision */ + 1 /* SubAuthorityCount */ + 6 /* IdentifierAuthority */; + header + 4 /* SubAuthority */ * sub_authority_count +} + /// An absolute security descriptor with an owner and a protected DACL, for creating an item or setting its /// security. pub(super) struct Descriptor { @@ -535,10 +541,8 @@ impl Descriptor { pub(super) fn new(owner: &Sid, entries: &[Entry]) -> io::Result { let too_large = || io::Error::new(io::ErrorKind::InvalidInput, "the access control list is too large"); // An entry is its header and mask, then its SID. - let size = entries.iter().try_fold(size_of::(), |size, (sid, _, _)| { - let sid_size = 1 /* Revision */ + 1 /* SubAuthorityCount */ + 6 /* IdentifierAuthority */ - + 4 /* SubAuthority */ * sid.sub_authorities().len(); - size.checked_add(offset_of!(ACCESS_ALLOWED_ACE, SidStart) + sid_size) + let size = entries.iter().try_fold(8 /* ACL header */, |size: usize, (sid, _, _)| { + size.checked_add(offset_of!(ACCESS_ALLOWED_ACE, SidStart) + sid_size(sid.sub_authorities().len())) }); // The ACL size is a 16-bit field. let size = size.and_then(|size| u16::try_from(size).ok()).ok_or_else(too_large)?; @@ -847,7 +851,7 @@ mod tests { assert_eq!(refusal(checked), "accepted"); } - // oracle: spec Microsoft Learn "AppContainer isolation" + // oracle: spec Microsoft Learn "Launch an AppContainer" §AppContainer overview #[test] fn an_ancestor_entry_for_an_app_container_capability_is_ignored() { // The capability SID that Windows puts on AppData\Local, with full access. @@ -889,7 +893,7 @@ mod tests { assert_eq!(refusal(checked), "accepted"); } - // oracle: spec Microsoft Learn "AppContainer isolation" + // oracle: spec Microsoft Learn "Launch an AppContainer" §AppContainer overview #[test] fn an_ancestor_entry_for_all_application_packages_is_ignored() { // ALL APPLICATION PACKAGES, S-1-15-2-1, a package SID. diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index 2054f6fb5a..0dedc21bd2 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -300,7 +300,7 @@ fn the_backup_privilege_opens_a_file_the_token_cannot() -> anyhow::Result<()> { matches!(denied.kind(), ErrorKind::Io { source, .. } if source.kind() == std::io::ErrorKind::PermissionDenied), "{denied:?}" ); - let _backup = match BackupPrivilege::enable() { + let backup = match BackupPrivilege::enable() { Ok(backup) => backup, Err(error) => { println!("SKIP: the process token doesn't hold the backup privilege: {error}"); @@ -308,6 +308,13 @@ fn the_backup_privilege_opens_a_file_the_token_cannot() -> anyhow::Result<()> { } }; assert!(base.open_file("file", &rule, FileOptions::new())?.is_some()); + // Dropping the guard takes the privilege away again. + drop(backup); + let denied = refusal(base.open_file("file", &rule, FileOptions::new())); + assert!( + matches!(denied.kind(), ErrorKind::Io { source, .. } if source.kind() == std::io::ErrorKind::PermissionDenied), + "{denied:?}" + ); Ok(()) } @@ -719,7 +726,7 @@ fn a_name_win32_paths_reach_differently_is_refused(#[case] name: &str) -> anyhow Ok(()) } -// oracle: reference crates/private-fs/src/unix.rs rename +// oracle: spec IEEE Std 1003.1-2017 §rename #[test] fn persist_replaces_a_destination_another_process_holds_open() -> anyhow::Result<()> { // Catches a replacement that fails while a reader, sharing deletion as std does, holds the destination open. @@ -736,7 +743,7 @@ fn persist_replaces_a_destination_another_process_holds_open() -> anyhow::Result Ok(()) } -// oracle: reference crates/private-fs/src/unix.rs remove_file +// oracle: spec IEEE Std 1003.1-2017 §unlink #[test] fn removing_a_file_another_process_holds_open_frees_its_name() -> anyhow::Result<()> { // Catches a removal that leaves the name pending deletion until the reader closes it, so that creating it again @@ -751,7 +758,7 @@ fn removing_a_file_another_process_holds_open_frees_its_name() -> anyhow::Result Ok(()) } -// oracle: reference crates/private-fs/src/windows/mod.rs BackupPrivilege +// oracle: mutant guards nested on one thread, whose drops out of order leave the thread with the privilege #[test] fn a_thread_holds_one_backup_privilege_at_a_time() -> anyhow::Result<()> { // Catches nested guards, whose drops out of order would leave the thread with the privilege. From 352b0c38c67c99d01e05caf51a3c07fc96d2b95f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 17:07:05 +0900 Subject: [PATCH 10/60] feat(agent): keep identity state with private-fs Agent Identity now provisions, checks, writes and removes its directories, stored identities, pending-enrollment files, in-progress records, rejection markers and Unix lock file through private-fs handles checked against one rule each, instead of its own permission, ACL, link, reparse-point and volume code for each platform. An administrator's `identity enroll` on Windows opens the drop box the same way, with the backup privilege, and publishes its file with an exclusive rename through the handle that created it. The data directory and every directory above it must not be replaceable by another account, as OpenSSH's StrictModes requires. Linux and macOS pending files owned by an account other than the agent's or root are now ignored and kept, and state with extended ACL entries or on a volume that ignores ownership is refused on every Unix platform, not only macOS. Root may own identity state on Linux and macOS, as SYSTEM may on Windows. Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/agent-identity/Cargo.toml | 2 +- crates/agent-identity/src/lib.rs | 2 - crates/agent-identity/src/macos_acl.rs | 51 - crates/agent-identity/src/pending.rs | 36 +- crates/agent-identity/src/private_file.rs | 1484 +++++++++++------ crates/agent-identity/src/state.rs | 32 +- crates/agent-identity/src/state_lock.rs | 131 +- crates/agent-identity/src/windows.rs | 1767 ++------------------- testsuite/tests/agent_identity/mod.rs | 4 +- 9 files changed, 1284 insertions(+), 2225 deletions(-) delete mode 100644 crates/agent-identity/src/macos_acl.rs diff --git a/crates/agent-identity/Cargo.toml b/crates/agent-identity/Cargo.toml index 46a60c4da3..e0d4171f1c 100644 --- a/crates/agent-identity/Cargo.toml +++ b/crates/agent-identity/Cargo.toml @@ -23,6 +23,7 @@ httpdate = "1" http-client-proxy = { path = "../http-client-proxy" } p256 = { version = "=0.14.0-rc.14", features = ["ecdsa", "pkcs8"] } p384 = { version = "=0.14.0-rc.14", features = ["ecdsa", "pkcs8"] } +private-fs = { path = "../private-fs" } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots", "socks"] } rustls-pemfile = "2.2" serde = { version = "1", features = ["derive"] } @@ -40,7 +41,6 @@ zeroize = "1" [target.'cfg(unix)'.dependencies] libc = "0.2" -private-fs = { path = "../private-fs" } [target.'cfg(windows)'.dependencies] win-api-wrappers = { path = "../win-api-wrappers" } diff --git a/crates/agent-identity/src/lib.rs b/crates/agent-identity/src/lib.rs index 012451bb8d..a06131b326 100644 --- a/crates/agent-identity/src/lib.rs +++ b/crates/agent-identity/src/lib.rs @@ -7,8 +7,6 @@ pub mod state; pub mod token; mod chain; -#[cfg(target_os = "macos")] -mod macos_acl; mod private_file; mod rejected; mod state_lock; diff --git a/crates/agent-identity/src/macos_acl.rs b/crates/agent-identity/src/macos_acl.rs deleted file mode 100644 index ec6a6d765d..0000000000 --- a/crates/agent-identity/src/macos_acl.rs +++ /dev/null @@ -1,51 +0,0 @@ -use std::fs::File; -use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; - -use anyhow::{Context as _, ensure}; -use private_fs::ExtendedAclError; - -fn ensure_private_volume(file: &File) -> anyhow::Result<()> { - let ignores_ownership = private_fs::volume_ignores_ownership(file).context("inspect identity volume")?; - ensure!(!ignores_ownership, "identity volume ignores ownership"); - Ok(()) -} - -fn ensure_no_extended_acl(file: &File) -> anyhow::Result<()> { - let has_entries = private_fs::has_extended_acl(file).map_err(|error| match error { - ExtendedAclError::Inspect(error) => anyhow::Error::new(error).context("inspect identity ACL"), - ExtendedAclError::Validate(error) => anyhow::Error::new(error).context("validate identity ACL"), - ExtendedAclError::ReadEntries(error) => anyhow::Error::new(error).context("read identity ACL entries"), - })?; - ensure!(!has_entries, "identity path has extended ACL entries"); - Ok(()) -} - -pub(crate) fn ensure_private_file(file: &File) -> anyhow::Result<()> { - ensure_private_volume(file)?; - let user = private_fs::effective_uid(); - ensure!( - file.metadata()?.uid() == user, - "identity file owner is not the process user" - ); - ensure_no_extended_acl(file) -} - -pub(crate) fn ensure_private_pending_file(file: &File) -> anyhow::Result<()> { - ensure_private_volume(file)?; - ensure_no_extended_acl(file) -} - -pub(crate) fn ensure_private_directory(file: &File) -> anyhow::Result<()> { - ensure_private_volume(file)?; - let metadata = file.metadata()?; - let user = private_fs::effective_uid(); - ensure!( - metadata.uid() == user, - "identity directory owner is not the process user" - ); - ensure!( - metadata.permissions().mode() & 0o022 == 0, - "identity directory is writable by other users" - ); - ensure_no_extended_acl(file) -} diff --git a/crates/agent-identity/src/pending.rs b/crates/agent-identity/src/pending.rs index f43272a47b..fca571a6da 100644 --- a/crates/agent-identity/src/pending.rs +++ b/crates/agent-identity/src/pending.rs @@ -80,7 +80,7 @@ pub fn write(data_dir: &Utf8Path, token: &Token, grant_current_user: bool) -> an let contents = plaintext; #[cfg(windows)] if administrator_writer && !grant_current_user { - crate::windows::submit_pending_as_administrator(data_dir, &path, &contents)?; + private_file::submit_pending_as_administrator(data_dir, &path, &contents)?; return Ok(path); } let _lock = state_lock::acquire(data_dir, grant_current_user)?; @@ -110,7 +110,7 @@ pub fn list(data_dir: &Utf8Path) -> anyhow::Result> { Ok(paths) } -fn pending_hash_from_name(name: &str) -> Option<&str> { +pub(crate) fn pending_hash_from_name(name: &str) -> Option<&str> { name.strip_suffix(&format!(".{EXTENSION}")) .filter(|hash| valid_hash_hex(hash)) } @@ -282,6 +282,16 @@ mod tests { use super::*; + /// Creates an empty data directory under the system temporary directory, whose directories above pass the checks + /// of the agent's state, which a workspace `target` directory may not. + fn test_data_dir() -> anyhow::Result { + let dir = Utf8PathBuf::from_path_buf(std::env::temp_dir()) + .map_err(|_| anyhow::anyhow!("the temporary directory path is not UTF-8"))? + .join(format!("ai-{}", uuid::Uuid::new_v4().simple())); + fs::create_dir_all(&dir)?; + Ok(dir) + } + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files #[test] fn only_matching_version_one_tokens_can_be_malformed() -> anyhow::Result<()> { @@ -352,11 +362,7 @@ mod tests { URL_SAFE_NO_PAD.encode(r#"{"u":"https://example.test"}"#), "A".repeat(43) ))?; - let dir = Utf8Path::new(env!("CARGO_MANIFEST_DIR")) - .join("target") - .join("pending-tests") - .join(uuid::Uuid::new_v4().to_string()); - fs::create_dir_all(&dir)?; + let dir = test_data_dir()?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; @@ -396,11 +402,7 @@ mod tests { URL_SAFE_NO_PAD.encode(r#"{"u":"https://example.test"}"#), "A".repeat(43) ); - let dir = Utf8Path::new(env!("CARGO_MANIFEST_DIR")) - .join("target") - .join("pending-tests") - .join(uuid::Uuid::new_v4().to_string()); - fs::create_dir_all(&dir)?; + let dir = test_data_dir()?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; @@ -460,11 +462,7 @@ mod tests { "A".repeat(43) ); let token = Token::parse(&raw)?; - let dir = Utf8Path::new(env!("CARGO_MANIFEST_DIR")) - .join("target") - .join("pending-tests") - .join(uuid::Uuid::new_v4().to_string()); - fs::create_dir_all(&dir)?; + let dir = test_data_dir()?; let hash = hex::encode(Sha256::digest(raw.as_bytes())); assert_eq!( file_path(&dir, &hash)?, @@ -529,12 +527,12 @@ mod tests { "files without a protected DACL must not be processed" ); fs::remove_file(&independent)?; - crate::windows::assert_pending_protection(&path, true)?; + crate::windows::test::assert_pending_protection(&path, true)?; let protected_path = independent; if crate::windows::is_system()? { assert_eq!(write(&dir, &other, false)?, protected_path); assert_eq!(write(&dir, &other, false)?, protected_path); - crate::windows::assert_pending_protection(&protected_path, false)?; + crate::windows::test::assert_pending_protection(&protected_path, false)?; fs::remove_file(protected_path)?; } else { assert!(write(&dir, &other, false).is_err()); diff --git a/crates/agent-identity/src/private_file.rs b/crates/agent-identity/src/private_file.rs index a5a24eae5f..1e85291085 100644 --- a/crates/agent-identity/src/private_file.rs +++ b/crates/agent-identity/src/private_file.rs @@ -1,13 +1,22 @@ use std::error::Error; use std::fmt; -use std::fs::{self, File, OpenOptions}; -use std::io::{ErrorKind, Write as _}; +use std::fs::File; +use std::io::{self, Write as _}; +#[cfg(windows)] +use std::time::{Duration, Instant}; use anyhow::{Context as _, ensure}; use camino::{Utf8Path, Utf8PathBuf}; +use private_fs::{Access, Account, Dir, EntryKind, Existing, Fallback, FileOptions, Replace, Rule, Share}; use tracing::warn; use uuid::Uuid; +use crate::pending; + +/// The message an administrator's submission adds when the service has not provisioned the drop box it writes into. +#[cfg(windows)] +const ENROLLMENT_DIRECTORY_NOT_READY: &str = "the Devolutions Agent service has not prepared the enrollment directory yet; start the Devolutions Agent service once, then retry"; + #[derive(Debug)] struct UntrustedStateFile; @@ -52,81 +61,167 @@ fn untrusted() -> anyhow::Error { anyhow::Error::new(UntrustedStateFile) } -struct TemporaryFile(Utf8PathBuf); +fn untrusted_stored_identity(path: &Utf8Path, source: anyhow::Error) -> anyhow::Error { + anyhow::Error::new(UntrustedStoredIdentity { + path: path.to_owned(), + source, + }) +} -impl Drop for TemporaryFile { - fn drop(&mut self) { - let _ = fs::remove_file(&self.0); +/// Gives SYSTEM full control, and the agent's account too when `grant_current_user` is set. +/// +/// [`crates/agent-identity/INTENT.md`] ("Local state"): on Linux and macOS the agent's account owns what it creates and +/// keeps full access whatever the rule says, so the grant to it applies only where it owns the item, and the owner's +/// full access is accepted. +/// +/// [`crates/agent-identity/INTENT.md`]: ../INTENT.md +fn granted(rule: Rule, grant_current_user: bool) -> Rule { + let rule = rule + .grant(Account::System, Access::Full) + .owner_fallback(Fallback::Access(Access::Full)); + if grant_current_user { + rule.grant_or(Account::CurrentUser, Access::Full, Fallback::NoAccess) + } else { + rule } } -pub(crate) fn create_directory(path: &Utf8Path) -> anyhow::Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; +/// The rule of ``, `identity`, `authorities`, `keys`, `rejected`, every authority directory and every +/// `identity.json`. +pub(crate) fn tree_rule(grant_current_user: bool) -> Rule { + granted( + Rule::new().owner(Account::CurrentUser).owner(Account::System), + grant_current_user, + ) + .exact() +} + +/// The rule of `identity/pending`, a drop box administrators can add a file to without listing or reading it. +fn drop_box_rule(grant_current_user: bool) -> Rule { + tree_rule(grant_current_user).grant(Account::Administrators, Access::AddOnly) +} - use private_fs::DirChainError; +/// The rule of in-progress records, rejection markers and the Unix lock file, which are checked for giving no account +/// more than it allows. +pub(crate) fn state_file_rule(grant_current_user: bool) -> Rule { + granted( + Rule::new().owner(Account::CurrentUser).owner(Account::System), + grant_current_user, + ) +} - private_fs::create_dir_chain(path).map_err(|error| match error { - DirChainError::NotADirectory(_) => anyhow::anyhow!("identity path is not a directory"), - DirChainError::Inspect(_, error) => anyhow::Error::new(error).context("inspect identity directory"), - DirChainError::Create(_, error) => anyhow::Error::new(error).context("create identity directory"), - DirChainError::Sync(_, error) => anyhow::Error::new(error).context("sync identity directory"), - })?; - #[cfg(target_os = "macos")] - crate::macos_acl::ensure_private_directory(&File::open(path)?)?; - ensure!( - fs::symlink_metadata(path)?.permissions().mode() & 0o022 == 0, - "identity directory is writable by other users" - ); - } - #[cfg(not(unix))] - fs::create_dir_all(path).context("create identity directory")?; - let metadata = fs::symlink_metadata(path)?; - ensure!(metadata.file_type().is_dir(), "identity path is not a directory"); +/// The rule of a pending-enrollment file, which an administrator can own. +/// +/// [`crates/agent-identity/INTENT.md`] ("Pending-enrollment files"): on Windows, the exact check accepts the rule's DACL, +/// plus a zero-access OWNER RIGHTS entry when an administrator who is not the agent's account owns the file. +/// +/// [`crates/agent-identity/INTENT.md`]: ../INTENT.md +fn pending_file_rule(grant_current_user: bool) -> Rule { + granted( + Rule::new() + .owner(Account::CurrentUser) + .owner(Account::System) + .owner(Account::Administrators), + grant_current_user, + ) + .exact() +} + +fn not_found() -> anyhow::Error { + anyhow::Error::new(io::Error::from(io::ErrorKind::NotFound)) +} + +fn is_not_found(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some_and(|error| error.kind() == io::ErrorKind::NotFound) +} + +/// Reports whether a state file fails its rule, or is closed to the agent, which only another account can do. +fn fails_trust_check(error: &private_fs::Error) -> bool { + error.is_refused() + || matches!( + error.kind(), + private_fs::ErrorKind::Io { source, .. } if source.kind() == io::ErrorKind::PermissionDenied + ) +} + +/// Opens the directory holding `path`, once it and the directories above it pass the check for directories above, and +/// fails with a top-level `NotFound` I/O error when it doesn't exist. +fn open_parent(path: &Utf8Path) -> anyhow::Result<(Dir, &str)> { + let parent = path.parent().context("identity path has no parent directory")?; + let name = path.file_name().context("identity path has no name")?; + // The check for directories above only asks who owns them and who can replace them, so a rule giving the agent's + // account access fits whether or not the agent runs as SYSTEM. + let directory = Dir::open_base(parent, &tree_rule(true)) + .context("open identity directory")? + .ok_or_else(not_found)?; + Ok((directory, name)) +} + +/// Provisions the identity directories, and returns ``. +/// +/// [`crates/agent-identity/INTENT.md`] ("Local state"): an identity directory with a wrong owner or wrong permissions is +/// reset, without changing anything below it. +/// +/// [`crates/agent-identity/INTENT.md`]: ../INTENT.md +pub(crate) fn provision_directories(data_dir: &Utf8Path, grant_current_user: bool) -> anyhow::Result { #[cfg(windows)] ensure!( - !crate::windows::is_reparse_point(&metadata), - "identity directory is a reparse point" + grant_current_user || crate::windows::is_system()?, + "identity directory must be created by SYSTEM" ); - Ok(()) + let rule = tree_rule(grant_current_user); + let data = Dir::create_base(data_dir, &rule).context("open identity data directory")?; + let identity = provision(&data, "identity", &rule)?; + for name in ["authorities", "keys", "rejected"] { + provision(&identity, name, &rule)?; + } + provision(&identity, "pending", &drop_box_rule(grant_current_user))?; + return Ok(data); + + fn provision(parent: &Dir, name: &str, rule: &Rule) -> anyhow::Result { + // Repairing only a directory that fails its rule leaves a correct one unwritten. + match parent.create_dir(name, rule, Existing::Check) { + Err(error) if error.is_refused() => parent.create_dir(name, rule, Existing::Repair), + created => created, + } + .context("provision identity directory") + } } +#[cfg(test)] pub(crate) fn prepare_directories(data_dir: &Utf8Path, grant_current_user: bool) -> anyhow::Result<()> { - create_directory(data_dir)?; - let identity = data_dir.join("identity"); - create_owned_directory(&identity, grant_current_user)?; - for name in ["authorities", "keys", "pending", "rejected"] { - create_owned_directory(&identity.join(name), grant_current_user)?; - } - Ok(()) + provision_directories(data_dir, grant_current_user).map(drop) } /// Checks, without changing anything, that every authority directory and its `identity.json` are owned by the agent's /// account and protected from other users, and returns the authority IDs of the directories that exist. pub(crate) fn check_stored_identities(data_dir: &Utf8Path, grant_current_user: bool) -> anyhow::Result> { - let authorities = data_dir.join("identity").join("authorities"); - let entries = match fs::read_dir(&authorities) { - Ok(entries) => entries, - Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()), - Err(error) => return Err(error).context("list authority directories"), + let path = data_dir.join("identity").join("authorities"); + let Some(authorities) = + Dir::open_base(&path, &tree_rule(grant_current_user)).context("open authority directories")? + else { + return Ok(Vec::new()); }; let mut authority_ids = Vec::new(); - for entry in entries { - let entry = entry.context("read authority directory entry")?; - let name = entry.file_name(); + for entry in authorities.entries().context("list authority directories")? { // A case variant of an authority ID names the same directory on a case-insensitive file system, such as the // default ones of Windows and macOS. - let Some(authority_id) = name.to_str().and_then(|name| { + let Some((name, authority_id)) = entry.name.to_str().and_then(|name| { Uuid::parse_str(name) .ok() .filter(|uuid| uuid.to_string().eq_ignore_ascii_case(name)) + .map(|uuid| (name, uuid)) }) else { continue; }; - let path = Utf8PathBuf::from_path_buf(entry.path()).map_err(|_| anyhow::anyhow!("non-UTF-8 authority path"))?; - if check_authority_directory(&path, grant_current_user)? { - check_identity_file(&path.join("identity.json"), grant_current_user)?; + let authority_path = path.join(name); + if let Some(authority) = open_authority(&authorities, name, &authority_path, grant_current_user)? { + let identity_path = authority_path.join("identity.json"); + // Opening a file for its data just after a write can take tens of milliseconds, for example while an + // antivirus scans it, and this check precedes every use of the state. + open_identity_in(&authority, &identity_path, grant_current_user, FileOptions::new())?; authority_ids.push(authority_id); } } @@ -138,64 +233,23 @@ pub(crate) fn check_stored_identities(data_dir: &Utf8Path, grant_current_user: b /// Checks, without changing it, that an authority directory is owned by the agent's account and protected from other /// users, and returns whether it exists. pub(crate) fn check_authority_directory(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result { - let untrusted = |source| { - anyhow::Error::new(UntrustedStoredIdentity { - path: path.to_owned(), - source, - }) - }; - let metadata = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false), - Err(error) => { - return Err(untrusted( - anyhow::Error::new(error).context("inspect authority directory"), - )); - } + let (authorities, name) = match open_parent(path) { + Ok(opened) => opened, + Err(error) if is_not_found(&error) => return Ok(false), + Err(error) => return Err(untrusted_stored_identity(path, error)), }; - verify_authority_directory(path, &metadata, grant_current_user).map_err(untrusted)?; - Ok(true) + Ok(open_authority(&authorities, name, path, grant_current_user)?.is_some()) } -fn verify_authority_directory( +fn open_authority( + authorities: &Dir, + name: &str, path: &Utf8Path, - metadata: &fs::Metadata, grant_current_user: bool, -) -> anyhow::Result<()> { - ensure!(!metadata.file_type().is_symlink(), "authority path is a symbolic link"); - ensure!(metadata.file_type().is_dir(), "authority path is not a directory"); - #[cfg(unix)] - { - use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}; - - let _ = grant_current_user; - let directory = OpenOptions::new() - .read(true) - .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY) - .open(path) - .context("open authority directory")?; - let metadata = directory.metadata()?; - let user = private_fs::effective_uid(); - verify_unix_directory_access(metadata.permissions().mode(), metadata.uid(), user)?; - #[cfg(target_os = "macos")] - crate::macos_acl::ensure_private_directory(&directory)?; - } - #[cfg(windows)] - { - ensure!( - !crate::windows::is_reparse_point(metadata), - "authority directory is a reparse point" - ); - crate::windows::verify_protected_directory_path(path, grant_current_user)?; - } - Ok(()) -} - -#[cfg(unix)] -fn verify_unix_directory_access(mode: u32, owner: libc::uid_t, user: libc::uid_t) -> anyhow::Result<()> { - ensure!(owner == user, "authority directory owner is not the agent's account"); - ensure!(mode & 0o077 == 0, "authority directory is accessible to other users"); - Ok(()) +) -> anyhow::Result> { + authorities + .open_dir(name, &tree_rule(grant_current_user), Share::Shared) + .map_err(|error| untrusted_stored_identity(path, error.into())) } /// Opens an authority directory's `identity.json`, without changing it, once it passes the trust check, and returns @@ -207,152 +261,52 @@ fn verify_unix_directory_access(mode: u32, owner: libc::uid_t, user: libc::uid_t /// /// [`crates/agent-identity/INTENT.md`]: ../INTENT.md pub(crate) fn open_identity_file(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result> { - classify_identity_file(path, open_trusted_identity_file(path, grant_current_user, true)) -} - -/// Checks an authority directory's `identity.json` as [`open_identity_file`] does, without reading it. -pub(crate) fn check_identity_file(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result<()> { - classify_identity_file(path, open_trusted_identity_file(path, grant_current_user, false)).map(drop) + open_identity(path, grant_current_user, FileOptions::new().read(true)) } -/// Turns a failure to open a trusted `identity.json` into an absent file, or else into an [`UntrustedStoredIdentity`]. -fn classify_identity_file(path: &Utf8Path, opened: anyhow::Result) -> anyhow::Result> { - match opened { - Ok(file) => Ok(Some(file)), - Err(error) - if error - .downcast_ref::() - .is_some_and(|error| error.kind() == ErrorKind::NotFound) => - { - Ok(None) - } - Err(source) => Err(anyhow::Error::new(UntrustedStoredIdentity { - path: path.to_owned(), - source, - })), - } +fn open_identity(path: &Utf8Path, grant_current_user: bool, options: FileOptions) -> anyhow::Result> { + let authority = match open_parent(path) { + Ok((authority, _)) => authority, + Err(error) if is_not_found(&error) => return Ok(None), + Err(error) => return Err(untrusted_stored_identity(path, error)), + }; + open_identity_in(&authority, path, grant_current_user, options) } -fn open_trusted_identity_file(path: &Utf8Path, grant_current_user: bool, read_data: bool) -> anyhow::Result { - let metadata = fs::symlink_metadata(path).context("inspect identity file")?; - ensure!(metadata.file_type().is_file(), "identity path is not a regular file"); - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt as _; - - options.custom_flags(libc::O_NOFOLLOW); - let _ = read_data; - } - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt as _; - - use windows::Win32::Storage::FileSystem::{FILE_FLAG_OPEN_REPARSE_POINT, FILE_READ_ATTRIBUTES, READ_CONTROL}; - - ensure!( - !crate::windows::is_reparse_point(&metadata), - "identity file is a reparse point" - ); - if !read_data { - // Opening a file for its data just after a write can take tens of milliseconds, for example while an - // antivirus scans it, and the check precedes every use of the state. - options.access_mode((FILE_READ_ATTRIBUTES | READ_CONTROL).0); - } - options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0); - } - let file = options.open(path).context("open identity file")?; - let metadata = file.metadata()?; - ensure!(metadata.file_type().is_file(), "identity path is not a regular file"); - #[cfg(unix)] - { - use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; - - let _ = grant_current_user; - let user = private_fs::effective_uid(); - verify_unix_file_permissions(metadata.permissions().mode(), metadata.uid(), Some(user))?; - #[cfg(target_os = "macos")] - crate::macos_acl::ensure_private_file(&file)?; - } - #[cfg(windows)] - { - ensure!( - !crate::windows::is_reparse_point(&metadata), - "identity file is a reparse point" - ); - crate::windows::verify_identity_file(&file, grant_current_user)?; - } - Ok(file) +fn open_identity_in( + authority: &Dir, + path: &Utf8Path, + grant_current_user: bool, + options: FileOptions, +) -> anyhow::Result> { + let name = path.file_name().context("identity path has no name")?; + authority + .open_file(name, &tree_rule(grant_current_user), options) + .map_err(|error| untrusted_stored_identity(path, error.into())) } /// Creates a missing authority directory, and only checks an existing one, which is never repaired. +#[cfg(test)] pub(crate) fn create_authority_directory(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::DirBuilderExt as _; - - let mut builder = fs::DirBuilder::new(); - builder.mode(0o700); - if let Err(error) = builder.create(path) - && error.kind() != ErrorKind::AlreadyExists - { - return Err(error).context("create authority directory"); - } - } - #[cfg(windows)] - crate::windows::create_missing_directory(path, grant_current_user)?; - ensure!( - check_authority_directory(path, grant_current_user)?, - "authority directory disappeared after its creation" - ); - #[cfg(unix)] - { - sync_directory(path)?; - sync_directory(path.parent().context("authority directory has no parent")?)?; - } - Ok(()) + create_authority(path, grant_current_user).map(drop) } -fn create_owned_directory(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::{DirBuilderExt as _, MetadataExt as _, PermissionsExt as _}; - - let _ = grant_current_user; - let mut builder = fs::DirBuilder::new(); - builder.mode(0o700); - if let Err(error) = builder.create(path) - && error.kind() != ErrorKind::AlreadyExists - { - return Err(error).context("create identity directory"); - } - let metadata = fs::symlink_metadata(path)?; - ensure!(metadata.file_type().is_dir(), "identity path is not a directory"); - let user = private_fs::effective_uid(); - ensure!( - metadata.uid() == user, - "identity directory owner is not the process user" - ); - if metadata.permissions().mode() & 0o777 != 0o700 { - fs::set_permissions(path, fs::Permissions::from_mode(0o700)).context("protect identity directory")?; - } - ensure!( - fs::symlink_metadata(path)?.permissions().mode() & 0o777 == 0o700, - "identity directory is not owner-only" - ); - #[cfg(target_os = "macos")] - crate::macos_acl::ensure_private_directory(&File::open(path)?)?; - sync_directory(path)?; - sync_directory(path.parent().context("identity directory has no parent")?)?; - } +fn create_authority(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result { #[cfg(windows)] - crate::windows::create_protected_directory( - path, - grant_current_user, - path.file_name() == Some("pending") && path.parent().and_then(Utf8Path::file_name) == Some("identity"), - )?; - Ok(()) + ensure!( + grant_current_user || crate::windows::is_system()?, + "identity directory must be created by SYSTEM" + ); + let (authorities, name) = open_parent(path)?; + authorities + .create_dir(name, &tree_rule(grant_current_user), Existing::Check) + .map_err(|error| { + if error.is_refused() { + untrusted_stored_identity(path, error.into()) + } else { + anyhow::Error::new(error).context("create authority directory") + } + }) } pub(crate) fn write_atomic( @@ -361,191 +315,137 @@ pub(crate) fn write_atomic( bytes: &[u8], grant_current_user: bool, ) -> anyhow::Result<()> { + #[cfg(windows)] + ensure!( + grant_current_user || crate::windows::is_system()?, + "identity file must be written by SYSTEM" + ); let parent = path.parent().context("identity file has no parent directory")?; + let name = path.file_name().context("identity file has no name")?; let identity = data_dir.join("identity"); - let authorities = identity.join("authorities"); - if parent.parent() == Some(authorities.as_path()) { - create_authority_directory(parent, grant_current_user)?; + let (directory, rule) = if parent.parent() == Some(identity.join("authorities").as_path()) { + ( + create_authority(parent, grant_current_user)?, + tree_rule(grant_current_user), + ) + } else if parent == identity.join("pending") { + let rule = if pending::pending_hash_from_name(name).is_some() { + pending_file_rule(grant_current_user) + } else { + state_file_rule(grant_current_user) + }; + (open_parent(path)?.0, rule) } else { ensure!( - parent == identity.join("pending") || parent == identity.join("rejected"), + parent == identity.join("rejected"), "identity file is outside its state directories" ); + (open_parent(path)?.0, state_file_rule(grant_current_user)) + }; + let temporary = format!(".{name}-{}.tmp", Uuid::new_v4()); + let mut file = directory + .create_file(&temporary, &rule, FileOptions::new()) + .context("create identity file")?; + let installed = file + .write_all(bytes) + .context("write identity file") + .and_then(|()| persist_replacing(&directory, &file, &temporary, name)); + if installed.is_err() { + let _ = directory.discard(&file, &temporary); } - let name = path.file_name().context("identity file has no name")?; - let temporary = TemporaryFile(parent.join(format!(".{name}-{}.tmp", Uuid::new_v4()))); - #[cfg(windows)] - let pending_blob = parent == identity.join("pending") && path.extension() == Some("dat"); - #[cfg(not(windows))] - let pending_blob = false; - let mut file = create_private_file(&temporary.0, grant_current_user, pending_blob)?; - file.write_all(bytes).context("write identity file")?; - file.sync_all().context("sync identity file")?; - #[cfg(windows)] - { - crate::windows::verify_installed_file(&file, grant_current_user, pending_blob)?; - drop(file); - crate::windows::replace_file(&temporary.0, path).context("install identity file")?; - let installed = open_read(path)?; - crate::windows::verify_installed_file(&installed, grant_current_user, pending_blob)?; - } - #[cfg(unix)] - { - drop(file); - replace(&temporary.0, path).context("install identity file")?; - sync_directory(parent)?; - } - Ok(()) + installed } -pub(crate) fn open_read(path: &Utf8Path) -> anyhow::Result { - open_read_with_policy(path, None) -} - -pub(crate) fn open_pending_read(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result { - open_read_with_policy(path, Some(grant_current_user)) +/// Makes `file`, written as `temporary`, durable as `name`, replacing the file of that name. +#[cfg(unix)] +fn persist_replacing(directory: &Dir, file: &File, temporary: &str, name: &str) -> anyhow::Result<()> { + directory + .persist(file, temporary, name, Replace::Allow) + .context("install identity file") } -fn open_read_with_policy(path: &Utf8Path, pending_grant: Option) -> anyhow::Result { - let metadata = fs::symlink_metadata(path).context("inspect identity file")?; - if !metadata.file_type().is_file() { - return Err(untrusted()); - } - #[cfg(windows)] - if crate::windows::is_reparse_point(&metadata) { - return Err(untrusted()); - } - #[cfg(windows)] - match pending_grant { - Some(grant) => crate::windows::verify_pending_path(path, grant).map_err(|_| untrusted())?, - None => verify_state_path(path)?, - } - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt as _; - - options.custom_flags(libc::O_NOFOLLOW); - } - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt as _; +/// Makes `file`, written as `temporary`, durable as `name`, replacing the file of that name. +/// +/// While another process has the file it replaces open, even sharing deletion, the replacement fails with +/// `ERROR_ACCESS_DENIED`, so it is retried for 5 s; a reader such as a backup or an antivirus scan +/// holds the file only briefly. +#[cfg(windows)] +fn persist_replacing(directory: &Dir, file: &File, temporary: &str, name: &str) -> anyhow::Result<()> { + use windows::Win32::Foundation::ERROR_ACCESS_DENIED; - use windows::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; + /// How long a replacement of an identity file waits for other processes to close the file it replaces. + const REPLACE_TIMEOUT: Duration = Duration::from_secs(5); + const REPLACE_RETRY_INTERVAL: Duration = Duration::from_millis(25); - options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0); - } - let file = options.open(path).map_err(|error| { - if error.kind() == ErrorKind::PermissionDenied { - untrusted() - } else { - anyhow::Error::new(error).context("open identity file") + let access_denied = i32::try_from(ERROR_ACCESS_DENIED.0)?; + let started = Instant::now(); + loop { + match directory.persist(file, temporary, name, Replace::Allow) { + Err(error) + if matches!( + error.kind(), + private_fs::ErrorKind::Io { source, .. } if source.raw_os_error() == Some(access_denied) + ) && started.elapsed() < REPLACE_TIMEOUT => + { + std::thread::sleep(REPLACE_RETRY_INTERVAL); + } + persisted => return persisted.context("install identity file"), } - })?; - #[cfg(windows)] - if let Some(grant) = pending_grant { - crate::windows::verify_pending_read(&file, grant).map_err(|_| untrusted())?; - } else { - verify_open_file(&file)?; - } - #[cfg(unix)] - if pending_grant.is_some() { - verify_open_pending_file(&file)?; - } else { - verify_open_file(&file)?; } - Ok(file) } -#[cfg(unix)] -fn verify_unix_file_permissions( - mode: u32, - actual_owner: libc::uid_t, - required_owner: Option, -) -> anyhow::Result<()> { - if let Some(owner) = required_owner { - ensure!(actual_owner == owner, "identity file owner is not the process user"); - } - ensure!(mode & 0o077 == 0, "identity file is not owner-only"); - Ok(()) +/// Opens a rejection marker or an in-progress record once it passes the trust check. +/// +/// The check accepts SYSTEM and the agent's account, whether or not the agent runs as SYSTEM, so that a reader that +/// doesn't know the configured grant can tell a file the agent wrote from one another account wrote. +pub(crate) fn open_read(path: &Utf8Path) -> anyhow::Result { + open_checked(path, &state_file_rule(true)) } -#[cfg(unix)] -fn verify_open_pending_file(file: &File) -> anyhow::Result<()> { - let protection = || -> anyhow::Result<()> { - use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; - - let metadata = file.metadata()?; - ensure!(metadata.file_type().is_file(), "identity path is not a regular file"); - verify_unix_file_permissions(metadata.permissions().mode(), metadata.uid(), None)?; - #[cfg(target_os = "macos")] - crate::macos_acl::ensure_private_pending_file(file)?; - Ok(()) - }; - protection().map_err(|_| untrusted()) +pub(crate) fn open_pending_read(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result { + open_checked(path, &pending_file_rule(grant_current_user)) } -pub(crate) fn verify_open_file(file: &File) -> anyhow::Result<()> { - let protection = || -> anyhow::Result<()> { - let metadata = file.metadata()?; - ensure!(metadata.file_type().is_file(), "identity path is not a regular file"); - #[cfg(windows)] - ensure!( - !crate::windows::is_reparse_point(&metadata), - "identity path is a reparse point" - ); - #[cfg(unix)] - { - use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; - - let user = private_fs::effective_uid(); - verify_unix_file_permissions(metadata.permissions().mode(), metadata.uid(), Some(user))?; - #[cfg(target_os = "macos")] - crate::macos_acl::ensure_private_file(file)?; - } - #[cfg(windows)] - crate::windows::verify_trusted_state_file(file)?; - Ok(()) +fn open_checked(path: &Utf8Path, rule: &Rule) -> anyhow::Result { + let (directory, name) = open_parent(path)?; + let open = |options| match directory.open_file(name, rule, options) { + Ok(Some(file)) => Ok(file), + Ok(None) => Err(not_found()), + Err(error) if fails_trust_check(&error) => Err(untrusted()), + Err(error) => Err(anyhow::Error::new(error).context("open identity file")), }; - protection().map_err(|_| untrusted()) -} - -#[cfg(windows)] -pub(crate) fn verify_state_path(path: &Utf8Path) -> anyhow::Result<()> { - crate::windows::verify_trusted_state_path(path).map_err(|_| untrusted()) + // Reading a file's owner and access takes no access to its data, which a handle that shares nothing still allows on + // Windows, so a file that fails its rule is ignored even while another process holds it. + drop(open(FileOptions::new())?); + open(FileOptions::new().read(true)) } pub(crate) fn remove_file(path: &Utf8Path) -> anyhow::Result<()> { - remove_checked_file(path, open_read) + remove_checked_file(path, &state_file_rule(true)) } pub(crate) fn remove_pending_file(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result<()> { - remove_checked_file(path, |path| open_pending_read(path, grant_current_user)) -} - -fn remove_checked_file(path: &Utf8Path, open: impl FnOnce(&Utf8Path) -> anyhow::Result) -> anyhow::Result<()> { - match open(path) { - Ok(file) => drop(file), - Err(error) - if is_untrusted(&error) - || error - .downcast_ref::() - .is_some_and(|error| error.kind() == ErrorKind::NotFound) => - { - return Ok(()); - } + remove_checked_file(path, &pending_file_rule(grant_current_user)) +} + +/// Removes the file at `path` once it passes `rule`, and keeps a file that fails it. +fn remove_checked_file(path: &Utf8Path, rule: &Rule) -> anyhow::Result<()> { + let (directory, name) = match open_parent(path) { + Ok(opened) => opened, + Err(error) if is_not_found(&error) => return Ok(()), Err(error) => return Err(error), - } - match fs::remove_file(path) { - Ok(()) => { - #[cfg(unix)] - sync_directory(path.parent().context("identity file has no parent directory")?)?; - Ok(()) + }; + match directory.open_file(name, rule, FileOptions::new()) { + Ok(Some(file)) => drop(file), + Ok(None) => return Ok(()), + Err(error) if fails_trust_check(&error) => { + drop(untrusted()); + return Ok(()); } - Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), - Err(error) => Err(error).context("remove identity file"), + Err(error) => return Err(anyhow::Error::new(error).context("open identity file")), } + directory.remove_file(name).context("remove identity file")?; + Ok(()) } pub(crate) fn cleanup_temporary_files( @@ -553,22 +453,18 @@ pub(crate) fn cleanup_temporary_files( matches_stem: impl Fn(&str) -> bool, remove: impl Fn(&Utf8Path, &str) -> anyhow::Result<()>, ) -> anyhow::Result<()> { - let entries = match fs::read_dir(directory) { - Ok(entries) => entries, - Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()), - Err(error) => return Err(error).context("list identity directory"), + let Some(opened) = Dir::open_base(directory, &tree_rule(true)).context("open identity directory")? else { + return Ok(()); }; - for entry in entries { - let entry = entry.context("read identity directory entry")?; - let name = entry.file_name(); - let Some(stem) = name.to_str().and_then(temporary_stem) else { + for entry in opened.entries().context("list identity directory")? { + let Some(name) = entry.name.to_str() else { continue; }; - if matches_stem(stem) && entry.file_type()?.is_file() { - remove( - &Utf8PathBuf::from_path_buf(entry.path()).map_err(|_| anyhow::anyhow!("non-UTF-8 identity path"))?, - stem, - )?; + let Some(stem) = temporary_stem(name) else { + continue; + }; + if matches_stem(stem) && entry.kind == EntryKind::File { + remove(&directory.join(name), stem)?; } } Ok(()) @@ -582,88 +478,764 @@ fn temporary_stem(name: &str) -> Option<&str> { (uuid.get_version_num() == 4 && uuid.to_string() == suffix).then_some(stem) } -#[cfg(unix)] -fn create_private_file(path: &Utf8Path, _grant_current_user: bool, _pending_blob: bool) -> anyhow::Result { - use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; - - let file = OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(path) - .context("create identity file")?; +/// The rules an administrator's submission checks the drop box and the directories above it against. +#[cfg(windows)] +struct DropBox { + base: Rule, + identity: Rule, + pending: Rule, +} + +#[cfg(windows)] +impl DropBox { + /// The directories as the service running as SYSTEM provisions them. + fn provisioned_by_system() -> Self { + let system = || { + Rule::new() + .owner(Account::System) + .grant(Account::System, Access::Full) + .exact() + }; + Self { + base: system(), + identity: system(), + pending: system().grant(Account::Administrators, Access::AddOnly), + } + } +} + +/// Submits a pending-enrollment file into the drop box the service provisioned, as an administrator who can neither +/// list nor read it. +/// +/// [`crates/agent-identity/INTENT.md`] ("Pending-enrollment files"): the file is owned by the administrator, with a +/// zero-access OWNER RIGHTS entry, so the administrator can neither read it back nor rewrite its DACL. +/// +/// [`crates/agent-identity/INTENT.md`]: ../INTENT.md +#[cfg(windows)] +pub(crate) fn submit_pending_as_administrator( + data_dir: &Utf8Path, + path: &Utf8Path, + contents: &[u8], +) -> anyhow::Result<()> { + let started = Instant::now(); + // The backup privilege lets an administrator open the identity directories, which grant it no list or read access, + // to check who owns them and who can access them. + let _backup = private_fs::BackupPrivilege::enable().context("enable administrator backup privilege")?; + submit_into_drop_box(data_dir, path, contents, &DropBox::provisioned_by_system(), started) +} + +#[cfg(windows)] +fn submit_into_drop_box( + data_dir: &Utf8Path, + path: &Utf8Path, + contents: &[u8], + rules: &DropBox, + started: Instant, +) -> anyhow::Result<()> { + let pending_path = data_dir.join("identity").join("pending"); ensure!( - file.metadata()?.permissions().mode() & 0o077 == 0, - "identity file volume does not enforce owner-only permissions" + path.parent() == Some(pending_path.as_path()), + "pending file is outside the drop box" ); - #[cfg(target_os = "macos")] - crate::macos_acl::ensure_private_file(&file)?; - Ok(file) + let name = path.file_name().context("pending file has no name")?; + let pending = { + let data = retry_sharing_violation(started, || Ok(Dir::open_base(data_dir, &rules.base)?)) + .and_then(|data| data.ok_or_else(not_found)) + .context("inspect identity data directory") + .map_err(explain_unprepared_drop_box)?; + let identity = open_drop_box_directory(&data, "identity", &rules.identity, started) + .context("inspect protected identity directory") + .map_err(explain_unprepared_drop_box)?; + open_drop_box_directory(&identity, "pending", &rules.pending, started) + .context("inspect protected pending drop box") + .map_err(explain_unprepared_drop_box)? + }; + let staging = format!(".{name}-{}.tmp", Uuid::new_v4()); + let mut file = pending + .create_file(&staging, &pending_file_rule(false), FileOptions::new()) + .context("submit pending enrollment")?; + let published = file + .write_all(contents) + .context("write pending enrollment") + .and_then(|()| match pending.persist(&file, &staging, name, Replace::Refuse) { + Ok(()) => Ok(true), + // A pending file for the same token is already there, as after a replayed install. + Err(error) + if matches!( + error.kind(), + private_fs::ErrorKind::Io { source, .. } if source.kind() == io::ErrorKind::AlreadyExists + ) => + { + Ok(false) + } + Err(error) => Err(anyhow::Error::new(error).context("publish pending enrollment")), + }); + if !matches!(published, Ok(true)) { + // The rule gives the administrator no access to the file, so only the handle that created it can remove it. + let _ = pending.discard(&file, &staging); + } + published.map(drop) } #[cfg(windows)] -fn create_private_file(path: &Utf8Path, grant_current_user: bool, pending_blob: bool) -> anyhow::Result { - crate::windows::create_private_file(path, grant_current_user, pending_blob) +fn open_drop_box_directory(parent: &Dir, name: &str, rule: &Rule, started: Instant) -> anyhow::Result { + retry_sharing_violation(started, || Ok(parent.open_dir(name, rule, Share::Shared)?))?.ok_or_else(not_found) } -#[cfg(unix)] -fn replace(from: &Utf8Path, to: &Utf8Path) -> anyhow::Result<()> { - fs::rename(from, to).context("atomically replace identity file") +/// Retries `operation` while it fails with a sharing violation, as while the service holds the state lock, for 10 s +/// from `started`. +#[cfg(windows)] +fn retry_sharing_violation(started: Instant, mut operation: impl FnMut() -> anyhow::Result) -> anyhow::Result { + loop { + match operation() { + Err(error) if is_sharing_violation(&error) => { + let remaining = Duration::from_secs(10).saturating_sub(started.elapsed()); + if remaining.is_zero() { + return Err(error); + } + std::thread::sleep(remaining.min(Duration::from_millis(100))); + } + result => return result, + } + } } -#[cfg(unix)] -fn sync_directory(path: &Utf8Path) -> anyhow::Result<()> { - private_fs::sync_directory(path).context("sync identity directory") +#[cfg(windows)] +fn is_sharing_violation(error: &anyhow::Error) -> bool { + use windows::Win32::Foundation::ERROR_SHARING_VIOLATION; + + error.chain().any(|cause| { + cause.downcast_ref::().is_some_and(|source| { + source.raw_os_error().and_then(|code| u32::try_from(code).ok()) == Some(ERROR_SHARING_VIOLATION.0) + }) + }) +} + +/// Explains a drop box that is missing, closed to the administrator or not provisioned as the service does. +#[cfg(windows)] +fn explain_unprepared_drop_box(error: anyhow::Error) -> anyhow::Error { + let unprepared = error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|source| matches!(source.kind(), io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied)) + || cause + .downcast_ref::() + .is_some_and(private_fs::Error::is_refused) + }); + if unprepared { + error.context(ENROLLMENT_DIRECTORY_NOT_READY) + } else { + error + } } #[cfg(test)] mod tests { use super::*; - // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files - #[cfg(unix)] + // oracle: intent crates/agent-identity/INTENT.md §Local state #[test] - fn pending_files_do_not_require_service_ownership() { + fn recognizes_only_own_temporary_file_names() { + let uuid = Uuid::new_v4(); + assert_eq!( + temporary_stem(&format!(".identity.json-{uuid}.tmp")), + Some(".identity.json") + ); + assert_eq!(temporary_stem(".identity.json-not-a-uuid.tmp"), None); + assert_eq!(temporary_stem(&format!(".identity.json-{uuid}.backup")), None); + assert_eq!(temporary_stem(&format!(".x-é{}.tmp", "a".repeat(35))), None); + } +} + +#[cfg(all(test, unix))] +mod unix_tests { + use std::fs; + use std::io::Write as _; + use std::os::unix::fs::PermissionsExt as _; + + use super::*; + + /// An empty data directory under the system temporary directory, removed on drop. + struct TestDir(Utf8PathBuf); + + impl TestDir { + fn new() -> anyhow::Result { + let path = Utf8PathBuf::from_path_buf(std::env::temp_dir()) + .map_err(|_| anyhow::anyhow!("the temporary directory path is not UTF-8"))? + .join(format!("ai-{}", Uuid::new_v4().simple())); + fs::create_dir_all(&path)?; + fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?; + prepare_directories(&path, true)?; + Ok(Self(path)) + } + + fn pending_file(&self) -> anyhow::Result { + let path = pending::file_path(&self.0, &"a".repeat(64))?; + write_atomic(&self.0, &path, br#"{"version":1,"token":"x"}"#, true)?; + Ok(path) + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + /// Gives `path` an owner other than root and the current user, which only root can do. + fn give_to_another_account(path: &Utf8Path) -> anyhow::Result { // SAFETY: Reading the process's effective UID has no preconditions. - let service_user = unsafe { libc::geteuid() }; - let other_owner = service_user.wrapping_add(1); - assert!(verify_unix_file_permissions(0o600, other_owner, None).is_ok()); - // oracle: intent crates/agent-identity/INTENT.md §Local state - assert!(verify_unix_file_permissions(0o600, other_owner, Some(service_user)).is_err()); + if unsafe { libc::geteuid() } != 0 { + writeln!(io::stderr(), "SKIP: giving a file another owner needs root")?; + return Ok(false); + } + // The conventional `nobody` account, which is neither root nor the agent's account here. + std::os::unix::fs::chown(path, Some(65534), None)?; + Ok(true) } // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files - #[cfg(unix)] #[test] - fn pending_files_still_require_owner_only_permissions() { - for mode in [0o640, 0o604, 0o644] { - assert!(verify_unix_file_permissions(mode, 1, None).is_err()); + fn a_pending_file_owned_by_another_account_is_ignored() -> anyhow::Result<()> { + let directory = TestDir::new()?; + let path = directory.pending_file()?; + if !give_to_another_account(&path)? { + return Ok(()); } - assert!(verify_unix_file_permissions(0o600, 1, None).is_ok()); + + let error = open_pending_read(&path, true).expect_err("a pending file owned by another account was read"); + + assert!(is_untrusted(&error), "{error:#}"); + assert!(path.exists()); + Ok(()) + } + + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + #[rstest::rstest] + #[case::group_read(0o640)] + #[case::other_read(0o604)] + #[case::group_and_other_read(0o644)] + #[case::group_write(0o620)] + fn a_pending_file_open_to_group_or_other_is_ignored(#[case] mode: u32) -> anyhow::Result<()> { + let directory = TestDir::new()?; + let path = directory.pending_file()?; + fs::set_permissions(&path, fs::Permissions::from_mode(mode))?; + + let error = open_pending_read(&path, true).expect_err("a pending file open to other users was read"); + + assert!(is_untrusted(&error), "{error:#}"); + Ok(()) + } + + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + #[test] + fn an_owner_only_pending_file_is_read() -> anyhow::Result<()> { + let directory = TestDir::new()?; + let path = directory.pending_file()?; + + open_pending_read(&path, true)?; + + assert_eq!(fs::metadata(&path)?.permissions().mode() & 0o777, 0o600); + Ok(()) } // oracle: intent crates/agent-identity/INTENT.md §Local state - #[cfg(unix)] #[test] - fn an_authority_directory_owned_by_another_account_fails_the_check() { - // `crates/agent-identity/INTENT.md` ("Local state"): an authority directory must be owned by the agent's account. - // Only root can give a test directory another owner, so this pins the comparison the check makes. - // SAFETY: Reading the process's effective UID has no preconditions. - let agent = unsafe { libc::geteuid() }; - assert!(verify_unix_directory_access(0o700, agent.wrapping_add(1), agent).is_err()); - assert!(verify_unix_directory_access(0o700, agent, agent).is_ok()); + fn an_authority_directory_owned_by_another_account_fails_the_check() -> anyhow::Result<()> { + let directory = TestDir::new()?; + let authority = directory + .0 + .join("identity") + .join("authorities") + .join(Uuid::new_v4().to_string()); + create_authority_directory(&authority, true)?; + if !give_to_another_account(&authority)? { + return Ok(()); + } + + let error = check_authority_directory(&authority, true) + .expect_err("an authority directory owned by another account passed the check"); + + let untrusted = error + .downcast_ref::() + .context("the failure is not an untrusted stored identity")?; + assert_eq!(untrusted.path, authority); + Ok(()) + } +} + +#[cfg(all(test, windows))] +mod windows_tests { + use std::fs; + use std::io::Write as _; + + use ::windows::Win32::Security::{ + CONTAINER_INHERIT_ACE, NO_INHERITANCE, OBJECT_INHERIT_ACE, WinBuiltinAdministratorsSid, WinBuiltinUsersSid, + }; + use ::windows::Win32::Storage::FileSystem::{FILE_ADD_FILE, FILE_ALL_ACCESS, SYNCHRONIZE, WRITE_DAC}; + use win_api_wrappers::identity::sid::Sid; + + use super::*; + use crate::windows::test::{self as acl, Ace}; + + /// An empty data directory under the system temporary directory, removed on drop. + struct TestDir(Utf8PathBuf); + + impl TestDir { + fn new() -> anyhow::Result { + // The system temporary directory keeps test paths under Windows' 260-character limit wherever the repository + // is checked out. + let path = Utf8PathBuf::from_path_buf(std::env::temp_dir()) + .map_err(|_| anyhow::anyhow!("the temporary directory path is not UTF-8"))? + .join(format!("ai-{}", Uuid::new_v4().simple())); + fs::create_dir_all(&path)?; + Ok(Self(path)) + } + + fn provisioned() -> anyhow::Result { + let directory = Self::new()?; + prepare_directories(&directory.0, true)?; + Ok(directory) + } + + fn identity(&self) -> Utf8PathBuf { + self.0.join("identity") + } + + fn pending(&self) -> Utf8PathBuf { + self.identity().join("pending") + } + + fn pending_file(&self, letter: char) -> Utf8PathBuf { + self.pending().join(format!("{}.dat", letter.to_string().repeat(64))) + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + /// The rules of a drop box that the current user provisioned under `__debug__.identity.acl_grant_current_user`, as a + /// test session without SYSTEM can. + fn provisioned_by_current_user() -> DropBox { + DropBox { + base: tree_rule(true), + identity: tree_rule(true), + pending: drop_box_rule(true), + } + } + + const INHERITED: u32 = CONTAINER_INHERIT_ACE.0 | OBJECT_INHERIT_ACE.0; + + fn full(sid: &Sid, flags: u32) -> Ace { + (sid.clone(), FILE_ALL_ACCESS.0, flags) + } + + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + // oracle: intent devolutions-agent/src/identity/INTENT.md §Configuration + #[test] + fn provisioned_directories_grant_system_the_agent_and_administrators_add_only_on_the_drop_box() -> anyhow::Result<()> + { + let directory = TestDir::provisioned()?; + let user = acl::user_sid()?; + let system = acl::system_sid()?; + let administrators = Sid::from_well_known(WinBuiltinAdministratorsSid, None)?; + let tree = vec![full(&system, INHERITED), full(&user, INHERITED)]; + let mut drop_box = tree.clone(); + drop_box.push((administrators, FILE_ADD_FILE.0 | SYNCHRONIZE.0, NO_INHERITANCE.0)); + + for (name, expected) in [ + ("identity", &tree), + ("identity/authorities", &tree), + ("identity/keys", &tree), + ("identity/rejected", &tree), + ("identity/pending", &drop_box), + ] { + assert_eq!( + acl::protected_dacl_of(&directory.0.join(name))?, + Some(acl::sorted(expected)), + "{name}" + ); + } + Ok(()) } // oracle: intent crates/agent-identity/INTENT.md §Local state #[test] - fn recognizes_only_own_temporary_file_names() { - let uuid = Uuid::new_v4(); + fn provisioning_without_system_or_the_debug_grant_is_refused() -> anyhow::Result<()> { + if crate::windows::is_system()? { + writeln!( + io::stderr(), + "SKIP: the refusal applies to an account other than SYSTEM" + )?; + return Ok(()); + } + let directory = TestDir::new()?; + + assert!(prepare_directories(&directory.0, false).is_err()); + assert!(!directory.identity().exists()); + Ok(()) + } + + // oracle: intent crates/agent-identity/INTENT.md §Local state + #[test] + fn replacing_a_file_waits_for_a_reader_to_close_it() -> anyhow::Result<()> { + // A reader holding the destination open, even one sharing deletion as the standard library does, makes the + // replacement fail with `ERROR_ACCESS_DENIED` until it closes the file. + let directory = TestDir::provisioned()?; + let path = directory + .identity() + .join("rejected") + .join(format!("{}.json", "a".repeat(64))); + write_atomic(&directory.0, &path, b"old", true)?; + let reader = File::open(&path)?; + let closer = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(300)); + drop(reader); + }); + + let replaced = write_atomic(&directory.0, &path, b"new", true); + let _ = closer.join(); + + replaced?; + assert_eq!(fs::read(&path)?, b"new"); + Ok(()) + } + + // oracle: intent crates/agent-identity/INTENT.md §Local state + #[test] + fn repairs_a_directory_with_an_extra_allow_principal() -> anyhow::Result<()> { + let directory = TestDir::new()?; + let identity = directory.identity(); + fs::create_dir(&identity)?; + let user = acl::user_sid()?; + let system = acl::system_sid()?; + let users = Sid::from_well_known(WinBuiltinUsersSid, None)?; + acl::set_dacl( + &identity, + &[ + full(&system, INHERITED), + full(&user, INHERITED), + full(&users, INHERITED), + ], + )?; + // `crates/agent-identity/INTENT.md` ("Local state"): start-up resets the directory's permissions, so the + // identities and keys stored beneath it must survive the repair. + let state = [ + ( + identity + .join("authorities") + .join(Uuid::new_v4().to_string()) + .join("identity.json"), + br#"{"version":1}"#.as_slice(), + ), + ( + identity.join("keys").join("devolutions-agent-identity_test.p8"), + b"device key".as_slice(), + ), + ]; + for (path, contents) in &state { + fs::create_dir_all(path.parent().context("test state file has no parent")?)?; + fs::write(path, contents)?; + } + + prepare_directories(&directory.0, true)?; + assert_eq!( - temporary_stem(&format!(".identity.json-{uuid}.tmp")), - Some(".identity.json") + acl::protected_dacl_of(&identity)?, + Some(acl::sorted(&[full(&system, INHERITED), full(&user, INHERITED)])) ); - assert_eq!(temporary_stem(".identity.json-not-a-uuid.tmp"), None); - assert_eq!(temporary_stem(&format!(".identity.json-{uuid}.backup")), None); - assert_eq!(temporary_stem(&format!(".x-é{}.tmp", "a".repeat(35))), None); + for (path, contents) in &state { + let actual = fs::read(path).with_context(|| format!("read {path} after the repair"))?; + assert_eq!(actual, *contents, "{path}"); + } + Ok(()) + } + + /// The DACL a test gives a pending file the current user owns. + #[derive(Clone, Copy, Debug)] + enum PendingDacl { + /// SYSTEM and the agent's account, as the agent writes it under `__debug__.identity.acl_grant_current_user`. + Provisioned, + /// The same with a zero-access OWNER RIGHTS entry, which only an administrator owner's file carries. + WithZeroOwnerRights, + /// The same with full control for Users. + WithUsers, + } + + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + #[rstest::rstest] + #[case::provisioned(PendingDacl::Provisioned, true)] + #[case::with_zero_owner_rights(PendingDacl::WithZeroOwnerRights, false)] + #[case::with_users(PendingDacl::WithUsers, false)] + fn a_pending_file_owned_by_the_agent_is_read_only_in_the_provisioned_form( + #[case] dacl: PendingDacl, + #[case] readable: bool, + ) -> anyhow::Result<()> { + let directory = TestDir::provisioned()?; + let path = directory.pending_file('a'); + write_atomic(&directory.0, &path, b"blob", true)?; + let user = acl::user_sid()?; + let system = acl::system_sid()?; + let mut entries = vec![full(&system, NO_INHERITANCE.0), full(&user, NO_INHERITANCE.0)]; + match dacl { + PendingDacl::Provisioned => {} + PendingDacl::WithZeroOwnerRights => entries.push((acl::owner_rights_sid()?, 0, NO_INHERITANCE.0)), + PendingDacl::WithUsers => entries.push(full(&Sid::from_well_known(WinBuiltinUsersSid, None)?, 0)), + } + acl::set_owner(&path, &user)?; + acl::set_dacl(&path, &entries)?; + + let read = open_pending_read(&path, true); + + assert_eq!(read.is_ok(), readable, "{dacl:?}: {read:?}"); + Ok(()) + } + + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + #[rstest::rstest] + #[case::with_zero_owner_rights(true, true)] + #[case::without_owner_rights(false, false)] + fn a_pending_file_owned_by_administrators_needs_a_zero_owner_rights_entry( + #[case] owner_rights: bool, + #[case] readable: bool, + ) -> anyhow::Result<()> { + let directory = TestDir::provisioned()?; + let path = directory.pending_file('b'); + write_atomic(&directory.0, &path, b"blob", true)?; + let administrators = Sid::from_well_known(WinBuiltinAdministratorsSid, None)?; + if acl::set_owner(&path, &administrators).is_err() { + writeln!( + io::stderr(), + "SKIP: giving a file another owner needs an elevated token" + )?; + return Ok(()); + } + let mut entries = vec![full(&acl::system_sid()?, NO_INHERITANCE.0)]; + if owner_rights { + entries.push((acl::owner_rights_sid()?, 0, NO_INHERITANCE.0)); + } + acl::set_dacl(&path, &entries)?; + + let read = open_pending_read(&path, false); + + assert_eq!(read.is_ok(), readable, "{read:?}"); + Ok(()) + } + + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + #[test] + fn a_pending_file_the_agent_wrote_without_owner_rights_is_readable() -> anyhow::Result<()> { + let directory = TestDir::provisioned()?; + let path = directory.pending_file('a'); + write_atomic( + &directory.0, + &path, + &crate::windows::protect(b"invalid pending JSON")?, + true, + )?; + + assert!(matches!(pending::read(&path, true)?, pending::PendingRead::Unusable)); + Ok(()) + } + + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + #[test] + fn drop_box_writer_cannot_reopen_its_file_and_replays_succeed() -> anyhow::Result<()> { + use std::os::windows::fs::OpenOptionsExt as _; + + let directory = TestDir::provisioned()?; + let path = directory.pending_file('a'); + let rules = provisioned_by_current_user(); + + submit_into_drop_box(&directory.0, &path, b"test payload", &rules, Instant::now())?; + + assert!(fs::symlink_metadata(&path)?.file_type().is_file()); + assert!(File::open(&path).is_err()); + assert!(fs::OpenOptions::new().write(true).open(&path).is_err()); + assert!(fs::OpenOptions::new().access_mode(WRITE_DAC.0).open(&path).is_err()); + submit_into_drop_box(&directory.0, &path, b"different payload", &rules, Instant::now())?; + assert_eq!(fs::read_dir(directory.pending())?.count(), 1); + Ok(()) + } + + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + #[test] + fn interrupted_staging_does_not_block_a_replayed_enrollment() -> anyhow::Result<()> { + let directory = TestDir::provisioned()?; + let path = directory.pending_file('e'); + let name = path.file_name().context("pending file has no name")?; + let staging = directory.pending().join(format!(".{name}-{}.tmp", Uuid::new_v4())); + fs::write(&staging, b"incomplete DPAPI blob")?; + + submit_into_drop_box( + &directory.0, + &path, + b"complete DPAPI blob", + &provisioned_by_current_user(), + Instant::now(), + )?; + + assert!(fs::symlink_metadata(&path)?.file_type().is_file()); + assert!(fs::symlink_metadata(&staging)?.file_type().is_file()); + Ok(()) + } + + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + #[test] + fn drop_box_denies_a_token_without_administrators_membership() -> anyhow::Result<()> { + if crate::windows::is_administrator_writer()? || crate::windows::is_system()? { + writeln!( + io::stderr(), + "SKIP: the denial applies to a token without Administrators membership" + )?; + return Ok(()); + } + let directory = TestDir::provisioned()?; + let administrators = Sid::from_well_known(WinBuiltinAdministratorsSid, None)?; + // The drop box as the service provisions it, still owned by the current user so that the test can restore it. + acl::set_dacl( + &directory.pending(), + &[ + full(&acl::system_sid()?, INHERITED), + (administrators, FILE_ADD_FILE.0 | SYNCHRONIZE.0, NO_INHERITANCE.0), + ], + )?; + let rules = DropBox { + pending: granted(Rule::new().owner(Account::CurrentUser), true) + .grant(Account::Administrators, Access::AddOnly), + ..provisioned_by_current_user() + }; + let path = directory.pending_file('c'); + + let submitted = submit_into_drop_box(&directory.0, &path, b"test payload", &rules, Instant::now()); + acl::set_dacl( + &directory.pending(), + &[full(&acl::system_sid()?, INHERITED), full(&acl::user_sid()?, INHERITED)], + )?; + + assert!(submitted.is_err()); + assert!(!path.exists()); + Ok(()) + } + + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + #[test] + fn drop_box_refuses_a_link_in_place_of_the_pending_directory_before_writing() -> anyhow::Result<()> { + let directory = TestDir::provisioned()?; + let pending = directory.pending(); + fs::remove_dir(&pending)?; + let redirected = directory.0.join("redirected"); + fs::create_dir(&redirected)?; + if let Err(error) = std::os::windows::fs::symlink_dir(&redirected, &pending) { + if error.kind() == io::ErrorKind::PermissionDenied { + writeln!( + io::stderr(), + "SKIP: creating a directory link needs Developer Mode or an elevated token" + )?; + return Ok(()); + } + return Err(error).context("create test reparse point"); + } + + let error = submit_into_drop_box( + &directory.0, + &directory.pending_file('d'), + b"test payload", + &provisioned_by_current_user(), + Instant::now(), + ) + .expect_err("a redirected drop box accepted a pending enrollment"); + + let refusal = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .context("the failure is not a refusal")?; + assert!( + matches!( + refusal.kind(), + private_fs::ErrorKind::Refused(private_fs::Refusal::Link) + ), + "{error:#}" + ); + assert!(fs::read_dir(&redirected)?.next().is_none()); + fs::remove_dir(&pending)?; + Ok(()) + } + + // oracle: intent devolutions-agent/src/identity/INTENT.md §CLI + #[test] + fn administrator_writer_requires_system_provisioned_directories() -> anyhow::Result<()> { + let directory = TestDir::new()?; + let path = directory.pending_file('f'); + // The test's data directory is the current user's, unlike the directories the service provisions. + let rules = DropBox { + base: tree_rule(true), + ..DropBox::provisioned_by_system() + }; + + let missing = submit_into_drop_box(&directory.0, &path, b"test payload", &rules, Instant::now()) + .expect_err("a missing identity directory was accepted"); + prepare_directories(&directory.0, true)?; + let untrusted = submit_into_drop_box(&directory.0, &path, b"test payload", &rules, Instant::now()) + .expect_err("a directory the current user provisioned was trusted as SYSTEM's"); + + assert_eq!(missing.to_string(), ENROLLMENT_DIRECTORY_NOT_READY); + assert!( + format!("{missing:#}").contains("inspect protected identity directory"), + "{missing:#}" + ); + assert_eq!(untrusted.to_string(), ENROLLMENT_DIRECTORY_NOT_READY); + assert!( + untrusted + .chain() + .filter_map(|cause| cause.downcast_ref::()) + .any(private_fs::Error::is_refused), + "{untrusted:#}" + ); + assert!(!path.exists()); + Ok(()) + } + + // oracle: intent crates/agent-identity/INTENT.md §Local state + // oracle: intent devolutions-agent/src/identity/INTENT.md §CLI + #[test] + fn retries_only_sharing_violations() -> anyhow::Result<()> { + use ::windows::Win32::Foundation::{ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION}; + + let sharing = || { + anyhow::Error::new(io::Error::from_raw_os_error( + i32::try_from(ERROR_SHARING_VIOLATION.0).expect("Win32 error codes fit an i32"), + )) + }; + let mut attempts = 0; + retry_sharing_violation(Instant::now(), || { + attempts += 1; + if attempts == 1 { Err(sharing()) } else { Ok(()) } + })?; + assert_eq!(attempts, 2); + + let mut attempts = 0; + let result: anyhow::Result<()> = retry_sharing_violation(Instant::now(), || { + attempts += 1; + Err(anyhow::Error::new(io::Error::from_raw_os_error(i32::try_from( + ERROR_ACCESS_DENIED.0, + )?))) + }); + assert!(result.is_err()); + assert_eq!(attempts, 1); + + let inaccessible = explain_unprepared_drop_box( + anyhow::Error::new(io::Error::from(io::ErrorKind::PermissionDenied)) + .context("inspect protected pending drop box"), + ); + assert_eq!(inaccessible.to_string(), ENROLLMENT_DIRECTORY_NOT_READY); + assert!( + format!("{inaccessible:#}").contains("inspect protected pending drop box"), + "{inaccessible:#}" + ); + Ok(()) } } diff --git a/crates/agent-identity/src/state.rs b/crates/agent-identity/src/state.rs index 567e1f9e82..cafcfeb501 100644 --- a/crates/agent-identity/src/state.rs +++ b/crates/agent-identity/src/state.rs @@ -1,5 +1,4 @@ use std::collections::HashSet; -use std::fs; use std::io::{ErrorKind, Read as _}; use anyhow::{Context as _, ensure}; @@ -181,7 +180,7 @@ impl Store { } /// Takes the state lock once every stored identity passes [`Store::check_stored_identities`]. - fn lock(&self) -> anyhow::Result { + fn lock(&self) -> anyhow::Result { let lock = state_lock::acquire(&self.data_dir, self.acl_grant_current_user)?; self.check_stored_identities()?; Ok(lock) @@ -856,6 +855,7 @@ fn config_revision(config: &Value) -> anyhow::Result { #[cfg(test)] mod tests { + use std::fs; use std::time::{Duration, UNIX_EPOCH}; use base64::Engine as _; @@ -1492,14 +1492,14 @@ mod tests { #[cfg(windows)] { let lock = state_lock::acquire(&directory.0, true)?; - crate::windows::verify_protected_directory(&lock, true, false)?; + lock.directory().check(&private_file::tree_rule(true))?; } #[cfg(windows)] drop(held); fs::remove_file(&path)?; let lock = state_lock::acquire(&directory.0, true)?; #[cfg(windows)] - crate::windows::verify_protected_directory(&lock, true, false)?; + lock.directory().check(&private_file::tree_rule(true))?; drop(lock); Ok(()) } @@ -1516,9 +1516,9 @@ mod tests { let identity = enroll_locally(&store, &directory.0)?; let path = store.identity_path(identity.authority_id); let authority = path.parent().context("identity has no authority directory")?; - crate::windows::set_test_directory_access(authority, false)?; + crate::windows::test::set_test_directory_access(authority, false)?; let result = store.reconcile(); - crate::windows::set_test_directory_access(authority, true)?; + crate::windows::test::set_test_directory_access(authority, true)?; assert!(result.is_err(), "inaccessible authority directory was silently ignored"); assert!(keys::open(store.key_backend(), &identity.keys.current.key_name)?.is_some()); assert_eq!(store.read_identity(identity.authority_id)?, Some(identity)); @@ -2852,7 +2852,7 @@ mod tests { fs::set_permissions(path, fs::Permissions::from_mode(mode))?; } #[cfg(windows)] - crate::windows::grant_test_directory_access( + crate::windows::test::grant_test_directory_access( path, match others { OtherUsers::Group => ::windows::Win32::Security::WinBuiltinUsersSid, @@ -2873,7 +2873,7 @@ mod tests { #[cfg(windows)] fn protection(path: &Utf8Path) -> anyhow::Result { - crate::windows::test_security(path) + crate::windows::test::test_security(path) } fn untrusted_path(error: &anyhow::Error) -> Option<&Utf8Path> { @@ -2891,7 +2891,7 @@ mod tests { fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; } #[cfg(windows)] - crate::windows::set_test_directory_access(path, true)?; + crate::windows::test::set_test_directory_access(path, true)?; Ok(()) } @@ -2960,7 +2960,7 @@ mod tests { fs::set_permissions(path, fs::Permissions::from_mode(0o640))?; } #[cfg(windows)] - crate::windows::set_test_file_access(path, &[::windows::Win32::Security::WinBuiltinUsersSid])?; + crate::windows::test::set_test_file_access(path, &[::windows::Win32::Security::WinBuiltinUsersSid])?; Ok(()) } @@ -2972,7 +2972,7 @@ mod tests { fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; } #[cfg(windows)] - crate::windows::set_test_file_access(path, &[])?; + crate::windows::test::set_test_file_access(path, &[])?; Ok(()) } @@ -3044,7 +3044,7 @@ mod tests { let identity = enroll_locally(&store, &directory.0)?; let path = store.identity_path(identity.authority_id); // Without SYSTEM, no other user can access the file, but the agent provisions SYSTEM too. - crate::windows::set_test_file_access_for_current_user_only(&path)?; + crate::windows::test::set_test_file_access_for_current_user_only(&path)?; let error = store .read_identity(identity.authority_id) @@ -3201,7 +3201,8 @@ mod tests { let directory = TestDir::new()?; let identity = enroll_locally(&directory.store()?, &directory.0)?; let authority = directory.authority(identity.authority_id); - if crate::windows::set_test_owner(&authority, ::windows::Win32::Security::WinBuiltinAdministratorsSid).is_err() + if crate::windows::test::set_test_owner(&authority, ::windows::Win32::Security::WinBuiltinAdministratorsSid) + .is_err() { // Only an elevated token can give the directory another owner. return Ok(()); @@ -3227,7 +3228,10 @@ mod tests { let directory = TestDir::new()?; drop(directory.store()?); let authorities = directory.0.join("identity").join("authorities"); - crate::windows::grant_test_directory_access(&authorities, ::windows::Win32::Security::WinBuiltinUsersSid)?; + crate::windows::test::grant_test_directory_access( + &authorities, + ::windows::Win32::Security::WinBuiltinUsersSid, + )?; let authority = directory.authority(Uuid::new_v4()); fs::create_dir(&authority)?; let inherited = protection(&authority)?; diff --git a/crates/agent-identity/src/state_lock.rs b/crates/agent-identity/src/state_lock.rs index 54c8da3b8f..66a38ecbdb 100644 --- a/crates/agent-identity/src/state_lock.rs +++ b/crates/agent-identity/src/state_lock.rs @@ -1,46 +1,56 @@ -use std::fs::{File, OpenOptions}; #[cfg(unix)] -use std::io::ErrorKind; +use std::fs::File; use anyhow::Context as _; #[cfg(windows)] use anyhow::ensure; use camino::Utf8Path; +#[cfg(windows)] +use private_fs::Dir; use crate::private_file; -pub(crate) fn acquire(data_dir: &Utf8Path, grant_current_user: bool) -> anyhow::Result { +/// An exclusive lock on the identity state, held until dropped. +pub(crate) struct StateLock { + #[cfg(unix)] + _file: File, + #[cfg(windows)] + _directory: Dir, +} + +pub(crate) fn acquire(data_dir: &Utf8Path, grant_current_user: bool) -> anyhow::Result { #[cfg(unix)] { - private_file::prepare_directories(data_dir, grant_current_user)?; - lock_file(&data_dir.join("identity").join(".agent-identity.lock")) + lock_file(data_dir, grant_current_user).map(|file| StateLock { _file: file }) } #[cfg(windows)] - lock_directory(data_dir, grant_current_user) + lock_directory(data_dir, grant_current_user).map(|directory| StateLock { _directory: directory }) } +/// Locks the identity state through `/identity/.agent-identity.lock`. #[cfg(unix)] -fn lock_file(path: &Utf8Path) -> anyhow::Result { +fn lock_file(data_dir: &Utf8Path, grant_current_user: bool) -> anyhow::Result { + use std::io::ErrorKind; use std::os::fd::AsRawFd as _; - use std::os::unix::fs::OpenOptionsExt as _; - - match private_file::open_read(path) { - Ok(file) => drop(file), - Err(error) - if error - .downcast_ref::() - .is_some_and(|source| source.kind() == ErrorKind::NotFound) => {} - Err(error) => return Err(error), - } - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .mode(0o600) - .custom_flags(libc::O_NOFOLLOW) - .open(path) + + use private_fs::FileOptions; + + let data = private_file::provision_directories(data_dir, grant_current_user)?; + let identity = data + .open_dir( + "identity", + &private_file::tree_rule(grant_current_user), + private_fs::Share::Shared, + ) + .context("open identity directory")? + .context("identity directory disappeared after its creation")?; + let file = identity + .open_or_create_file( + ".agent-identity.lock", + &private_file::state_file_rule(grant_current_user), + FileOptions::new().read(true).write(true), + ) .context("open identity state lock")?; - private_file::verify_open_file(&file)?; loop { // SAFETY: The file descriptor stays open until the caller drops the lock guard. if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } == 0 { @@ -55,61 +65,39 @@ fn lock_file(path: &Utf8Path) -> anyhow::Result { /// Locks the identity state through an unshared handle on `/identity` itself rather than a lock file. /// -/// The lock needs no object of its own: it is the directory the agent provisions and verifies before each lock, opened +/// The lock needs no object of its own: it is the directory the agent provisions and checks before each lock, opened /// without following a reparse point, so no file another account created or replaced can stand in for it. /// Any other handle open on the directory, a backup's or an antivirus scan's for example, also fails the open with a /// sharing violation, so a busy lock is retried for 30 s. #[cfg(windows)] -fn lock_directory(data_dir: &Utf8Path, grant_current_user: bool) -> anyhow::Result { - use std::os::windows::fs::OpenOptionsExt as _; - use std::time::Duration; +fn lock_directory(data_dir: &Utf8Path, grant_current_user: bool) -> anyhow::Result { + use std::time::{Duration, Instant}; use windows::Win32::Foundation::ERROR_SHARING_VIOLATION; - use windows::Win32::Storage::FileSystem::{ - FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, - READ_CONTROL, - }; - - let started = std::time::Instant::now(); - let mut options = OpenOptions::new(); - options - .read(true) - .access_mode((FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | READ_CONTROL).0) - .share_mode(0) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0 | FILE_FLAG_OPEN_REPARSE_POINT.0); + + let started = Instant::now(); let sharing_violation = i32::try_from(ERROR_SHARING_VIOLATION.0)?; loop { - match private_file::prepare_directories(data_dir, grant_current_user) { - Ok(()) => {} + let locked = private_file::provision_directories(data_dir, grant_current_user).and_then(|data| { + data.open_dir( + "identity", + &private_file::tree_rule(grant_current_user), + private_fs::Share::Exclusive, + ) + .context("open identity state lock") + }); + match locked { + Ok(Some(directory)) => return Ok(directory), + Ok(None) => anyhow::bail!("identity directory disappeared after its creation"), Err(error) if is_sharing_violation(&error, sharing_violation) => { ensure!( started.elapsed() < Duration::from_secs(30), "identity state lock remained busy" ); std::thread::sleep(Duration::from_millis(25)); - continue; } Err(error) => return Err(error), } - let identity = data_dir.join("identity"); - match options.open(&identity) { - Ok(directory) => { - crate::windows::verify_protected_directory(&directory, grant_current_user, false)?; - let obsolete = identity.join(".agent-identity.lock"); - if std::fs::symlink_metadata(&obsolete).is_ok() { - let _ = private_file::verify_state_path(&obsolete); - } - return Ok(directory); - } - Err(error) if error.raw_os_error() == Some(sharing_violation) => { - ensure!( - started.elapsed() < Duration::from_secs(30), - "identity state lock remained busy" - ); - std::thread::sleep(Duration::from_millis(25)); - } - Err(error) => return Err(error).context("open identity state lock"), - } } } @@ -121,20 +109,29 @@ fn is_sharing_violation(error: &anyhow::Error, code: i32) -> bool { .any(|error| error.raw_os_error() == Some(code)) } +#[cfg(all(test, windows))] +impl StateLock { + /// The unshared handle on `/identity` that holds the lock. + pub(crate) fn directory(&self) -> &Dir { + &self._directory + } +} + #[cfg(all(test, windows))] mod tests { use std::fs; use std::time::Duration; + use camino::Utf8PathBuf; + use super::*; // oracle: intent crates/agent-identity/INTENT.md §Local state #[test] fn trusted_lock_serializes_concurrent_writers() -> anyhow::Result<()> { - let data_dir = Utf8Path::new(env!("CARGO_MANIFEST_DIR")) - .join("target") - .join("lock-tests") - .join(uuid::Uuid::new_v4().to_string()); + let data_dir = Utf8PathBuf::from_path_buf(std::env::temp_dir()) + .map_err(|_| anyhow::anyhow!("the temporary directory path is not UTF-8"))? + .join(format!("ai-{}", uuid::Uuid::new_v4().simple())); fs::create_dir_all(&data_dir)?; let lock = acquire(&data_dir, true)?; let (sender, receiver) = std::sync::mpsc::channel(); diff --git a/crates/agent-identity/src/windows.rs b/crates/agent-identity/src/windows.rs index de128284ba..de7e8a1dc6 100644 --- a/crates/agent-identity/src/windows.rs +++ b/crates/agent-identity/src/windows.rs @@ -1,51 +1,15 @@ -use std::fs::{self, File}; -use std::io::{ErrorKind, Write as _}; -use std::os::windows::ffi::OsStrExt as _; -use std::os::windows::fs::MetadataExt as _; -use std::time::{Duration, Instant}; - use anyhow::{Context as _, ensure}; -use camino::Utf8Path; -use win_api_wrappers::identity::sid::{Sid, StringSid}; -use win_api_wrappers::process::Process; -use win_api_wrappers::raw::Win32::Security::Authorization::GRANT_ACCESS; -use win_api_wrappers::raw::Win32::Storage::FileSystem::FILE_ALL_ACCESS; -use win_api_wrappers::security::acl::{Acl, ExplicitAccess, InheritableAcl, InheritableAclKind, Trustee}; -use win_api_wrappers::security::attributes::SecurityAttributesInit; -use win_api_wrappers::security::descriptor::{DaclError, SecurityDescriptor}; -use win_api_wrappers::security::privilege; -use win_api_wrappers::str::U16CString; +use win_api_wrappers::identity::sid::Sid; use win_api_wrappers::token::Token; -use windows::Win32::Foundation::{ - ERROR_ACCESS_DENIED, ERROR_ALREADY_EXISTS, ERROR_FILE_EXISTS, ERROR_SHARING_VIOLATION, GENERIC_READ, GENERIC_WRITE, - HANDLE, HLOCAL, LUID, LocalFree, -}; -use windows::Win32::Security::Authorization::{ - AUTHZ_CLIENT_CONTEXT_HANDLE, AUTHZ_RESOURCE_MANAGER_HANDLE, AUTHZ_RM_FLAG_NO_AUDIT, AuthzContextInfoGroupsSids, - AuthzFreeContext, AuthzFreeResourceManager, AuthzGetInformationFromContext, AuthzInitializeContextFromSid, - AuthzInitializeResourceManager, SE_FILE_OBJECT, -}; +use windows::Win32::Foundation::{HLOCAL, LocalFree}; +use windows::Win32::Security; use windows::Win32::Security::Cryptography::{ CRYPT_INTEGER_BLOB, CRYPTPROTECT_LOCAL_MACHINE, CRYPTPROTECT_UI_FORBIDDEN, CryptProtectData, CryptUnprotectData, }; -use windows::Win32::Security::{ - self, DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, SecurityImpersonation, - SetKernelObjectSecurity, TOKEN_ADJUST_PRIVILEGES, TOKEN_DUPLICATE, TOKEN_IMPERSONATE, TOKEN_QUERY, -}; -use windows::Win32::Storage::FileSystem::{ - CREATE_NEW, CreateFileW, DELETE, FILE_ADD_FILE, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, - FILE_DISPOSITION_INFO, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, - FILE_NAME_NORMALIZED, FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_RENAME_INFO_0, FILE_SHARE_DELETE, - FILE_SHARE_NONE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_WRITE_DATA, FileDispositionInfo, FileIdInfo, - FileRenameInfo, GetFileInformationByHandleEx, GetFinalPathNameByHandleW, GetVolumeInformationByHandleW, - MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, READ_CONTROL, SYNCHRONIZE, - SetFileInformationByHandle, WRITE_DAC, WRITE_OWNER, -}; use windows::core::{BOOL, PCWSTR}; use zeroize::{Zeroize as _, Zeroizing}; const ENTROPY: &[u8] = b"Devolutions.Agent.PendingEnrollment.v1"; -const ENROLLMENT_DIRECTORY_NOT_READY: &str = "the Devolutions Agent service has not prepared the enrollment directory yet; start the Devolutions Agent service once, then retry"; fn user_sid() -> anyhow::Result { Ok(Token::current_process_token() @@ -58,76 +22,6 @@ fn system_sid() -> anyhow::Result { Sid::from_well_known(Security::WinLocalSystemSid, None).context("get SYSTEM SID") } -fn administrators_sid() -> anyhow::Result { - Sid::from_well_known(Security::WinBuiltinAdministratorsSid, None).context("get Administrators SID") -} - -fn owner_rights_sid() -> anyhow::Result { - StringSid::from_str("OW")?.to_sid().context("get OWNER RIGHTS SID") -} - -fn allowed_principals(system: &Sid, user: &Sid, grant_current_user: bool) -> Vec { - let mut principals = vec![system.clone()]; - if grant_current_user && user != system { - principals.push(user.clone()); - } - principals -} - -fn directory_aces( - system: &Sid, - user: &Sid, - administrators: &Sid, - grant_current_user: bool, - pending_drop_box: bool, -) -> Vec<(Sid, u32, u32)> { - let mut entries = expected_aces( - &allowed_principals(system, user, grant_current_user), - Security::CONTAINER_INHERIT_ACE | Security::OBJECT_INHERIT_ACE, - ); - if pending_drop_box { - entries.push(( - administrators.clone(), - FILE_ADD_FILE.0 | SYNCHRONIZE.0, - Security::NO_INHERITANCE.0, - )); - } - entries -} - -fn pending_base_aces(system: &Sid, user: &Sid, grant_current_user: bool) -> Vec<(Sid, u32, u32)> { - expected_aces( - &allowed_principals(system, user, grant_current_user), - Security::NO_INHERITANCE, - ) -} - -fn pending_file_aces(system: &Sid, user: &Sid, owner_rights: &Sid, grant_current_user: bool) -> Vec<(Sid, u32, u32)> { - let mut entries = pending_base_aces(system, user, grant_current_user); - entries.push((owner_rights.clone(), 0, Security::NO_INHERITANCE.0)); - entries -} - -fn pending_aces_are_allowed( - actual: &[(Sid, u32, u32)], - system: &Sid, - user: &Sid, - owner_rights: &Sid, - grant_current_user: bool, - administrator_owner: bool, -) -> bool { - let mut actual = actual.to_vec(); - actual.sort(); - let mut base = pending_base_aces(system, user, grant_current_user); - base.sort(); - if !administrator_owner && actual == base { - return true; - } - base.push((owner_rights.clone(), 0, Security::NO_INHERITANCE.0)); - base.sort(); - actual == base -} - pub(crate) fn is_system() -> anyhow::Result { Ok(user_sid()? == system_sid()?) } @@ -137,7 +31,8 @@ pub(crate) fn is_administrator_writer() -> anyhow::Result { } fn current_user_is_administrator() -> anyhow::Result { - let administrators = administrators_sid()?; + let administrators = + Sid::from_well_known(Security::WinBuiltinAdministratorsSid, None).context("get Administrators SID")?; let mut is_member = BOOL::default(); // SAFETY: A null token selects the effective current token and the output pointer is writable. unsafe { Security::CheckTokenMembership(None, administrators.as_psid_const(), &mut is_member) } @@ -145,1016 +40,6 @@ fn current_user_is_administrator() -> anyhow::Result { Ok(is_member.as_bool()) } -pub(crate) fn is_reparse_point(metadata: &fs::Metadata) -> bool { - metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 -} - -pub(crate) fn create_protected_directory( - path: &Utf8Path, - grant_current_user: bool, - pending_drop_box: bool, -) -> anyhow::Result<()> { - use std::os::windows::fs::OpenOptionsExt as _; - use std::os::windows::io::AsRawHandle as _; - - let system = system_sid()?; - let user = user_sid()?; - ensure!( - grant_current_user || user == system, - "identity directory must be created by SYSTEM" - ); - let expected = directory_aces( - &system, - &user, - &administrators_sid()?, - grant_current_user, - pending_drop_box, - ); - create_directory_if_missing(path, &user, &expected)?; - let mut options = fs::OpenOptions::new(); - options - .access_mode((FILE_READ_ATTRIBUTES | READ_CONTROL | WRITE_DAC).0) - .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE).0) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0 | FILE_FLAG_OPEN_REPARSE_POINT.0); - let mut directory = options.open(path)?; - let metadata = directory.metadata()?; - ensure!( - metadata.file_type().is_dir() && !is_reparse_point(&metadata), - "identity directory is not a regular directory" - ); - ensure_acl_volume(&directory)?; - let (owner, descriptor) = owner_and_dacl(&directory)?; - let owner_is_trusted = owner == system || owner == user; - if !owner_is_trusted || verify_dacl(descriptor.as_raw(), expected.clone()).is_err() { - // A foreign owner could widen its own DACL again, so repair ownership and access together. - if !owner_is_trusted { - let original_id = file_id_info(&directory)?; - options.access_mode((FILE_READ_ATTRIBUTES | READ_CONTROL | WRITE_DAC | WRITE_OWNER).0); - let reopened = options - .open(path) - .context("open identity directory for ownership repair")?; - let metadata = reopened.metadata()?; - ensure!( - metadata.file_type().is_dir() && !is_reparse_point(&metadata), - "identity directory changed during ownership repair" - ); - let reopened_id = file_id_info(&reopened)?; - ensure!( - original_id.VolumeSerialNumber == reopened_id.VolumeSerialNumber - && original_id.FileId.Identifier == reopened_id.FileId.Identifier, - "identity directory changed during ownership repair" - ); - directory = reopened; - } - let attributes = SecurityAttributesInit { - owner: (!owner_is_trusted).then(|| user.clone()), - dacl: Some(protected_dacl(&expected)?), - ..Default::default() - } - .init(); - // SAFETY: `attributes` owns the security attributes it points to. - let descriptor = PSECURITY_DESCRIPTOR(unsafe { (*attributes.as_ptr()).lpSecurityDescriptor }); - let mut security_info = DACL_SECURITY_INFORMATION; - if !owner_is_trusted { - security_info |= OWNER_SECURITY_INFORMATION; - } - // Unlike SetSecurityInfo, SetKernelObjectSecurity doesn't propagate inheritable entries to existing children, so - // repairing this directory never changes an untrusted authority directory under it. - // SAFETY: The directory handle and the protected security descriptor remain live for the call. - unsafe { SetKernelObjectSecurity(HANDLE(directory.as_raw_handle()), security_info, descriptor) } - .context("protect identity directory")?; - let (repaired_owner, repaired) = owner_and_dacl(&directory)?; - ensure!( - repaired_owner == system || repaired_owner == user, - "identity directory owner is not trusted" - ); - verify_dacl(repaired.as_raw(), expected)?; - } - Ok(()) -} - -pub(crate) fn verify_protected_directory( - file: &File, - grant_current_user: bool, - pending_drop_box: bool, -) -> anyhow::Result<()> { - let metadata = file.metadata()?; - ensure!( - metadata.file_type().is_dir() && !is_reparse_point(&metadata), - "identity directory is not a regular directory" - ); - ensure_acl_volume(file)?; - let system = system_sid()?; - let user = user_sid()?; - let (owner, descriptor) = owner_and_dacl(file)?; - ensure!( - owner == system || owner == user, - "identity directory owner is not trusted" - ); - verify_dacl( - descriptor.as_raw(), - directory_aces( - &system, - &user, - &administrators_sid()?, - grant_current_user, - pending_drop_box, - ), - ) -} - -/// Creates `path` with the protected DACL the agent provisions, unless it exists, in which case it is left unchanged. -pub(crate) fn create_missing_directory(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result<()> { - let system = system_sid()?; - let user = user_sid()?; - ensure!( - grant_current_user || user == system, - "identity directory must be created by SYSTEM" - ); - create_directory_if_missing( - path, - &user, - &directory_aces(&system, &user, &administrators_sid()?, grant_current_user, false), - ) -} - -fn create_directory_if_missing(path: &Utf8Path, owner: &Sid, entries: &[(Sid, u32, u32)]) -> anyhow::Result<()> { - if let Err(error) = fs::symlink_metadata(path) - && error.kind() == ErrorKind::NotFound - { - let attributes = SecurityAttributesInit { - owner: Some(owner.clone()), - dacl: Some(protected_dacl(entries)?), - ..Default::default() - } - .init(); - let parent = path.parent().context("identity directory has no parent")?; - let name = path.file_name().context("identity directory has no name")?; - let long_path = fs::canonicalize(parent)?.join(name); - if let Err(error) = win_api_wrappers::fs::create_directory(&long_path, Some(&attributes)) - && fs::symlink_metadata(path).is_err() - { - return Err(error).context("create protected identity directory"); - } - } - Ok(()) -} - -/// Checks, without changing it, that the directory at `path` is the protected directory the agent provisions. -pub(crate) fn verify_protected_directory_path(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result<()> { - use std::os::windows::fs::OpenOptionsExt as _; - - let directory = fs::OpenOptions::new() - .access_mode((FILE_READ_ATTRIBUTES | READ_CONTROL).0) - .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0 | FILE_FLAG_OPEN_REPARSE_POINT.0) - .open(path) - .context("open identity directory")?; - verify_protected_directory(&directory, grant_current_user, false) -} - -pub(crate) fn create_private_file( - path: &Utf8Path, - grant_current_user: bool, - pending_blob: bool, -) -> anyhow::Result { - let system = system_sid()?; - let user = user_sid()?; - ensure!( - grant_current_user || user == system, - "pending enrollment file must be written by SYSTEM" - ); - let expected = if pending_blob { - pending_base_aces(&system, &user, grant_current_user) - } else { - expected_aces( - &allowed_principals(&system, &user, grant_current_user), - Security::NO_INHERITANCE, - ) - }; - let create_dacl = protected_dacl(&expected)?; - let attributes = SecurityAttributesInit { - owner: Some(user.clone()), - dacl: Some(create_dacl), - ..Default::default() - } - .init(); - let parent = path.parent().context("identity file has no parent directory")?; - let name = path.file_name().context("identity file has no name")?; - let long_path = fs::canonicalize(parent)?.join(name); - use std::os::windows::io::FromRawHandle as _; - - let wide: Vec<_> = long_path.as_os_str().encode_wide().chain(Some(0)).collect(); - // SAFETY: The path and protected security attributes remain live until CreateFileW returns. - let handle = unsafe { - CreateFileW( - PCWSTR::from_raw(wide.as_ptr()), - GENERIC_READ.0 | GENERIC_WRITE.0 | WRITE_DAC.0 | DELETE.0, - FILE_SHARE_DELETE, - Some(attributes.as_ptr()), - CREATE_NEW, - FILE_ATTRIBUTE_NORMAL, - None, - ) - } - .context("create protected identity file")?; - // SAFETY: CreateFileW returned an owned handle that File now closes. - let file = unsafe { File::from_raw_handle(handle.0) }; - ensure_acl_volume(&file)?; - - require_protected_dacl(&file, &user, expected)?; - Ok(file) -} - -pub(crate) fn submit_pending_as_administrator( - data_dir: &Utf8Path, - path: &Utf8Path, - contents: &[u8], -) -> anyhow::Result<()> { - let started = Instant::now(); - verify_system_provisioned_drop_box(data_dir, started)?; - submit_pending_into_drop_box(data_dir, path, contents, started) -} - -fn retry_sharing_violation(started: Instant, mut operation: impl FnMut() -> anyhow::Result) -> anyhow::Result { - loop { - match operation() { - Err(error) if is_sharing_violation(&error) => { - let remaining = Duration::from_secs(10).saturating_sub(started.elapsed()); - if remaining.is_zero() { - return Err(error); - } - std::thread::sleep(remaining.min(Duration::from_millis(100))); - } - result => return result, - } - } -} - -fn is_sharing_violation(error: &anyhow::Error) -> bool { - error.chain().any(|cause| { - cause.downcast_ref::().is_some_and(|source| { - source.raw_os_error().and_then(|code| u32::try_from(code).ok()) == Some(ERROR_SHARING_VIOLATION.0) - }) || cause - .downcast_ref::() - .is_some_and(|source| source.code() == ERROR_SHARING_VIOLATION.to_hresult()) - }) -} - -fn explain_unprepared_drop_box(error: anyhow::Error) -> anyhow::Error { - if error - .chain() - .filter_map(|cause| cause.downcast_ref::()) - .any(|source| matches!(source.kind(), ErrorKind::NotFound | ErrorKind::PermissionDenied)) - { - error.context(ENROLLMENT_DIRECTORY_NOT_READY) - } else { - error - } -} - -fn verify_drop_box_directory(directory: &File, pending_drop_box: bool) -> anyhow::Result<()> { - verify_protected_directory(directory, false, pending_drop_box)?; - let (owner, _) = owner_and_dacl(directory)?; - ensure!(owner == system_sid()?, "identity drop box is not SYSTEM-provisioned"); - Ok(()) -} - -fn verify_system_provisioned_drop_box(data_dir: &Utf8Path, started: Instant) -> anyhow::Result<()> { - use std::os::windows::fs::OpenOptionsExt as _; - - let metadata = fs::symlink_metadata(data_dir) - .context("inspect identity data directory") - .map_err(explain_unprepared_drop_box)?; - ensure!( - metadata.file_type().is_dir() && !is_reparse_point(&metadata), - "identity data directory is not a regular directory" - ); - let mut token = Process::current_process() - .token(TOKEN_DUPLICATE | TOKEN_QUERY) - .context("open administrator token")? - .duplicate( - TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY | TOKEN_IMPERSONATE, - None, - SecurityImpersonation, - Security::TokenImpersonation, - ) - .context("duplicate administrator token")?; - token - .enable_privilege(privilege::SE_BACKUP_NAME) - .context("enable administrator backup privilege")?; - let _impersonation = token - .impersonate() - .context("impersonate administrator for ACL inspection")?; - - let mut options = fs::OpenOptions::new(); - options - .read(true) - .access_mode((FILE_READ_ATTRIBUTES | READ_CONTROL).0) - .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE).0) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0 | FILE_FLAG_OPEN_REPARSE_POINT.0); - let base = fs::canonicalize(data_dir)?; - let data_directory = retry_sharing_violation(started, || { - options.open(&base).context("inspect identity data directory") - })?; - ensure_acl_volume(&data_directory)?; - let identity_path = base.join("identity"); - let identity = retry_sharing_violation(started, || { - options - .open(&identity_path) - .context("inspect protected identity directory") - }) - .map_err(explain_unprepared_drop_box)?; - verify_drop_box_directory(&identity, false).context(ENROLLMENT_DIRECTORY_NOT_READY)?; - let pending_path = identity_path.join("pending"); - let pending = retry_sharing_violation(started, || { - options - .open(&pending_path) - .context("inspect protected pending drop box") - }) - .map_err(explain_unprepared_drop_box)?; - verify_drop_box_directory(&pending, true).context(ENROLLMENT_DIRECTORY_NOT_READY)?; - for directory in [&identity, &pending] { - ensure!( - volume_serial(directory)? == volume_serial(&data_directory)?, - "identity drop box resolves to another volume" - ); - } - Ok(()) -} - -fn submit_pending_into_drop_box( - data_dir: &Utf8Path, - path: &Utf8Path, - contents: &[u8], - started: Instant, -) -> anyhow::Result<()> { - use std::os::windows::fs::OpenOptionsExt as _; - use std::os::windows::io::AsRawHandle as _; - - let metadata = fs::symlink_metadata(data_dir)?; - ensure!( - metadata.file_type().is_dir() && !is_reparse_point(&metadata), - "identity data directory is not a regular directory" - ); - let mut options = fs::OpenOptions::new(); - options - .read(true) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0 | FILE_FLAG_OPEN_REPARSE_POINT.0); - let data_directory = retry_sharing_violation(started, || options.open(data_dir).map_err(Into::into))?; - ensure_acl_volume(&data_directory)?; - let pending_dir = data_dir.join("identity").join("pending"); - ensure!( - path.parent() == Some(pending_dir.as_path()), - "pending file is outside the drop box" - ); - let name = path.file_name().context("pending file has no name")?; - let long_dir = fs::canonicalize(data_dir)?.join("identity").join("pending"); - let (mut file, staging) = create_pending_staging(&long_dir, name, started)?; - let published = (|| -> anyhow::Result { - ensure_acl_volume(&file)?; - verify_staging_destination(&data_directory, &file, &staging)?; - file.write_all(contents).context("write pending enrollment")?; - file.sync_all().context("sync pending enrollment")?; - let published = publish_staged_pending(&file, &long_dir.join(name))?; - if published { - file.sync_all().context("sync published pending enrollment")?; - } - Ok(published) - })(); - if !matches!(published, Ok(true)) { - let discard = FILE_DISPOSITION_INFO { DeleteFile: true }; - // SAFETY: The handle has DELETE access and the disposition structure stays live for the call. - let _ = unsafe { - SetFileInformationByHandle( - HANDLE(file.as_raw_handle()), - FileDispositionInfo, - (&raw const discard).cast(), - u32::try_from(size_of::())?, - ) - }; - } - published.map(|_| ()) -} - -fn create_pending_staging( - long_dir: &std::path::Path, - name: &str, - started: Instant, -) -> anyhow::Result<(File, std::path::PathBuf)> { - use std::os::windows::io::FromRawHandle as _; - - let staging = long_dir.join(format!(".{name}-{}.tmp", uuid::Uuid::new_v4())); - let wide: Vec<_> = staging.as_os_str().encode_wide().chain(Some(0)).collect(); - - let system = system_sid()?; - let user = user_sid()?; - let attributes = SecurityAttributesInit { - owner: Some(user.clone()), - dacl: Some(protected_dacl(&pending_file_aces( - &system, - &user, - &owner_rights_sid()?, - false, - ))?), - ..Default::default() - } - .init(); - let handle = retry_sharing_violation(started, || { - // SAFETY: The path and protected security attributes remain live until CreateFileW returns. - unsafe { - CreateFileW( - PCWSTR::from_raw(wide.as_ptr()), - FILE_WRITE_DATA.0 | DELETE.0 | SYNCHRONIZE.0, - FILE_SHARE_NONE, - Some(attributes.as_ptr()), - CREATE_NEW, - FILE_ATTRIBUTE_NORMAL, - None, - ) - } - .map_err(anyhow::Error::new) - }) - .context("submit pending enrollment")?; - // SAFETY: CreateFileW returned an owned handle transferred to File for closing. - let file = unsafe { File::from_raw_handle(handle.0) }; - Ok((file, staging)) -} - -fn verify_staging_destination(data_directory: &File, staged: &File, expected: &std::path::Path) -> anyhow::Result<()> { - use std::os::windows::io::AsRawHandle as _; - - ensure!( - volume_serial(data_directory)? == volume_serial(staged)?, - "pending drop box resolves to another volume" - ); - - let mut name = vec![0u16; 512]; - loop { - // SAFETY: The staged file handle remains open and the UTF-16 output buffer is writable. - let length = - unsafe { GetFinalPathNameByHandleW(HANDLE(staged.as_raw_handle()), &mut name, FILE_NAME_NORMALIZED) }; - ensure!(length != 0, "inspect pending destination failed"); - let length = usize::try_from(length)?; - if length < name.len() { - let actual = String::from_utf16(&name[..length]).context("pending destination is not UTF-16")?; - ensure!( - expected.as_os_str().to_string_lossy().eq_ignore_ascii_case(&actual), - "pending drop box resolves outside the data directory" - ); - return Ok(()); - } - name.resize(length + 1, 0); - } -} - -fn volume_serial(file: &File) -> anyhow::Result { - Ok(file_id_info(file)?.VolumeSerialNumber) -} - -fn file_id_info(file: &File) -> anyhow::Result { - use std::os::windows::io::AsRawHandle as _; - - let mut info = FILE_ID_INFO::default(); - // SAFETY: The live file handle and exact-size writable FILE_ID_INFO buffer are valid. - unsafe { - GetFileInformationByHandleEx( - HANDLE(file.as_raw_handle()), - FileIdInfo, - (&raw mut info).cast(), - u32::try_from(size_of::())?, - ) - } - .context("inspect file identity")?; - Ok(info) -} - -#[expect( - clippy::multiple_unsafe_ops_per_block, - reason = "initializing one bounded variable-length FILE_RENAME_INFO is one logical operation" -)] -fn publish_staged_pending(file: &File, destination: &std::path::Path) -> anyhow::Result { - use std::os::windows::io::AsRawHandle as _; - - let name: Vec = destination.as_os_str().encode_wide().collect(); - let name_bytes = name - .len() - .checked_mul(size_of::()) - .context("pending filename is too long")?; - let name_offset = std::mem::offset_of!(FILE_RENAME_INFO, FileName); - let size = size_of::() - .checked_add(name_bytes) - .context("pending rename information is too large")?; - let mut buffer = vec![0usize; size.div_ceil(size_of::())]; - let info = buffer.as_mut_ptr().cast::(); - // SAFETY: The aligned buffer fits the rename structure and its complete UTF-16 filename. - unsafe { - (*info).Anonymous = FILE_RENAME_INFO_0 { Flags: 0 }; - (*info).RootDirectory = HANDLE::default(); - (*info).FileNameLength = u32::try_from(name_bytes)?; - std::ptr::copy_nonoverlapping( - name.as_ptr(), - buffer.as_mut_ptr().cast::().add(name_offset).cast(), - name.len(), - ); - } - // SAFETY: The file and directory handles and initialized rename buffer remain live for the call. - match unsafe { - SetFileInformationByHandle( - HANDLE(file.as_raw_handle()), - FileRenameInfo, - buffer.as_ptr().cast(), - u32::try_from(size)?, - ) - } { - Ok(()) => Ok(true), - Err(error) - if error.code() == ERROR_FILE_EXISTS.to_hresult() || error.code() == ERROR_ALREADY_EXISTS.to_hresult() => - { - Ok(false) - } - Err(error) => Err(error).context("publish pending enrollment"), - } -} - -pub(crate) fn verify_installed_file(file: &File, grant_current_user: bool, pending_blob: bool) -> anyhow::Result<()> { - let system = system_sid()?; - let user = user_sid()?; - let expected = if pending_blob { - pending_base_aces(&system, &user, grant_current_user) - } else { - expected_aces( - &allowed_principals(&system, &user, grant_current_user), - Security::NO_INHERITANCE, - ) - }; - require_protected_dacl(file, &user, expected) -} - -pub(crate) fn verify_pending_read(file: &File, grant_current_user: bool) -> anyhow::Result<()> { - let metadata = file.metadata()?; - ensure!( - metadata.file_type().is_file() && !is_reparse_point(&metadata), - "pending enrollment path is not a regular file" - ); - ensure_acl_volume(file)?; - let (owner, descriptor) = owner_and_dacl(file)?; - verify_pending_owner_and_dacl(&owner, descriptor.as_raw(), grant_current_user) -} - -pub(crate) fn verify_pending_path(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result<()> { - let metadata = fs::symlink_metadata(path)?; - ensure!( - metadata.file_type().is_file() && !is_reparse_point(&metadata), - "pending enrollment path is not a regular file" - ); - let (owner, descriptor) = named_owner_and_dacl(path)?; - verify_pending_owner_and_dacl(&owner, descriptor.as_raw(), grant_current_user) -} - -fn verify_pending_owner_and_dacl( - owner: &Sid, - descriptor: PSECURITY_DESCRIPTOR, - grant_current_user: bool, -) -> anyhow::Result<()> { - let system = system_sid()?; - let user = user_sid()?; - let owner_rights = owner_rights_sid()?; - let administrator_owner = owner != &system && owner != &user && owner_is_administrator_member(owner)?; - ensure!( - pending_owner_is_allowed(&system, &user, owner, administrator_owner), - "pending enrollment file owner is not trusted" - ); - ensure!( - pending_aces_are_allowed( - &read_dacl(descriptor)?, - &system, - &user, - &owner_rights, - grant_current_user, - administrator_owner - ), - "pending enrollment DACL grants unexpected access" - ); - Ok(()) -} - -fn pending_owner_is_allowed(system: &Sid, user: &Sid, owner: &Sid, administrator_owner: bool) -> bool { - owner == system || owner == user || administrator_owner -} - -pub(crate) fn verify_trusted_state_file(file: &File) -> anyhow::Result<()> { - ensure_acl_volume(file)?; - let (owner, descriptor) = owner_and_dacl(file)?; - verify_trusted_owner_and_dacl(&owner, descriptor.as_raw()) -} - -/// Checks, without changing it, that an open `identity.json` has the owner and the protected DACL the agent provisions. -/// -/// UNTESTED: an `identity.json` owned by another account fails this check, because that owner can always rewrite its -/// DACL. -/// Only an elevated token can give a test file another owner; when changing this, keep the owner in the check. -pub(crate) fn verify_identity_file(file: &File, grant_current_user: bool) -> anyhow::Result<()> { - ensure_acl_volume(file)?; - verify_installed_file(file, grant_current_user, false) -} - -pub(crate) fn verify_trusted_state_path(path: &Utf8Path) -> anyhow::Result<()> { - let metadata = fs::symlink_metadata(path)?; - ensure!( - metadata.file_type().is_file() && !is_reparse_point(&metadata), - "identity state path is not a regular file" - ); - let (owner, descriptor) = named_owner_and_dacl(path)?; - verify_trusted_owner_and_dacl(&owner, descriptor.as_raw()) -} - -fn verify_trusted_owner_and_dacl(owner: &Sid, descriptor: PSECURITY_DESCRIPTOR) -> anyhow::Result<()> { - let system = system_sid()?; - let user = user_sid()?; - let owner_rights = owner_rights_sid()?; - ensure!( - owner == &system || owner == &user, - "identity state file owner is not trusted" - ); - ensure!( - read_dacl(descriptor)?.iter().all(|(sid, mask, flags)| { - *flags == Security::NO_INHERITANCE.0 - && (sid == &system || sid == &user || sid == &owner_rights && *mask == 0) - }), - "identity state file DACL grants access to another principal" - ); - Ok(()) -} - -fn ensure_acl_volume(file: &File) -> anyhow::Result<()> { - use std::os::windows::io::AsRawHandle as _; - - use win_api_wrappers::raw::Win32::System::SystemServices::FILE_PERSISTENT_ACLS; - - let mut flags = 0; - // SAFETY: The handle is live, and the volume-flags output pointer is writable. - unsafe { GetVolumeInformationByHandleW(HANDLE(file.as_raw_handle()), None, None, None, Some(&mut flags), None) } - .context("inspect identity file volume")?; - ensure!( - flags & FILE_PERSISTENT_ACLS != 0, - "identity file volume does not enforce ACLs" - ); - Ok(()) -} - -#[cfg(test)] -pub(crate) fn assert_pending_protection(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result<()> { - let file = crate::private_file::open_pending_read(path, grant_current_user)?; - verify_installed_file(&file, grant_current_user, true) -} - -#[cfg(test)] -pub(crate) fn set_test_directory_access(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result<()> { - set_test_dacl( - path, - &directory_aces( - &system_sid()?, - &user_sid()?, - &administrators_sid()?, - grant_current_user, - false, - ), - ) -} - -/// Gives `other` inherited full control of a test directory, besides SYSTEM and the current user. -#[cfg(test)] -pub(crate) fn grant_test_directory_access(path: &Utf8Path, other: Security::WELL_KNOWN_SID_TYPE) -> anyhow::Result<()> { - let mut entries = directory_aces(&system_sid()?, &user_sid()?, &administrators_sid()?, true, false); - entries.push(( - Sid::from_well_known(other, None)?, - FILE_ALL_ACCESS.0, - (Security::CONTAINER_INHERIT_ACE | Security::OBJECT_INHERIT_ACE).0, - )); - set_test_dacl(path, &entries) -} - -/// Gives a test file the protected DACL the agent provisions for its state files, plus full control for `others`. -#[cfg(test)] -pub(crate) fn set_test_file_access(path: &Utf8Path, others: &[Security::WELL_KNOWN_SID_TYPE]) -> anyhow::Result<()> { - let mut entries = expected_aces( - &allowed_principals(&system_sid()?, &user_sid()?, true), - Security::NO_INHERITANCE, - ); - for other in others { - entries.push(( - Sid::from_well_known(*other, None)?, - FILE_ALL_ACCESS.0, - Security::NO_INHERITANCE.0, - )); - } - set_test_dacl(path, &entries) -} - -/// Gives a test file a protected DACL that grants full control to the current user only. -#[cfg(test)] -pub(crate) fn set_test_file_access_for_current_user_only(path: &Utf8Path) -> anyhow::Result<()> { - set_test_dacl(path, &expected_aces(&[user_sid()?], Security::NO_INHERITANCE)) -} - -#[cfg(test)] -fn set_test_dacl(path: &Utf8Path, entries: &[(Sid, u32, u32)]) -> anyhow::Result<()> { - use win_api_wrappers::security::acl::set_named_security_info; - - set_named_security_info( - &test_wide_path(path)?, - SE_FILE_OBJECT, - None, - None, - Some(&protected_dacl(entries)?), - None, - ) -} - -/// Makes `owner` the owner of a test path, which takes an elevated token for any owner but the current user. -#[cfg(test)] -pub(crate) fn set_test_owner(path: &Utf8Path, owner: Security::WELL_KNOWN_SID_TYPE) -> anyhow::Result<()> { - use win_api_wrappers::security::acl::set_named_security_info; - - set_named_security_info( - &test_wide_path(path)?, - SE_FILE_OBJECT, - Some(&Sid::from_well_known(owner, None)?), - None, - None, - None, - ) -} - -#[cfg(test)] -fn test_wide_path(path: &Utf8Path) -> anyhow::Result { - let parent = path.parent().context("test path has no parent")?; - let name = path.file_name().context("test path has no name")?; - let full_path = fs::canonicalize(parent)?.join(name); - Ok(U16CString::from_os_str(full_path.as_os_str())?) -} - -/// Returns the owner and DACL of a test path in SDDL, which shows inherited entries and DACL protection. -#[cfg(test)] -pub(crate) fn test_security(path: &Utf8Path) -> anyhow::Result { - use windows::Win32::Security::Authorization::{ - ConvertSecurityDescriptorToStringSecurityDescriptorW, SDDL_REVISION_1, - }; - use windows::core::PWSTR; - - let (_, descriptor) = named_owner_and_dacl(path)?; - let mut sddl = PWSTR::null(); - // SAFETY: The descriptor stays live through the call and the output pointer is writable. - unsafe { - ConvertSecurityDescriptorToStringSecurityDescriptorW( - descriptor.as_raw(), - SDDL_REVISION_1, - OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, - &mut sddl, - None, - ) - } - .context("convert test security descriptor")?; - // SAFETY: The conversion returned a NUL-terminated string. - let text = unsafe { sddl.to_string() }; - // SAFETY: The conversion allocated the string with LocalAlloc. - unsafe { LocalFree(Some(HLOCAL(sddl.0.cast()))) }; - Ok(text?) -} - -/// How long a replacement of an identity file waits for other processes to close the file it replaces. -const REPLACE_TIMEOUT: Duration = Duration::from_secs(5); -const REPLACE_RETRY_INTERVAL: Duration = Duration::from_millis(25); - -/// Replaces `destination` with `source`. -/// -/// While another process has `destination` open, even sharing deletion, the replacement fails with -/// `ERROR_ACCESS_DENIED`, so it is retried until [`REPLACE_TIMEOUT`]; a reader such as a backup or an antivirus scan -/// holds the file only briefly. -pub(crate) fn replace_file(source: &Utf8Path, destination: &Utf8Path) -> anyhow::Result<()> { - use std::os::windows::ffi::OsStrExt as _; - - let full_path = |path: &Utf8Path| -> anyhow::Result> { - let parent = path.parent().context("identity file has no parent directory")?; - let name = path.file_name().context("identity file has no name")?; - Ok(fs::canonicalize(parent)? - .join(name) - .as_os_str() - .encode_wide() - .chain(Some(0)) - .collect()) - }; - let source = full_path(source)?; - let destination = full_path(destination)?; - let started = Instant::now(); - loop { - // SAFETY: Both extended-length paths are NUL-terminated and remain live for this call. - let replaced = unsafe { - MoveFileExW( - PCWSTR::from_raw(source.as_ptr()), - PCWSTR::from_raw(destination.as_ptr()), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, - ) - }; - match replaced { - Err(error) if error.code() == ERROR_ACCESS_DENIED.to_hresult() && started.elapsed() < REPLACE_TIMEOUT => { - std::thread::sleep(REPLACE_RETRY_INTERVAL); - } - replaced => return replaced.context("atomically replace identity file"), - } - } -} - -fn protected_dacl(entries: &[(Sid, u32, u32)]) -> anyhow::Result { - let entries: Vec<_> = entries - .iter() - .map(|(sid, mask, flags)| ExplicitAccess { - access_permissions: *mask, - access_mode: GRANT_ACCESS, - inheritance: Security::ACE_FLAGS(*flags), - trustee: Trustee::Sid(sid.clone()), - }) - .collect(); - Ok(InheritableAcl { - kind: InheritableAclKind::Protected, - acl: Acl::new() - .and_then(|acl| acl.set_entries(&entries)) - .context("build protected identity DACL")?, - }) -} - -fn expected_aces(principals: &[Sid], inheritance: Security::ACE_FLAGS) -> Vec<(Sid, u32, u32)> { - principals - .iter() - .cloned() - .map(|sid| (sid, FILE_ALL_ACCESS.0, inheritance.0)) - .collect() -} - -struct AuthzResourceManager(AUTHZ_RESOURCE_MANAGER_HANDLE); - -impl Drop for AuthzResourceManager { - fn drop(&mut self) { - // SAFETY: AuthzInitializeResourceManager returned this owned handle. - let _ = unsafe { AuthzFreeResourceManager(self.0) }; - } -} - -struct AuthzContext(AUTHZ_CLIENT_CONTEXT_HANDLE); - -impl Drop for AuthzContext { - fn drop(&mut self) { - // SAFETY: AuthzInitializeContextFromSid returned this owned handle. - let _ = unsafe { AuthzFreeContext(self.0) }; - } -} - -fn owner_is_administrator_member(owner: &Sid) -> anyhow::Result { - let administrators = administrators_sid()?; - if owner == &administrators { - return Ok(true); - } - match authz_administrator_membership(owner, &administrators) { - Ok(is_member) => Ok(is_member), - Err(error) => { - if win_api_wrappers::netmgmt::get_local_admin_group_members() - .context("list local administrators")? - .contains(owner) - { - Ok(true) - } else { - Err(error).context("verify pending owner administrator membership") - } - } - } -} - -fn authz_administrator_membership(owner: &Sid, administrators: &Sid) -> anyhow::Result { - let mut manager = AUTHZ_RESOURCE_MANAGER_HANDLE::default(); - // SAFETY: The output pointer is writable and the callbacks and name are intentionally absent. - unsafe { AuthzInitializeResourceManager(AUTHZ_RM_FLAG_NO_AUDIT.0, None, None, None, PCWSTR::null(), &mut manager) } - .context("initialize administrator membership check")?; - let manager = AuthzResourceManager(manager); - - let mut context = AUTHZ_CLIENT_CONTEXT_HANDLE::default(); - // SAFETY: The SID and resource manager stay live until the context has been freed. - unsafe { - AuthzInitializeContextFromSid( - 0, - owner.as_psid_const(), - manager.0, - None, - LUID::default(), - None, - &mut context, - ) - } - .context("resolve pending file owner groups")?; - let context = AuthzContext(context); - - let mut required = 0; - // SAFETY: The zero-length query writes only the required buffer size. - let _ = unsafe { - AuthzGetInformationFromContext( - context.0, - AuthzContextInfoGroupsSids, - 0, - &mut required, - std::ptr::null_mut(), - ) - }; - let size = usize::try_from(required)?; - ensure!( - size >= size_of::(), - "invalid administrator group list size" - ); - let mut buffer = vec![0usize; size.div_ceil(size_of::())]; - // SAFETY: The word-aligned buffer has at least `required` writable bytes. - unsafe { - AuthzGetInformationFromContext( - context.0, - AuthzContextInfoGroupsSids, - required, - &mut required, - buffer.as_mut_ptr().cast(), - ) - } - .context("read pending file owner groups")?; - // SAFETY: AuthzGetInformationFromContext filled the aligned buffer with a TOKEN_GROUPS header. - let groups = unsafe { &*buffer.as_ptr().cast::() }; - let count = usize::try_from(groups.GroupCount)?; - let bytes = count - .checked_mul(size_of::()) - .and_then(|bytes| bytes.checked_add(std::mem::offset_of!(Security::TOKEN_GROUPS, Groups))) - .context("invalid administrator group list size")?; - ensure!(bytes <= size, "invalid administrator group list size"); - // SAFETY: The verified count fits the Authz-allocated TOKEN_GROUPS buffer. - let entries = unsafe { std::slice::from_raw_parts(groups.Groups.as_ptr(), count) }; - for entry in entries { - // SAFETY: AuthzGetInformationFromContext returned a valid SID in each group entry. - if unsafe { Sid::from_psid(entry.Sid) }? == *administrators { - return Ok(true); - } - } - Ok(false) -} - -fn require_protected_dacl(file: &File, expected_owner: &Sid, expected: Vec<(Sid, u32, u32)>) -> anyhow::Result<()> { - let (owner, descriptor) = owner_and_dacl(file)?; - ensure!(owner == *expected_owner, "identity file owner is not the expected user"); - verify_dacl(descriptor.as_raw(), expected) -} - -fn named_owner_and_dacl(path: &Utf8Path) -> anyhow::Result<(Sid, SecurityDescriptor<'static>)> { - let parent = path.parent().context("identity state path has no parent")?; - let name = path.file_name().context("identity state path has no name")?; - let full_path = U16CString::from_os_str(fs::canonicalize(parent)?.join(name).as_os_str())?; - let descriptor = SecurityDescriptor::of_path(&full_path, SE_FILE_OBJECT).context("inspect identity state DACL")?; - let owner = descriptor - .owner() - .context("read identity state owner")? - .context("identity state path has no owner")?; - Ok((owner, descriptor)) -} - -fn owner_and_dacl(file: &File) -> anyhow::Result<(Sid, SecurityDescriptor<'static>)> { - use std::os::windows::io::AsRawHandle as _; - - let descriptor = SecurityDescriptor::of_handle(HANDLE(file.as_raw_handle()), SE_FILE_OBJECT) - .context("inspect identity file DACL")?; - let owner = descriptor - .owner() - .context("read identity file owner")? - .context("identity file has no owner")?; - Ok((owner, descriptor)) -} - -fn verify_dacl(descriptor: PSECURITY_DESCRIPTOR, mut expected: Vec<(Sid, u32, u32)>) -> anyhow::Result<()> { - let mut actual = read_dacl(descriptor)?; - expected.sort(); - actual.sort(); - ensure!(actual == expected, "identity DACL grants unexpected access"); - Ok(()) -} - -fn read_dacl(descriptor: PSECURITY_DESCRIPTOR) -> anyhow::Result> { - // SAFETY: Callers pass a descriptor that stays live through this call. - let descriptor = unsafe { SecurityDescriptor::from_raw(descriptor) }; - let entries = descriptor.protected_allow_entries().map_err(|error| match error { - DaclError::NotProtected => anyhow::anyhow!("identity file DACL is not protected"), - DaclError::Missing => anyhow::anyhow!("identity file has no DACL"), - DaclError::InvalidAce => anyhow::anyhow!("identity file has an invalid ACE"), - DaclError::UnexpectedAceType => anyhow::anyhow!("identity file has an unexpected ACE type"), - DaclError::Sid(error) => error.into(), - DaclError::Windows(error) => error.into(), - })?; - Ok(entries - .into_iter() - .map(|entry| (entry.sid, entry.mask, entry.flags)) - .collect()) -} - fn blob(bytes: &[u8]) -> anyhow::Result { Ok(CRYPT_INTEGER_BLOB { cbData: u32::try_from(bytes.len())?, @@ -1227,531 +112,185 @@ pub(crate) fn unprotect(contents: &[u8]) -> anyhow::Result>> { Ok(plaintext) } +/// Builds and reads owners and DACLs directly, so that tests can set up states the agent must refuse and check what it +/// provisions independently of private-fs. #[cfg(test)] -mod tests { - use camino::Utf8PathBuf; - use win_api_wrappers::security::acl::set_named_security_info; +pub(crate) mod test { + use std::fs; + + use anyhow::Context as _; + use camino::Utf8Path; + use win_api_wrappers::identity::sid::{Sid, StringSid}; + use win_api_wrappers::raw::Win32::Security::Authorization::GRANT_ACCESS; + use win_api_wrappers::security::acl::{ + Acl, ExplicitAccess, InheritableAcl, InheritableAclKind, Trustee, set_named_security_info, + }; + use win_api_wrappers::security::descriptor::{DaclError, SecurityDescriptor}; use win_api_wrappers::str::U16CString; + use windows::Win32::Foundation::{HLOCAL, LocalFree}; + use windows::Win32::Security::Authorization::SE_FILE_OBJECT; + use windows::Win32::Security::{self, DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION}; + use windows::Win32::Storage::FileSystem::FILE_ALL_ACCESS; - use super::*; + /// One allow entry of a DACL: its SID, access mask and ACE flags. + pub(crate) type Ace = (Sid, u32, u32); - /// Creates an empty data directory under the system temporary directory. - fn test_data_dir() -> anyhow::Result { - // The system temporary directory keeps test paths under Windows' 260-character limit wherever the repository is - // checked out. - let path = Utf8PathBuf::from_path_buf(std::env::temp_dir()) - .map_err(|_| anyhow::anyhow!("the temporary directory path is not UTF-8"))? - .join(format!("ai-{}", uuid::Uuid::new_v4().simple())); - fs::create_dir_all(&path)?; - Ok(path) + pub(crate) fn user_sid() -> anyhow::Result { + super::user_sid() } - // oracle: intent crates/agent-identity/INTENT.md §Local state - #[test] - fn replacing_a_file_waits_for_a_reader_to_close_it() -> anyhow::Result<()> { - // A reader holding the destination open, even one sharing deletion as the standard library does, makes - // `MoveFileExW` fail with `ERROR_ACCESS_DENIED` until it closes the file. - let directory = Utf8PathBuf::from_path_buf(std::env::temp_dir()) - .map_err(|_| anyhow::anyhow!("the temporary directory path is not UTF-8"))? - .join(format!("ai-{}", uuid::Uuid::new_v4().simple())); - fs::create_dir_all(&directory)?; - let destination = directory.join("identity.json"); - let source = directory.join("identity.json.new"); - fs::write(&destination, b"old")?; - fs::write(&source, b"new")?; - let reader = File::open(&destination)?; - let closer = std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(300)); - drop(reader); - }); - - let replaced = replace_file(&source, &destination); - let _ = closer.join(); - let contents = fs::read(&destination); - fs::remove_dir_all(&directory)?; - - replaced?; - assert_eq!(contents?, b"new"); - Ok(()) + pub(crate) fn system_sid() -> anyhow::Result { + super::system_sid() } - // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files - // oracle: intent devolutions-agent/src/identity/INTENT.md §Configuration - #[test] - fn directory_acl_policy_grants_only_system_and_optional_current_user() -> anyhow::Result<()> { - let system = system_sid()?; - let other = Sid::from_well_known(Security::WinBuiltinUsersSid, None)?; - let administrators = administrators_sid()?; - assert_eq!(allowed_principals(&system, &other, false), vec![system.clone()]); - assert_eq!( - allowed_principals(&system, &other, true), - vec![system.clone(), other.clone()] - ); - assert_eq!(allowed_principals(&system, &system, true), vec![system.clone()]); - - let inherited = (Security::CONTAINER_INHERIT_ACE | Security::OBJECT_INHERIT_ACE).0; - let owner_only = directory_aces(&system, &other, &administrators, false, false); - assert_eq!(owner_only, vec![(system.clone(), FILE_ALL_ACCESS.0, inherited)]); - assert_eq!( - directory_aces(&system, &other, &administrators, false, true), - vec![ - (system.clone(), FILE_ALL_ACCESS.0, inherited), - ( - administrators.clone(), - FILE_ADD_FILE.0 | SYNCHRONIZE.0, - Security::NO_INHERITANCE.0 - ) - ] - ); - assert_eq!( - directory_aces(&system, &other, &administrators, true, true), - vec![ - (system, FILE_ALL_ACCESS.0, inherited), - (other, FILE_ALL_ACCESS.0, inherited), - ( - administrators, - FILE_ADD_FILE.0 | SYNCHRONIZE.0, - Security::NO_INHERITANCE.0 - ) - ] - ); - Ok(()) + pub(crate) fn owner_rights_sid() -> anyhow::Result { + StringSid::from_str("OW")?.to_sid().context("get OWNER RIGHTS SID") } - // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files - #[test] - fn pending_file_acl_denies_implicit_owner_access() -> anyhow::Result<()> { - let system = system_sid()?; - let user = Sid::from_well_known(Security::WinBuiltinUsersSid, None)?; - let owner_rights = owner_rights_sid()?; - assert_eq!( - pending_file_aces(&system, &user, &owner_rights, false), - vec![ - (system.clone(), FILE_ALL_ACCESS.0, Security::NO_INHERITANCE.0), - (owner_rights.clone(), 0, Security::NO_INHERITANCE.0) - ] - ); - assert_eq!( - pending_file_aces(&system, &user, &owner_rights, true), - vec![ - (system, FILE_ALL_ACCESS.0, Security::NO_INHERITANCE.0), - (user, FILE_ALL_ACCESS.0, Security::NO_INHERITANCE.0), - (owner_rights, 0, Security::NO_INHERITANCE.0) - ] - ); - Ok(()) + pub(crate) fn sorted(entries: &[Ace]) -> Vec { + let mut entries = entries.to_vec(); + entries.sort(); + entries } - // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files - #[test] - fn drop_box_writer_cannot_reopen_its_file_and_replays_succeed() -> anyhow::Result<()> { - use std::os::windows::fs::OpenOptionsExt as _; - - let data_dir = test_data_dir()?; - crate::private_file::prepare_directories(&data_dir, true)?; - let path = data_dir - .join("identity") - .join("pending") - .join(format!("{}.dat", "a".repeat(64))); - let lock = crate::state_lock::acquire(&data_dir, true)?; - submit_pending_into_drop_box(&data_dir, &path, b"test payload", Instant::now())?; - drop(lock); - assert!(fs::symlink_metadata(&path)?.file_type().is_file()); - assert!(File::open(&path).is_err()); - assert!(fs::OpenOptions::new().write(true).open(&path).is_err()); - assert!(fs::OpenOptions::new().access_mode(WRITE_DAC.0).open(&path).is_err()); - submit_pending_into_drop_box(&data_dir, &path, b"different payload", Instant::now())?; - fs::remove_file(path)?; - fs::remove_dir_all(data_dir)?; - Ok(()) + /// Gives a test path a protected DACL of `entries`. + pub(crate) fn set_dacl(path: &Utf8Path, entries: &[Ace]) -> anyhow::Result<()> { + let entries: Vec<_> = entries + .iter() + .map(|(sid, mask, flags)| ExplicitAccess { + access_permissions: *mask, + access_mode: GRANT_ACCESS, + inheritance: Security::ACE_FLAGS(*flags), + trustee: Trustee::Sid(sid.clone()), + }) + .collect(); + let dacl = InheritableAcl { + kind: InheritableAclKind::Protected, + acl: Acl::new() + .and_then(|acl| acl.set_entries(&entries)) + .context("build test DACL")?, + }; + set_named_security_info(&wide_path(path)?, SE_FILE_OBJECT, None, None, Some(&dacl), None) + } + + /// Makes `owner` the owner of a test path, which takes an elevated token for any owner but the current user. + pub(crate) fn set_owner(path: &Utf8Path, owner: &Sid) -> anyhow::Result<()> { + set_named_security_info(&wide_path(path)?, SE_FILE_OBJECT, Some(owner), None, None, None) + } + + /// Returns the entries of a test path's DACL, sorted, or `None` when the DACL is not protected. + pub(crate) fn protected_dacl_of(path: &Utf8Path) -> anyhow::Result>> { + let descriptor = SecurityDescriptor::of_path(&wide_path(path)?, SE_FILE_OBJECT).context("read test DACL")?; + match descriptor.protected_allow_entries() { + Ok(entries) => Ok(Some(sorted( + &entries + .into_iter() + .map(|entry| (entry.sid, entry.mask, entry.flags)) + .collect::>(), + ))), + Err(DaclError::NotProtected) => Ok(None), + Err(error) => Err(anyhow::anyhow!("{error:?}")).context("read test DACL entries"), + } } - // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files - #[test] - fn interrupted_staging_does_not_block_a_replayed_enrollment() -> anyhow::Result<()> { - let data_dir = test_data_dir()?; - crate::private_file::prepare_directories(&data_dir, true)?; - let name = format!("{}.dat", "e".repeat(64)); - let long_dir = fs::canonicalize(&data_dir)?.join("identity").join("pending"); - let (mut interrupted, staging) = create_pending_staging(&long_dir, &name, Instant::now())?; - interrupted.write_all(b"incomplete DPAPI blob")?; - drop(interrupted); - assert!(fs::symlink_metadata(&staging)?.file_type().is_file()); - - let pending = data_dir.join("identity").join("pending").join(name); - submit_pending_into_drop_box(&data_dir, &pending, b"complete DPAPI blob", Instant::now())?; - assert!(fs::symlink_metadata(&pending)?.file_type().is_file()); - fs::remove_file(staging)?; - fs::remove_file(pending)?; - fs::remove_dir_all(data_dir)?; - Ok(()) + fn full_control(sids: &[Sid], flags: Security::ACE_FLAGS) -> Vec { + sids.iter() + .map(|sid| (sid.clone(), FILE_ALL_ACCESS.0, flags.0)) + .collect() } - // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files - #[test] - fn drop_box_writer_can_traverse_an_identity_directory_without_read_access() -> anyhow::Result<()> { - let data_dir = test_data_dir()?; - crate::private_file::prepare_directories(&data_dir, true)?; - let identity = data_dir.join("identity"); - let pending = identity.join("pending"); - let identity_name = U16CString::from_os_str(fs::canonicalize(&identity)?.as_os_str())?; - let pending_name = U16CString::from_os_str(fs::canonicalize(&pending)?.as_os_str())?; - let system = system_sid()?; - let user = user_sid()?; - let administrators = administrators_sid()?; - let identity_private = protected_dacl(&directory_aces(&system, &user, &administrators, false, false))?; - let pending_add_only = protected_dacl(&[ - ( - system.clone(), - FILE_ALL_ACCESS.0, - (Security::CONTAINER_INHERIT_ACE | Security::OBJECT_INHERIT_ACE).0, - ), - ( - user.clone(), - FILE_ADD_FILE.0 | SYNCHRONIZE.0, - Security::NO_INHERITANCE.0, - ), - ])?; - set_named_security_info(&pending_name, SE_FILE_OBJECT, None, None, Some(&pending_add_only), None)?; - set_named_security_info( - &identity_name, - SE_FILE_OBJECT, - None, - None, - Some(&identity_private), - None, - )?; - - assert!(fs::read_dir(&identity).is_err()); - assert!(fs::read_dir(&pending).is_err()); - let path = pending.join(format!("{}.dat", "b".repeat(64))); - submit_pending_into_drop_box(&data_dir, &path, b"test payload", Instant::now())?; - submit_pending_into_drop_box(&data_dir, &path, b"different payload", Instant::now())?; - - let pending_debug = protected_dacl(&directory_aces(&system, &user, &administrators, true, true))?; - let identity_debug = protected_dacl(&directory_aces(&system, &user, &administrators, true, false))?; - set_named_security_info(&pending_name, SE_FILE_OBJECT, None, None, Some(&pending_debug), None)?; - set_named_security_info(&identity_name, SE_FILE_OBJECT, None, None, Some(&identity_debug), None)?; - assert_eq!(fs::read_dir(&pending)?.count(), 1); - fs::remove_file(path)?; - fs::remove_dir_all(data_dir)?; - Ok(()) + fn inherited() -> Security::ACE_FLAGS { + Security::CONTAINER_INHERIT_ACE | Security::OBJECT_INHERIT_ACE } - // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files - #[test] - fn drop_box_denies_a_token_without_administrators_membership() -> anyhow::Result<()> { - if current_user_is_administrator()? { - return Ok(()); + /// Gives a test directory the DACL the agent provisions for its directories, with or without the current user. + pub(crate) fn set_test_directory_access(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result<()> { + let mut sids = vec![system_sid()?]; + if grant_current_user { + sids.push(user_sid()?); } - let data_dir = test_data_dir()?; - crate::private_file::prepare_directories(&data_dir, true)?; - let pending = data_dir.join("identity").join("pending"); - let name = U16CString::from_os_str(fs::canonicalize(&pending)?.as_os_str())?; - let system = system_sid()?; - let user = user_sid()?; - let administrators = administrators_sid()?; - let add_only = protected_dacl(&directory_aces(&system, &user, &administrators, false, true))?; - set_named_security_info(&name, SE_FILE_OBJECT, None, None, Some(&add_only), None)?; - - let path = pending.join(format!("{}.dat", "c".repeat(64))); - assert!(submit_pending_into_drop_box(&data_dir, &path, b"test payload", Instant::now()).is_err()); - assert!(!path.exists()); - - let debug_dacl = protected_dacl(&directory_aces(&system, &user, &administrators, true, true))?; - set_named_security_info(&name, SE_FILE_OBJECT, None, None, Some(&debug_dacl), None)?; - fs::remove_dir_all(data_dir)?; - Ok(()) - } - - // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files - #[test] - fn drop_box_rejects_reparse_redirects_before_writing() -> anyhow::Result<()> { - use std::os::windows::fs::symlink_dir; - - let data_dir = test_data_dir()?; - crate::private_file::prepare_directories(&data_dir, true)?; - let pending = data_dir.join("identity").join("pending"); - fs::remove_dir(&pending)?; - let redirected = data_dir.join("redirected"); - fs::create_dir(&redirected)?; - if let Err(error) = symlink_dir(&redirected, &pending) { - if error.kind() == ErrorKind::PermissionDenied { - fs::remove_dir_all(data_dir)?; - return Ok(()); - } - return Err(error).context("create test reparse point"); + set_dacl(path, &full_control(&sids, inherited())) + } + + /// Gives `other` inherited full control of a test directory, besides SYSTEM and the current user. + pub(crate) fn grant_test_directory_access( + path: &Utf8Path, + other: Security::WELL_KNOWN_SID_TYPE, + ) -> anyhow::Result<()> { + let sids = [system_sid()?, user_sid()?, Sid::from_well_known(other, None)?]; + set_dacl(path, &full_control(&sids, inherited())) + } + + /// Gives a test file the protected DACL the agent provisions for its state files, plus full control for `others`. + pub(crate) fn set_test_file_access( + path: &Utf8Path, + others: &[Security::WELL_KNOWN_SID_TYPE], + ) -> anyhow::Result<()> { + let mut sids = vec![system_sid()?, user_sid()?]; + for other in others { + sids.push(Sid::from_well_known(*other, None)?); } - - let path = pending.join(format!("{}.dat", "d".repeat(64))); - let error = submit_pending_into_drop_box(&data_dir, &path, b"test payload", Instant::now()) - .expect_err("redirected drop box accepted a pending enrollment"); - assert!(format!("{error:#}").contains("resolves outside the data directory")); - assert!(fs::read_dir(&redirected)?.next().is_none()); - fs::remove_dir(&pending)?; - fs::remove_dir_all(data_dir)?; - Ok(()) - } - - // oracle: intent devolutions-agent/src/identity/INTENT.md §CLI - #[test] - fn administrator_writer_requires_system_provisioned_directories() -> anyhow::Result<()> { - let data_dir = test_data_dir()?; - let missing = verify_system_provisioned_drop_box(&data_dir, Instant::now()) - .expect_err("missing identity directory was accepted"); - assert_eq!(missing.to_string(), ENROLLMENT_DIRECTORY_NOT_READY); - assert!( - format!("{missing:#}").contains("inspect protected identity directory"), - "{missing:#}" - ); - crate::private_file::prepare_directories(&data_dir, true)?; - let untrusted = verify_system_provisioned_drop_box(&data_dir, Instant::now()) - .expect_err("debug directory was SYSTEM-trusted"); - assert_eq!(untrusted.to_string(), ENROLLMENT_DIRECTORY_NOT_READY); - assert!(format!("{untrusted:#}").contains("unexpected access"), "{untrusted:#}"); - fs::remove_dir_all(data_dir)?; - Ok(()) + set_dacl(path, &full_control(&sids, Security::NO_INHERITANCE)) } - // oracle: intent crates/agent-identity/INTENT.md §Local state - // oracle: intent devolutions-agent/src/identity/INTENT.md §CLI - #[test] - fn retries_only_sharing_violations() -> anyhow::Result<()> { - let sharing = anyhow::Error::new(std::io::Error::from_raw_os_error(i32::try_from( - ERROR_SHARING_VIOLATION.0, - )?)) - .context("inspect protected identity directory"); - assert!(is_sharing_violation(&sharing)); - - let mut attempts = 0; - retry_sharing_violation(Instant::now(), || { - attempts += 1; - if attempts == 1 { - Err(windows::core::Error::from_hresult(ERROR_SHARING_VIOLATION.to_hresult()).into()) - } else { - Ok(()) - } - })?; - assert_eq!(attempts, 2); - - let other = windows::core::Error::from_hresult(ERROR_ACCESS_DENIED.to_hresult()); - assert!(!is_sharing_violation(&anyhow::Error::new(other))); - let inaccessible = explain_unprepared_drop_box( - anyhow::Error::new(std::io::Error::from(ErrorKind::PermissionDenied)) - .context("inspect protected pending drop box"), - ); - assert_eq!(inaccessible.to_string(), ENROLLMENT_DIRECTORY_NOT_READY); - assert!( - format!("{inaccessible:#}").contains("inspect protected pending drop box"), - "{inaccessible:#}" - ); - let mut attempts = 0; - let result: anyhow::Result<()> = retry_sharing_violation(Instant::now(), || { - attempts += 1; - Err(std::io::Error::from(ErrorKind::PermissionDenied).into()) - }); - assert!(result.is_err()); - assert_eq!(attempts, 1); - Ok(()) + /// Gives a test file a protected DACL that grants full control to the current user only. + pub(crate) fn set_test_file_access_for_current_user_only(path: &Utf8Path) -> anyhow::Result<()> { + set_dacl(path, &full_control(&[user_sid()?], Security::NO_INHERITANCE)) } - // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files - #[test] - fn pending_reader_accepts_only_the_two_exact_acl_forms() -> anyhow::Result<()> { - let system = system_sid()?; - let user = Sid::from_well_known(Security::WinBuiltinUsersSid, None)?; - let other = Sid::from_well_known(Security::WinBuiltinGuestsSid, None)?; - let owner_rights = owner_rights_sid()?; - assert!(pending_owner_is_allowed(&system, &user, &system, false)); - assert!(pending_owner_is_allowed(&system, &user, &user, false)); - assert!(pending_owner_is_allowed(&system, &system, &user, true)); - assert!(!pending_owner_is_allowed(&system, &user, &other, false)); - for grant in [false, true] { - let base = pending_base_aces(&system, &user, grant); - let with_owner_rights = pending_file_aces(&system, &user, &owner_rights, grant); - assert!(pending_aces_are_allowed( - &base, - &system, - &user, - &owner_rights, - grant, - false - )); - assert!(!pending_aces_are_allowed( - &base, - &system, - &user, - &owner_rights, - grant, - true - )); - assert!(pending_aces_are_allowed( - &with_owner_rights, - &system, - &user, - &owner_rights, - grant, - true - )); - assert!(pending_aces_are_allowed( - &with_owner_rights, - &system, - &user, - &owner_rights, - grant, - false - )); - for unexpected in [ - (other.clone(), FILE_ALL_ACCESS.0, Security::NO_INHERITANCE.0), - (owner_rights.clone(), FILE_ALL_ACCESS.0, Security::NO_INHERITANCE.0), - (owner_rights.clone(), 0, Security::OBJECT_INHERIT_ACE.0), - ] { - let mut extra = base.clone(); - extra.push(unexpected); - assert!(!pending_aces_are_allowed( - &extra, - &system, - &user, - &owner_rights, - grant, - false - )); - assert!(!pending_aces_are_allowed( - &extra, - &system, - &user, - &owner_rights, - grant, - true - )); - } - let mut duplicate = with_owner_rights; - duplicate.push((owner_rights.clone(), 0, Security::NO_INHERITANCE.0)); - assert!(!pending_aces_are_allowed( - &duplicate, - &system, - &user, - &owner_rights, - grant, - true - )); - } - Ok(()) + /// Makes `owner` the owner of a test path, which takes an elevated token for any owner but the current user. + pub(crate) fn set_test_owner(path: &Utf8Path, owner: Security::WELL_KNOWN_SID_TYPE) -> anyhow::Result<()> { + set_owner(path, &Sid::from_well_known(owner, None)?) } - // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files - #[test] - fn distinct_administrator_owner_requires_zero_access_owner_rights() -> anyhow::Result<()> { - let system = system_sid()?; - let administrator = administrators_sid()?; - let current = user_sid()?; - let owner_rights = owner_rights_sid()?; - let base = SecurityAttributesInit { - owner: Some(administrator.clone()), - dacl: Some(protected_dacl(&pending_base_aces(&system, ¤t, false))?), - ..Default::default() + /// Checks that a pending file is owned by the current user and has exactly the DACL the agent provisions. + pub(crate) fn assert_pending_protection(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result<()> { + let descriptor = + SecurityDescriptor::of_path(&wide_path(path)?, SE_FILE_OBJECT).context("read pending file owner")?; + assert_eq!(descriptor.owner()?, Some(user_sid()?), "{path}"); + let mut sids = vec![system_sid()?]; + if grant_current_user { + sids.push(user_sid()?); } - .init(); - // SAFETY: The security attributes and their descriptor remain live through verification. - let descriptor = PSECURITY_DESCRIPTOR(unsafe { (*base.as_ptr()).lpSecurityDescriptor }); - assert!(verify_pending_owner_and_dacl(&administrator, descriptor, false).is_err()); - - let protected = SecurityAttributesInit { - owner: Some(administrator.clone()), - dacl: Some(protected_dacl(&pending_file_aces( - &system, - ¤t, - &owner_rights, - false, - ))?), - ..Default::default() - } - .init(); - // SAFETY: The security attributes and their descriptor remain live through verification. - let descriptor = PSECURITY_DESCRIPTOR(unsafe { (*protected.as_ptr()).lpSecurityDescriptor }); - verify_pending_owner_and_dacl(&administrator, descriptor, false)?; - Ok(()) - } - - // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files - #[test] - fn system_style_pending_file_without_owner_rights_is_readable() -> anyhow::Result<()> { - let data_dir = test_data_dir()?; - crate::private_file::prepare_directories(&data_dir, true)?; - let path = data_dir - .join("identity") - .join("pending") - .join(format!("{}.dat", "a".repeat(64))); - let mut file = create_private_file(&path, true, false)?; - file.write_all(&protect(b"invalid pending JSON")?)?; - file.sync_all()?; - drop(file); - assert!(matches!( - crate::pending::read(&path, true)?, - crate::pending::PendingRead::Unusable - )); - fs::remove_file(path)?; - fs::remove_dir_all(data_dir)?; - Ok(()) - } - - // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files - #[test] - fn owner_membership_distinguishes_administrators_from_local_service() -> anyhow::Result<()> { - let administrators = administrators_sid()?; - assert!(owner_is_administrator_member(&administrators)?); - let local_service = Sid::from_well_known(Security::WinLocalServiceSid, None)?; - assert!(!owner_is_administrator_member(&local_service)?); + assert_eq!( + protected_dacl_of(path)?, + Some(sorted(&full_control(&sids, Security::NO_INHERITANCE))), + "{path}" + ); Ok(()) } - // oracle: intent crates/agent-identity/INTENT.md §Local state - #[test] - fn repairs_a_directory_with_an_extra_allow_principal() -> anyhow::Result<()> { - let data_dir = test_data_dir()?; - let directory = data_dir.join("identity"); - fs::create_dir(&directory)?; - let system = system_sid()?; - let user = user_sid()?; - let mut broad = allowed_principals(&system, &user, true); - let users = Sid::from_well_known(Security::WinBuiltinUsersSid, None)?; - broad.push(users); - let inheritance = Security::CONTAINER_INHERIT_ACE | Security::OBJECT_INHERIT_ACE; - let name = U16CString::from_os_str(fs::canonicalize(&directory)?.as_os_str())?; - let dacl = protected_dacl(&expected_aces(&broad, inheritance))?; - set_named_security_info(&name, SE_FILE_OBJECT, None, None, Some(&dacl), None)?; - // `crates/agent-identity/INTENT.md` ("Local state"): start-up resets the directory's permissions, so the - // identities and keys stored beneath it must survive the repair. - let state = [ - ( - directory - .join("authorities") - .join(uuid::Uuid::new_v4().to_string()) - .join("identity.json"), - br#"{"version":1}"#.as_slice(), - ), - ( - directory.join("keys").join("devolutions-agent-identity_test.p8"), - b"device key".as_slice(), - ), - ]; - for (path, contents) in &state { - fs::create_dir_all(path.parent().context("test state file has no parent")?)?; - fs::write(path, contents)?; - } + /// Returns the owner and DACL of a test path in SDDL, which shows inherited entries and DACL protection. + pub(crate) fn test_security(path: &Utf8Path) -> anyhow::Result { + use windows::Win32::Security::Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows::core::PWSTR; - create_protected_directory(&directory, true, false)?; - let (owner, descriptor) = named_owner_and_dacl(&directory)?; - assert!(owner == system || owner == user); - verify_dacl( - descriptor.as_raw(), - expected_aces(&allowed_principals(&system, &user, true), inheritance), - )?; - for (path, contents) in &state { - let actual = fs::read(path).with_context(|| format!("read {path} after the repair"))?; - assert_eq!(actual, *contents, "{path}"); + let descriptor = SecurityDescriptor::of_path(&wide_path(path)?, SE_FILE_OBJECT).context("read test DACL")?; + let mut sddl = PWSTR::null(); + // SAFETY: The descriptor stays live through the call and the output pointer is writable. + unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor.as_raw(), + SDDL_REVISION_1, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &mut sddl, + None, + ) } - fs::remove_dir_all(data_dir)?; - Ok(()) + .context("convert test security descriptor")?; + // SAFETY: The conversion returned a NUL-terminated string. + let text = unsafe { sddl.to_string() }; + // SAFETY: The conversion allocated the string with LocalAlloc. + unsafe { LocalFree(Some(HLOCAL(sddl.0.cast()))) }; + Ok(text?) + } + + fn wide_path(path: &Utf8Path) -> anyhow::Result { + let parent = path.parent().context("test path has no parent")?; + let name = path.file_name().context("test path has no name")?; + let full_path = fs::canonicalize(parent)?.join(name); + Ok(U16CString::from_os_str(full_path.as_os_str())?) } } diff --git a/testsuite/tests/agent_identity/mod.rs b/testsuite/tests/agent_identity/mod.rs index cb44b1014b..05c53bc989 100644 --- a/testsuite/tests/agent_identity/mod.rs +++ b/testsuite/tests/agent_identity/mod.rs @@ -312,7 +312,9 @@ async fn conformance() -> anyhow::Result<()> { let agent_version = std::fs::read_to_string(project_dir.join("VERSION")).context("read workspace VERSION")?; let agent_version = agent_version.trim(); ensure!(!agent_version.is_empty(), "workspace VERSION is empty"); - let work_dir = project_dir.join("target").join("agent-identity-integration"); + // The agent refuses a data directory with a directory above it that another account can rename, as a workspace + // `target` directory's can be, and the system temporary directory's are not. + let work_dir = std::env::temp_dir().join("agent-identity-integration"); std::fs::create_dir_all(&work_dir)?; let mut first_dir = tempfile::Builder::new().prefix("mock-1-").tempdir_in(&work_dir)?; let second_dir = tempfile::Builder::new().prefix("mock-2-").tempdir_in(&work_dir)?; From d54fcf815fc91062f37339a33ce92a48ba030032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 16:31:14 +0900 Subject: [PATCH 11/60] fix(private-fs): tighten the Linux and macOS checks Group write on a directory above now counts whatever the group, since administrators are root alone and a member of root's group or of macOS's admin group need not be root. Volumes are also refused by macOS file system name (SMB, NFS, WebDAV, AFP, MS-DOS, exFAT, NTFS, FUSE) and by Linux type (ntfs3, NTFS, UDF). A directory that can't be searched while looking for the first existing directory is reported as unreadable, with its path. An existing directory is opened before the parent's inheritable ACL is checked, so only one that must be created is refused for it, and a created file gets mode 0600 when the umask narrowed it. The exclusive rename falls back to link and unlink only when the file system lacks it, and undoes the link when the old name can't be removed. The ACL tests set POSIX ACLs through extended attributes so they run without setfacl, and the root-only tests run as root in CI. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 + Cargo.lock | 1 + crates/private-fs/src/linux.rs | 186 +++------------------ crates/private-fs/src/macos.rs | 49 +++++- crates/private-fs/src/unix.rs | 247 +++++++++++++++++----------- testsuite/Cargo.toml | 2 + testsuite/tests/private_fs/unix.rs | 256 +++++++++++++++++++++-------- 7 files changed, 415 insertions(+), 329 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 032cf21261..65a67b4384 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -344,6 +344,9 @@ jobs: # A root-only test run without root skips and passes, so after changing this step, check that its log has no SKIP line. run: | cargo test --locked --target ${{ matrix.target }} --config "target.${{ matrix.target }}.runner='sudo'" -p device-key --lib root_only_ + cargo test --locked --target ${{ matrix.target }} --config "target.${{ matrix.target }}.runner='sudo'" -p testsuite --test integration_tests private_fs::unix::root_only_ + env: + AWS_LC_SYS_NO_ASM: true - name: Show sccache stats if: ${{ needs.preflight.outputs.sccache == 'true' && !cancelled() }} diff --git a/Cargo.lock b/Cargo.lock index dd69ba2bdb..92293e2f19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8131,6 +8131,7 @@ dependencies = [ "hex", "ipnetwork", "jmux-proto", + "libc", "libsql", "mcp-proxy", "network-scanner", diff --git a/crates/private-fs/src/linux.rs b/crates/private-fs/src/linux.rs index 0c87c87a74..e8f4360df9 100644 --- a/crates/private-fs/src/linux.rs +++ b/crates/private-fs/src/linux.rs @@ -14,12 +14,9 @@ use std::os::fd::AsRawFd as _; use camino::Utf8Path; -use crate::unix::{Allowed, admin_gid}; +use crate::unix::Allowed; use crate::{Error, Refusal, Result}; -/// Groups whose write access to a directory above doesn't count: root's. -pub(crate) const ADMIN_GIDS: &[libc::gid_t] = &[0]; - const ACCESS_ACL: &CStr = c"system.posix_acl_access"; const DEFAULT_ACL: &CStr = c"system.posix_acl_default"; const ACL_VERSION: u32 = 2; @@ -36,9 +33,9 @@ const ACL_WRITE: u16 = 0x02; /// Read, write and execute, the permissions a missing mask leaves. const ACL_ALL: u16 = 0x07; -/// The `statfs` types of volumes that ignore or fake ownership: FUSE, CIFS, SMB2, SMB, NFS, 9P, vboxsf, MSDOS/vfat and -/// exFAT. -const IGNORING_VOLUMES: [u32; 9] = [ +/// The `statfs` types of volumes that ignore or fake ownership: FUSE, CIFS, SMB2, SMB, NFS, 9P, vboxsf, MSDOS/vfat, +/// exFAT, ntfs3, NTFS (ntfs and ntfs-3g's kernel type) and UDF. +const IGNORING_VOLUMES: [u32; 12] = [ 0x6573_5546, 0xFF53_4D42, 0xFE53_4D42, @@ -48,6 +45,9 @@ const IGNORING_VOLUMES: [u32; 9] = [ 0x786F_4256, 0x0000_4D44, 0x2011_BAB0, + 0x7366_746E, + 0x5346_544E, + 0x1501_3346, ]; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -70,7 +70,7 @@ pub(crate) fn check_volume(file: &File, path: &Utf8Path) -> Result<()> { let info = unsafe { info.assume_init() }; // UNTESTED: a file on one of the listed volume types is refused. // CI has no such volume; on one, the owner and mode that the other checks read are made up by the driver. - // When changing this, check a file on a FUSE or SMB mount and on a vfat image, and one on ext4. + // When changing this, check a file on a FUSE or SMB mount and on a vfat, NTFS or UDF image, and one on ext4. if ignores_ownership(i128::from(info.f_type)) { return Err(Error::refused(path, Refusal::Volume)); } @@ -135,8 +135,11 @@ pub(crate) fn ancestor_acl_of_path(path: &Utf8Path) -> io::Result { .map(AncestorAcl) } -/// Reports whether a named user or group entry of `acl` lets an account that `allowed` doesn't name write, so rename or -/// replace entries, unless the sticky bit limits writers to adding entries and removing their own. +/// Reports whether a named user entry for an account that `allowed` doesn't name, or any named group entry, of `acl` +/// can write, so rename or replace entries, unless the sticky bit limits writers to adding entries and removing their +/// own. +/// +/// A group is never allowed: administrators are root alone, and any account can be a member of a group. pub(crate) fn acl_lets_replace(acl: &AncestorAcl, sticky: bool, allowed: &Allowed) -> bool { let Some(entries) = &acl.0 else { return false; @@ -149,7 +152,7 @@ pub(crate) fn acl_lets_replace(acl: &AncestorAcl, sticky: bool, allowed: &Allowe entry.perm & mask & ACL_WRITE != 0 && match entry.tag { ACL_USER => !allowed.uid(entry.id), - ACL_GROUP => !admin_gid(entry.id), + ACL_GROUP => true, _ => false, } }) @@ -183,6 +186,16 @@ pub(crate) fn rename_exclusive(fd: libc::c_int, from: &CStr, to: &CStr) -> io::R Ok(()) } +/// Reports whether `renameat2` failed with `code` because the kernel or file system has no `RENAME_NOREPLACE`. +/// +/// A kernel before 3.15 fails with `ENOSYS`, and a file system without the flag with `EINVAL` on older kernels or +/// `EOPNOTSUPP` on newer ones. +/// `EINVAL` has no other cause here: the names are single components of one directory, never `.` or `..`, so neither +/// is an ancestor of the other. +pub(crate) fn exclusive_rename_unsupported(code: libc::c_int) -> bool { + matches!(code, libc::ENOSYS | libc::EINVAL | libc::EOPNOTSUPP) +} + pub(crate) fn clear_errno() { // SAFETY: __errno_location has no preconditions and returns the calling thread's errno. let errno = unsafe { libc::__errno_location() }; @@ -253,8 +266,6 @@ impl AncestorAcl { #[cfg(test)] mod tests { - use std::io::Write as _; - use super::*; const NOBODY: u32 = 65534; @@ -336,13 +347,14 @@ mod tests { // oracle: intent crates/private-fs/INTENT.md §paths #[test] - fn a_named_group_other_than_root_that_can_write_makes_a_directory_replaceable() { + fn a_named_group_that_can_write_makes_a_directory_replaceable() { let allowed = Allowed::new_for_tests(None); let mut group = nobody_can_write(); group[1] = entry(ACL_GROUP, 7, 100); assert!(acl_lets_replace(&ancestor(&group), false, &allowed)); + // Root's group too: its members need not be root. group[1] = entry(ACL_GROUP, 7, 0); - assert!(!acl_lets_replace(&ancestor(&group), false, &allowed)); + assert!(acl_lets_replace(&ancestor(&group), false, &allowed)); } // With an ACL, the mode's group bits are the mask, so the owning group's write comes from its entry. @@ -368,148 +380,4 @@ mod tests { assert!(!ignores_ownership(0xEF53)); assert!(!ignores_ownership(0x0102_1994)); } - - /// A directory under the system temporary directory, removed when dropped. - struct Scratch(camino::Utf8PathBuf); - - impl Scratch { - fn new(test: &str) -> Self { - let path = camino::Utf8PathBuf::from_path_buf(std::env::temp_dir()) - .expect("UTF-8 temporary directory") - .join(format!("private-fs-{test}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&path); - std::fs::create_dir(&path).expect("create a scratch directory"); - set_mode(&path, 0o700); - Self(path) - } - } - - impl Drop for Scratch { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } - - fn set_mode(path: &Utf8Path, mode: u32) { - std::fs::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(mode)).expect("set a test mode"); - } - - fn rule() -> crate::Rule { - crate::Rule::new() - .owner(crate::Account::CurrentUser) - .grant(crate::Account::CurrentUser, crate::Access::Full) - } - - /// Sets the ACL attribute `name` of `path` as `setfacl` would, and returns `false` when the volume has no ACLs. - fn set_acl(path: &Utf8Path, name: &CStr, entries: &[[u8; 8]]) -> bool { - let path = CString::new(path.as_str()).expect("NUL-free path"); - let value = attribute(entries); - // SAFETY: The path and name are NUL-terminated, and the value is readable for its length. - let status = unsafe { libc::setxattr(path.as_ptr(), name.as_ptr(), value.as_ptr().cast(), value.len(), 0) }; - if status == 0 { - return true; - } - let error = io::Error::last_os_error(); - assert!(no_acl(&error), "set a test ACL: {error}"); - // The test harness captures `eprintln!` output from a passing test, but not direct writes to stderr. - let _ = writeln!(io::stderr(), "SKIP: the temporary directory's volume has no POSIX ACLs"); - false - } - - fn refusal(error: &Error) -> Option<&Refusal> { - match error.kind() { - crate::ErrorKind::Refused(refusal) => Some(refusal), - crate::ErrorKind::Io { .. } | crate::ErrorKind::Unsupported { .. } => None, - } - } - - /// `setfacl -m u:nobody:r,m::-` on a file with mode 0600: nobody is named, and the mask leaves it nothing, so the - /// mode still reads 0600. - fn nobody_named() -> [[u8; 8]; 5] { - [ - entry(ACL_USER_OBJ, 6, u32::MAX), - entry(ACL_USER, 4, NOBODY), - entry(ACL_GROUP_OBJ, 0, u32::MAX), - entry(ACL_MASK, 0, u32::MAX), - entry(ACL_OTHER, 0, u32::MAX), - ] - } - - // A named entry is an access the mode doesn't show, and a mask change can open it up. - // oracle: intent crates/private-fs/INTENT.md §mechanisms - #[test] - fn a_file_with_a_named_acl_entry_is_refused() { - let scratch = Scratch::new("named-entry"); - let base = crate::Dir::create_base(&scratch.0, &rule()).expect("open the scratch directory"); - drop( - base.create_file("key", &rule(), crate::FileOptions::new()) - .expect("create a file"), - ); - if !set_acl(&scratch.0.join("key"), ACCESS_ACL, &nobody_named()) { - return; - } - assert_eq!( - std::os::unix::fs::MetadataExt::mode(&std::fs::metadata(scratch.0.join("key")).expect("inspect")) & 0o777, - 0o600 - ); - - let error = base - .open_file("key", &rule(), crate::FileOptions::new()) - .expect_err("refused"); - assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); - } - - // A new entry would inherit the default ACL, and so not have exactly its rule from the moment it exists. - // oracle: intent crates/private-fs/INTENT.md §paths - #[test] - fn a_default_acl_refuses_creation_until_repaired() { - let scratch = Scratch::new("default-acl"); - let base = crate::Dir::create_base(&scratch.0, &rule()).expect("open the scratch directory"); - let parent = base - .create_dir("parent", &rule(), crate::Existing::Check) - .expect("create a directory"); - let default = [ - entry(ACL_USER_OBJ, 7, u32::MAX), - entry(ACL_GROUP_OBJ, 0, u32::MAX), - entry(ACL_OTHER, 0, u32::MAX), - ]; - if !set_acl(&scratch.0.join("parent"), DEFAULT_ACL, &default) { - return; - } - - let error = parent - .create_file("file", &rule(), crate::FileOptions::new()) - .expect_err("refused"); - assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); - assert!(!scratch.0.join("parent").join("file").exists()); - let error = parent.check(&rule()).expect_err("refused"); - assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); - - parent.repair(&rule()).expect("repair"); - drop( - parent - .create_file("file", &rule(), crate::FileOptions::new()) - .expect("create a file"), - ); - } - - // StrictModes: a named user who can write to a directory above can rename or replace its entries. - // oracle: intent crates/private-fs/INTENT.md §paths - #[test] - fn a_directory_above_that_a_named_user_can_write_is_refused() { - let scratch = Scratch::new("ancestor-acl"); - let shared = scratch.0.join("shared"); - std::fs::create_dir(&shared).expect("create a directory"); - set_mode(&shared, 0o755); - // The mask makes the mode read 0775, but the owning group can't write: only the named entry can. - if !set_acl(&shared, ACCESS_ACL, &nobody_can_write()) { - return; - } - - let error = crate::Dir::create_base(&shared.join("base"), &rule()).expect_err("refused"); - assert!(matches!(refusal(&error), Some(Refusal::Replaceable)), "{error:?}"); - - set_mode(&shared, 0o1775); - crate::Dir::create_base(&shared.join("base"), &rule()).expect("accepted with the sticky bit"); - } } diff --git a/crates/private-fs/src/macos.rs b/crates/private-fs/src/macos.rs index cf4227ef4f..9602a4590e 100644 --- a/crates/private-fs/src/macos.rs +++ b/crates/private-fs/src/macos.rs @@ -16,8 +16,10 @@ use camino::Utf8Path; use crate::unix::Allowed; use crate::{Error, Refusal, Result}; -/// Groups whose write access to a directory above doesn't count: root's (`wheel`) and `admin`. -pub(crate) const ADMIN_GIDS: &[libc::gid_t] = &[0, 80]; +/// The `statfs` type names of file systems that ignore or fake ownership, besides those starting with `fuse`. +const IGNORING_FILE_SYSTEMS: [&[u8]; 9] = [ + b"smbfs", b"nfs", b"webdav", b"afpfs", b"msdos", b"exfat", b"ntfs", b"macfuse", b"osxfuse", +]; const ACL_TYPE_EXTENDED: libc::c_int = 0x100; const ACL_FIRST_ENTRY: libc::c_int = 0; @@ -76,15 +78,25 @@ pub(crate) fn check_volume(file: &File, path: &Utf8Path) -> Result<()> { } // SAFETY: fstatfs initialized the output buffer on success. let info = unsafe { info.assume_init() }; - // UNTESTED: a volume with "Ignore ownership on this volume" set is refused, and the boot volume is not. - // CI has no such volume; on one, every owner check passes whatever the file's owner. - // When changing this, check both on a Mac, for example on a disk image after `diskutil disableOwnership`. - if info.f_flags & libc::MNT_IGNORE_OWNERSHIP.cast_unsigned() != 0 { + // UNTESTED: a volume with "Ignore ownership on this volume" set, or of one of the listed file system types, is + // refused, and the boot volume is not. + // CI has no such volume; on one, every owner check passes whatever the file's owner, or the owner and mode are + // made up by the driver. + // When changing this, check a disk image after `diskutil disableOwnership`, an SMB or MS-DOS volume, and the boot + // volume on a Mac. + let type_name = info.f_fstypename.map(libc::c_char::cast_unsigned); + let type_name = CStr::from_bytes_until_nul(&type_name).map_or(&type_name[..], CStr::to_bytes); + if info.f_flags & libc::MNT_IGNORE_OWNERSHIP.cast_unsigned() != 0 || type_ignores_ownership(type_name) { return Err(Error::refused(path, Refusal::Volume)); } Ok(()) } +/// Reports whether volumes of the file system type `name`, as `statfs` reports it, ignore or fake ownership. +fn type_ignores_ownership(name: &[u8]) -> bool { + name.starts_with(b"fuse") || IGNORING_FILE_SYSTEMS.contains(&name) +} + pub(crate) fn check_no_extended_acl(file: &File, path: &Utf8Path, _is_dir: bool) -> Result<()> { let summary = acl_of_file(file).map_err(|error| Error::io(path, "read access control list", error))?; if summary.has_entries { @@ -155,6 +167,14 @@ pub(crate) fn rename_exclusive(fd: libc::c_int, from: &CStr, to: &CStr) -> io::R Ok(()) } +/// Reports whether `renameatx_np` failed with `code` because the file system has no exclusive rename. +/// +/// `EINVAL` is left out: `RENAME_EXCL` is a valid flag on every supported macOS, and the names are single components +/// of one directory, so it can't mean the flag is unsupported. +pub(crate) fn exclusive_rename_unsupported(code: libc::c_int) -> bool { + code == libc::ENOTSUP || code == libc::EOPNOTSUPP || code == libc::ENOSYS +} + pub(crate) fn clear_errno() { // SAFETY: __error has no preconditions and returns the calling thread's errno. let errno = unsafe { libc::__error() }; @@ -243,3 +263,20 @@ impl AncestorAcl { Self { replace_allowed: false } } } + +#[cfg(test)] +mod tests { + use super::*; + + // A network or foreign file system shows owners and modes its driver makes up. + // oracle: intent crates/private-fs/INTENT.md §mechanisms + #[test] + fn recognizes_file_systems_that_fake_ownership() { + for name in ["smbfs", "nfs", "msdos", "exfat", "macfuse", "fuse-t"] { + assert!(type_ignores_ownership(name.as_bytes()), "{name}"); + } + for name in ["apfs", "hfs", "devfs"] { + assert!(!type_ignores_ownership(name.as_bytes()), "{name}"); + } + } +} diff --git a/crates/private-fs/src/unix.rs b/crates/private-fs/src/unix.rs index f22caf2603..9951d682fe 100644 --- a/crates/private-fs/src/unix.rs +++ b/crates/private-fs/src/unix.rs @@ -28,7 +28,7 @@ use crate::{ const DIR_MODE: u32 = 0o700; /// [`DIR_MODE`] as `mkdirat` takes it. const MKDIR_MODE: libc::mode_t = 0o700; -/// The mode of a file, owner-only, as `openat` takes it. +/// The mode of a file, owner-only, of the `c_uint` type `openat` takes. const FILE_MODE: libc::c_uint = 0o600; /// The permission bits of the group and other classes. const GROUP_OTHER_BITS: u32 = 0o077; @@ -69,11 +69,6 @@ impl Allowed { } } -/// Reports whether `gid` is a group of administrators, whose write access to a directory above doesn't count. -pub(crate) fn admin_gid(gid: libc::gid_t) -> bool { - os::ADMIN_GIDS.contains(&gid) -} - pub(crate) fn create_base(path: &Utf8Path, rule: &Rule) -> Result { let base = Base::open(path, rule)?; let mut dir = base.dir; @@ -110,23 +105,29 @@ pub(crate) fn open_base(path: &Utf8Path, rule: &Rule) -> Result> { pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, existing: Existing) -> Result { check_creatable(path, rule, Kind::Directory)?; - os::refuse_inheritable_acl(&parent.file, parent_path(path))?; let c_name = c_name(path, name)?; - // SAFETY: The parent descriptor is open and the name is NUL-terminated. - let created = unsafe { libc::mkdirat(parent.file.as_raw_fd(), c_name.as_ptr(), MKDIR_MODE) } == 0; - if !created { - let error = io::Error::last_os_error(); - if error.raw_os_error() != Some(libc::EEXIST) { - return Err(Error::io(path, "create directory", error)); + let open = || open_at(parent, path, name, libc::O_RDONLY | libc::O_DIRECTORY, Kind::Directory); + // A directory that exists is checked or repaired, so only one that must be created can inherit the parent's ACL. + let (file, created) = match open()? { + Some(file) => (file, false), + None => { + os::refuse_inheritable_acl(&parent.file, parent_path(path))?; + // SAFETY: The parent descriptor is open and the name is NUL-terminated. + let created = unsafe { libc::mkdirat(parent.file.as_raw_fd(), c_name.as_ptr(), MKDIR_MODE) } == 0; + if !created { + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::EEXIST) { + return Err(Error::io(path, "create directory", error)); + } + } + let file = open()?.ok_or_else(|| Error::io(path, "open directory", io::ErrorKind::NotFound.into()))?; + (file, created) } - } - let Some(file) = open_at(parent, path, name, libc::O_RDONLY | libc::O_DIRECTORY, Kind::Directory)? else { - return Err(Error::io(path, "open directory", io::ErrorKind::NotFound.into())); }; let dir = Dir { file }; if created { - if let Err(error) = protect_created_dir(&dir, path, rule) { - discard_entry(parent, &c_name, &dir.file, libc::AT_REMOVEDIR); + if let Err(error) = protect_created(&dir.file, path, rule, Kind::Directory) { + let _ = discard_entry(parent, path, &c_name, &dir.file, libc::AT_REMOVEDIR); return Err(error); } } else { @@ -188,8 +189,15 @@ pub(crate) fn create_file( options: FileOptions, ) -> Result { check_creatable(path, rule, Kind::File)?; - os::refuse_inheritable_acl(&parent.file, parent_path(path))?; let c_name = c_name(path, name)?; + // An existing entry fails the creation as it would without the ACL, which callers such as + // `Dir::open_or_create_file` rely on, so only a file that must be created can be refused for the parent's ACL. + match stat_at(&parent.file, &c_name) { + Ok(_) => return Err(Error::io(path, "create file", io::ErrorKind::AlreadyExists.into())), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(Error::io(path, "create file", error)), + } + os::refuse_inheritable_acl(&parent.file, parent_path(path))?; let flags = access_flags(options) | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NOCTTY; // SAFETY: The parent descriptor is open, the name is NUL-terminated, and O_CREAT takes the mode argument. @@ -199,8 +207,8 @@ pub(crate) fn create_file( } // SAFETY: openat returned a new descriptor that nothing else owns. let file = File::from(unsafe { OwnedFd::from_raw_fd(fd) }); - if let Err(error) = check_item(&file, path, rule, Kind::File) { - discard_entry(parent, &c_name, &file, 0); + if let Err(error) = protect_created(&file, path, rule, Kind::File) { + let _ = discard_entry(parent, path, &c_name, &file, 0); return Err(error); } Ok(file) @@ -217,12 +225,13 @@ pub(crate) fn rename(parent: &Dir, path: &Utf8Path, from: &str, to: &str, replac _ => Err(io::Error::last_os_error()), }, Replace::Refuse => match os::rename_exclusive(fd, &from, &to) { - Err(error) if error.raw_os_error().is_some_and(rename_exclusive_unsupported) => { + Err(error) if error.raw_os_error().is_some_and(os::exclusive_rename_unsupported) => { // UNTESTED: without an exclusive rename, the entry is linked under its new name, which fails when // the name exists, and then unlinked under its old one. // The kernels and file systems CI runs on support the exclusive rename. - // When changing this, keep the link step failing on an existing name rather than replacing it. - link_then_unlink(fd, &from, &to) + // When changing this, keep the link step failing on an existing name rather than replacing it, and a + // failed unlink leaving only the old name. + link_then_unlink(&parent.file, &from, &to) } renamed => renamed, }, @@ -261,24 +270,7 @@ pub(crate) fn remove_file(parent: &Dir, path: &Utf8Path, name: &str) -> Result Result<()> { - let c_name = c_name(path, name)?; - let created = fstat(file).map_err(|error| Error::io(path, "inspect file", error))?; - let current = match stat_at(&parent.file, &c_name) { - Ok(current) => current, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), - Err(error) => return Err(Error::io(path, "inspect file", error)), - }; - if !same_file(&created, ¤t) { - return Ok(()); - } - // SAFETY: The descriptor is open and the name is NUL-terminated. - if unsafe { libc::unlinkat(parent.file.as_raw_fd(), c_name.as_ptr(), 0) } != 0 { - let error = io::Error::last_os_error(); - if error.kind() != io::ErrorKind::NotFound { - return Err(Error::io(path, "remove file", error)); - } - } - Ok(()) + discard_entry(parent, path, &c_name(path, name)?, file, 0) } pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { @@ -391,6 +383,9 @@ struct Base<'a> { impl<'a> Base<'a> { fn open(path: &'a Utf8Path, rule: &Rule) -> Result { let mut missing = Vec::new(); + // A lookup refused for lack of search permission, which the walk up then attributes to the deepest directory + // it can look up. + let mut denied = None; let mut components = path.components(); let (prefix, candidate) = loop { let prefix = components.as_path(); @@ -399,28 +394,50 @@ impl<'a> Base<'a> { } else { prefix }; - match std::fs::symlink_metadata(candidate) { + let found = std::fs::symlink_metadata(candidate); + if found.is_ok() + && let Some(error) = denied.take() + { + return Err(Error::refused(candidate, Refusal::Unreadable(error))); + } + let error = match found { Ok(metadata) if metadata.file_type().is_symlink() => { return Err(Error::refused(candidate, Refusal::Link)); } Ok(metadata) if !metadata.is_dir() => return Err(Error::refused(candidate, Refusal::NotADirectory)), Ok(_) => break (prefix.to_owned(), candidate), + Err(error) if permission_denied(&error) => { + denied.get_or_insert(error); + None + } Err(error) if error.kind() == io::ErrorKind::NotFound || error.raw_os_error() == Some(libc::ENOTDIR) => { - match components.next_back() { - Some(Utf8Component::Normal(name)) => missing.push(name), - Some(_) => { - return Err(Error::io( - candidate, - "use directory path", - io::ErrorKind::InvalidInput.into(), - )); - } - None => return Err(Error::io(candidate, "inspect directory", error)), - } + Some(error) } Err(error) => return Err(Error::io(candidate, "inspect directory", error)), + }; + match components.next_back() { + Some(Utf8Component::Normal(name)) => missing.push(name), + // A walk that only looks for the directory to blame skips any component. + Some(_) if denied.is_some() => {} + Some(_) => { + return Err(Error::io( + candidate, + "use directory path", + io::ErrorKind::InvalidInput.into(), + )); + } + None => { + return Err(match (denied, error) { + (Some(denied), _) => Error::refused(candidate, Refusal::Unreadable(denied)), + (None, error) => Error::io( + candidate, + "inspect directory", + error.unwrap_or_else(|| io::ErrorKind::NotFound.into()), + ), + }); + } } }; missing.reverse(); @@ -431,6 +448,7 @@ impl<'a> Base<'a> { .map_err(|error| match std::fs::symlink_metadata(candidate) { Ok(metadata) if metadata.file_type().is_symlink() => Error::refused(candidate, Refusal::Link), Ok(metadata) if !metadata.is_dir() => Error::refused(candidate, Refusal::NotADirectory), + _ if permission_denied(&error) => Error::refused(candidate, Refusal::Unreadable(error)), _ => Error::io(candidate, "open directory", error), })?; check_ancestors(&file, candidate, rule)?; @@ -445,7 +463,13 @@ impl<'a> Base<'a> { /// Refuses when an account outside the system account, administrators and the accounts of `rule` owns the open /// directory `dir`, at `path`, or a directory above it on its resolved path, or can rename or replace one of them. fn check_ancestors(dir: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { - let resolved = std::fs::canonicalize(path).map_err(|error| Error::io(path, "resolve directory", error))?; + let resolved = std::fs::canonicalize(path).map_err(|error| { + if permission_denied(&error) { + Error::refused(path, Refusal::Unreadable(error)) + } else { + Error::io(path, "resolve directory", error) + } + })?; let resolved = Utf8PathBuf::from_path_buf(resolved) .map_err(|_| Error::io(path, "resolve directory", io::ErrorKind::InvalidData.into()))?; let opened = dir @@ -494,17 +518,19 @@ fn check_not_replaceable_path(path: &Utf8Path, allowed: &Allowed) -> Result<()> } fn check_not_replaceable(path: &Utf8Path, metadata: &Metadata, acl: &os::AncestorAcl, allowed: &Allowed) -> Result<()> { - if !allowed.uid(metadata.uid()) || replaceable(metadata.mode(), metadata.gid(), acl, allowed) { + if !allowed.uid(metadata.uid()) || replaceable(metadata.mode(), acl, allowed) { return Err(Error::refused(path, Refusal::Replaceable)); } Ok(()) } /// Reports whether an account that `allowed` doesn't name can rename or replace the entries of a directory with -/// `mode`, group `gid` and `acl`. +/// `mode` and `acl`. /// /// Write access to a directory with the sticky bit only lets an account add entries and remove its own. -fn replaceable(mode: u32, gid: libc::gid_t, acl: &os::AncestorAcl, allowed: &Allowed) -> bool { +/// Group write counts whatever the group, root's included: administrators are root alone, and a group's members need +/// not be root. +fn replaceable(mode: u32, acl: &os::AncestorAcl, allowed: &Allowed) -> bool { let sticky = mode & STICKY != 0; if os::acl_lets_replace(acl, sticky, allowed) { return true; @@ -513,24 +539,24 @@ fn replaceable(mode: u32, gid: libc::gid_t, acl: &os::AncestorAcl, allowed: &All return false; } let group_write = os::owning_group_writes(acl).unwrap_or(mode & GROUP_WRITE != 0); - mode & OTHER_WRITE != 0 || (group_write && !admin_gid(gid)) + mode & OTHER_WRITE != 0 || group_write } -fn protect_created_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { - let mode = dir - .file - .metadata() - .map_err(|error| Error::io(path, "inspect directory", error))? - .mode(); - // UNTESTED: a umask that removes owner bits doesn't leave the new directory unusable. +/// Gives the directory or file that was just created as `file` its rule's mode, then checks it. +fn protect_created(file: &File, path: &Utf8Path, rule: &Rule, kind: Kind) -> Result<()> { + let (expected, step) = match kind { + Kind::Directory => (DIR_MODE, "inspect directory"), + Kind::File => (FILE_MODE, "inspect file"), + }; + let mode = file.metadata().map_err(|error| Error::io(path, step, error))?.mode(); + // UNTESTED: a umask that removes owner bits doesn't leave the new directory or file without the owner's access. // The umask is process-wide, so a test can't set one without racing every other test. // When changing this, keep the mode change before the check. - if mode & 0o777 != DIR_MODE { - dir.file - .set_permissions(Permissions::from_mode(DIR_MODE)) + if mode & 0o777 != expected { + file.set_permissions(Permissions::from_mode(expected)) .map_err(|error| Error::io(path, "change mode", error))?; } - check_item(&dir.file, path, rule, Kind::Directory) + check_item(file, path, rule, kind) } fn check_item(file: &File, path: &Utf8Path, rule: &Rule, kind: Kind) -> Result<()> { @@ -685,31 +711,54 @@ fn open_at(parent: &Dir, path: &Utf8Path, name: &str, flags: libc::c_int, kind: }) } -/// Removes the entry `name` of `parent` if it is still `file`, after a creation whose check failed. -fn discard_entry(parent: &Dir, name: &CStr, file: &File, flags: libc::c_int) { - let (Ok(created), Ok(current)) = (fstat(file), stat_at(&parent.file, name)) else { - return; - }; - if same_file(&created, ¤t) { - // SAFETY: The descriptor is open and the name is NUL-terminated. - let _ = unsafe { libc::unlinkat(parent.file.as_raw_fd(), name.as_ptr(), flags) }; +/// Removes the entry `name` of `parent`, at `path`, if it is still the file or directory `file`. +/// +/// `flags` is `AT_REMOVEDIR` for a directory, as `unlinkat` takes it. +fn discard_entry(parent: &Dir, path: &Utf8Path, name: &CStr, file: &File, flags: libc::c_int) -> Result<()> { + let created = fstat(file).map_err(|error| Error::io(path, "inspect created entry", error))?; + match stat_at(&parent.file, name) { + Ok(current) if same_file(&created, ¤t) => {} + Ok(_) => return Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(Error::io(path, "inspect created entry", error)), } + // SAFETY: The descriptor is open and the name is NUL-terminated. + if unsafe { libc::unlinkat(parent.file.as_raw_fd(), name.as_ptr(), flags) } != 0 { + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::NotFound { + return Err(Error::io(path, "remove created entry", error)); + } + } + Ok(()) } -fn link_then_unlink(fd: libc::c_int, from: &CStr, to: &CStr) -> io::Result<()> { +/// Renames `from` to `to` in `dir` without replacing `to`, for a file system without an exclusive rename. +/// +/// When the old name can't be removed, the new one is removed again, so the entry keeps only its old name. +fn link_then_unlink(dir: &File, from: &CStr, to: &CStr) -> io::Result<()> { + let fd = dir.as_raw_fd(); // SAFETY: The descriptor is open and both names are NUL-terminated. if unsafe { libc::linkat(fd, from.as_ptr(), fd, to.as_ptr(), 0) } != 0 { return Err(io::Error::last_os_error()); } // SAFETY: The descriptor is open and the name is NUL-terminated. - if unsafe { libc::unlinkat(fd, from.as_ptr(), 0) } != 0 { - return Err(io::Error::last_os_error()); + if unsafe { libc::unlinkat(fd, from.as_ptr(), 0) } == 0 { + return Ok(()); } - Ok(()) + let error = io::Error::last_os_error(); + if let (Ok(source), Ok(linked)) = (stat_at(dir, from), stat_at(dir, to)) + && same_file(&source, &linked) + { + // SAFETY: The descriptor is open and the name is NUL-terminated. + let _ = unsafe { libc::unlinkat(fd, to.as_ptr(), 0) }; + } + Err(error) } -fn rename_exclusive_unsupported(code: libc::c_int) -> bool { - code == libc::ENOSYS || code == libc::EINVAL || code == libc::EOPNOTSUPP || code == libc::ENOTSUP +/// Reports whether `error` is a refusal for lack of permission, such as looking up an entry of a directory without +/// search permission on it. +fn permission_denied(error: &io::Error) -> bool { + matches!(error.raw_os_error(), Some(libc::EACCES | libc::EPERM)) } fn stat_at(dir: &File, name: &CStr) -> io::Result { @@ -796,7 +845,7 @@ mod tests { // A backend that let the owner keep less than full access, or chose a broader access than the rule, would leave // the owner, who can always change the mode, with access the rule withholds. - // oracle: intent crates/private-fs/INTENT.md §rules + // oracle: reference crates/private-fs/src/rule.rs Access #[test] fn a_grant_this_platform_cannot_enforce_needs_a_fallback() { let rule = Rule::new() @@ -826,7 +875,7 @@ mod tests { ); } - // oracle: intent crates/private-fs/INTENT.md §rules + // oracle: reference crates/private-fs/src/rule.rs Rule::owner_fallback #[test] fn an_owner_without_full_access_needs_an_owner_fallback() { let rule = Rule::new().owner(Account::CurrentUser).grant_or( @@ -855,7 +904,7 @@ mod tests { } // Root has every access whatever the mode says, so a rule may give root, or a root owner, any access. - // oracle: intent crates/private-fs/INTENT.md §mechanisms + // oracle: reference crates/private-fs/src/rule.rs Account #[test] fn grants_to_root_need_nothing_on_disk() { let rule = Rule::new() @@ -872,7 +921,7 @@ mod tests { } // An account other than root that doesn't own the item gets no access from mode 0700 or 0600. - // oracle: intent crates/private-fs/INTENT.md §rules + // oracle: mutant a grant to an account other than root that does not own the item counted as enforced #[test] fn a_grant_to_a_user_who_does_not_own_the_item_is_unsupported() { let rule = Rule::new() @@ -892,7 +941,7 @@ mod tests { assert!(check(&rule, 0, USER, Kind::Directory).is_ok()); } - // oracle: intent crates/private-fs/INTENT.md §rules + // oracle: reference crates/private-fs/src/rule.rs Access::AddOnly #[test] fn add_only_access_to_a_file_is_unsupported_even_for_root() { let rule = Rule::new() @@ -906,9 +955,9 @@ mod tests { assert!(check(&rule, USER, USER, Kind::Directory).is_ok()); } - // oracle: intent crates/private-fs/INTENT.md §mechanisms + // oracle: reference crates/private-fs/src/rule.rs Rule::owner #[test] - fn only_root_and_the_accounts_a_rule_names_may_own_an_item() { + fn only_an_account_the_rule_names_may_own_an_item() { let current_user_only = Rule::new().owner(Account::CurrentUser); assert!(owner_allowed(¤t_user_only, USER, USER)); assert!(!owner_allowed(¤t_user_only, 0, USER)); @@ -920,18 +969,18 @@ mod tests { } // StrictModes: write access to a directory above lets an account rename or replace its entries, unless the sticky - // bit limits it to adding entries and removing its own, or the account is root or in an administrators group. + // bit limits it to adding entries and removing its own. + // Group write counts for any group, since administrators are root alone. // oracle: intent crates/private-fs/INTENT.md §paths #[test] fn write_access_for_others_makes_a_directory_above_replaceable() { let acl = os::AncestorAcl::empty(); let allowed = Allowed::new_for_tests(Some(USER)); - let staff = 20; - assert!(replaceable(0o777, staff, &acl, &allowed)); - assert!(replaceable(0o775, staff, &acl, &allowed)); - assert!(replaceable(0o757, 0, &acl, &allowed)); - assert!(!replaceable(0o1777, staff, &acl, &allowed)); - assert!(!replaceable(0o775, 0, &acl, &allowed)); - assert!(!replaceable(0o755, staff, &acl, &allowed)); + assert!(replaceable(0o777, &acl, &allowed)); + assert!(replaceable(0o775, &acl, &allowed)); + assert!(replaceable(0o757, &acl, &allowed)); + assert!(!replaceable(0o1777, &acl, &allowed)); + assert!(!replaceable(0o1775, &acl, &allowed)); + assert!(!replaceable(0o755, &acl, &allowed)); } } diff --git a/testsuite/Cargo.toml b/testsuite/Cargo.toml index 25c9b1c378..cbd210e7be 100644 --- a/testsuite/Cargo.toml +++ b/testsuite/Cargo.toml @@ -68,6 +68,8 @@ tokio-rustls = { version = "0.26", features = ["ring"] } uuid = { version = "1", features = ["v4"] } [target.'cfg(unix)'.dev-dependencies] +# Sets POSIX ACLs in the private-fs tests, which `setfacl` would need a package for. +libc = "0.2" sysevent-syslog.path = "../crates/sysevent-syslog" [target.'cfg(windows)'.dev-dependencies] diff --git a/testsuite/tests/private_fs/unix.rs b/testsuite/tests/private_fs/unix.rs index 6ea0b32e87..74e8090242 100644 --- a/testsuite/tests/private_fs/unix.rs +++ b/testsuite/tests/private_fs/unix.rs @@ -77,45 +77,116 @@ fn skip(test: &str, reason: &str) { let _ = writeln!(std::io::stderr(), "SKIP: {test} {reason}"); } -/// Runs a platform command that changes a test path's ACL, and returns `false` when the platform can't run it. -fn run_acl_command(program: &str, args: &[&str]) -> bool { - Command::new(program) - .args(args) - .status() - .is_ok_and(|status| status.success()) -} - -/// Gives another account read access to `path` through an extended ACL. -fn grant_others_read(path: &Utf8Path) -> bool { - if cfg!(target_os = "macos") { - run_acl_command("chmod", &["+a", "everyone allow read", path.as_str()]) - } else { - run_acl_command("setfacl", &["-m", "u:65534:r", path.as_str()]) +#[cfg(target_os = "linux")] +mod acl { + //! POSIX ACLs, set without `setfacl`, which a CI runner may lack. + //! + //! They are written as the kernel reads them from the `system.posix_acl_access` and `system.posix_acl_default` + //! extended attributes: a 4-byte little-endian version, 2, then 8-byte entries of a 2-byte tag, a 2-byte + //! permission set and a 4-byte ID, as `include/uapi/linux/posix_acl_xattr.h` defines them. + + use std::ffi::{CStr, CString}; + use std::os::unix::fs::MetadataExt as _; + + use camino::Utf8Path; + + const ACCESS: &CStr = c"system.posix_acl_access"; + const DEFAULT: &CStr = c"system.posix_acl_default"; + const USER_OBJ: u16 = 0x01; + const USER: u16 = 0x02; + const GROUP_OBJ: u16 = 0x04; + const MASK: u16 = 0x10; + const OTHER: u16 = 0x20; + /// The ID of an entry that names no account. + const NO_ID: u32 = u32::MAX; + const NOBODY: u32 = 65534; + + /// Sets the ACL attribute `name` of `path`, and returns `false` when its volume has no POSIX ACLs. + fn set(path: &Utf8Path, name: &CStr, entries: &[(u16, u16, u32)]) -> bool { + let mut value = 2u32.to_le_bytes().to_vec(); + for &(tag, perm, id) in entries { + value.extend_from_slice(&tag.to_le_bytes()); + value.extend_from_slice(&perm.to_le_bytes()); + value.extend_from_slice(&id.to_le_bytes()); + } + let path = CString::new(path.as_str()).expect("NUL-free test path"); + // SAFETY: The path and name are NUL-terminated, and the value is readable for its length. + let status = unsafe { libc::setxattr(path.as_ptr(), name.as_ptr(), value.as_ptr().cast(), value.len(), 0) }; + if status == 0 { + return true; + } + let error = std::io::Error::last_os_error(); + assert_eq!(error.raw_os_error(), Some(libc::EOPNOTSUPP), "set a test ACL: {error}"); + false + } + + /// The owner's permissions, which an access ACL's owner entry must repeat. + fn owner_perm(path: &Utf8Path) -> u16 { + let mode = std::fs::metadata(path).expect("inspect a test path").mode(); + u16::try_from((mode >> 6) & 0o7).expect("three permission bits") } -} -/// Gives the directory `path` an ACL that new entries inherit. -fn make_inheritable(path: &Utf8Path) -> bool { - if cfg!(target_os = "macos") { - run_acl_command( - "chmod", - &[ - "+a", - "everyone allow read,file_inherit,directory_inherit", - path.as_str(), - ], + /// Names nobody with read access, behind a mask that leaves it none, so the mode doesn't change. + pub(super) fn name_another_account(path: &Utf8Path) -> bool { + let entries = [ + (USER_OBJ, owner_perm(path), NO_ID), + (USER, 4, NOBODY), + (GROUP_OBJ, 0, NO_ID), + (MASK, 0, NO_ID), + (OTHER, 0, NO_ID), + ]; + set(path, ACCESS, &entries) + } + + /// Gives the directory `path` a default ACL, which new entries inherit. + pub(super) fn make_inheritable(path: &Utf8Path) -> bool { + set( + path, + DEFAULT, + &[(USER_OBJ, 7, NO_ID), (GROUP_OBJ, 0, NO_ID), (OTHER, 0, NO_ID)], ) - } else { - run_acl_command("setfacl", &["-d", "-m", "u::rwx", path.as_str()]) + } + + /// Lets nobody write to the directory `path`, while its owning group can only read and search. + /// + /// The mode's group bits then show the mask, rwx. + pub(super) fn let_others_replace(path: &Utf8Path) -> bool { + let entries = [ + (USER_OBJ, 7, NO_ID), + (USER, 7, NOBODY), + (GROUP_OBJ, 5, NO_ID), + (MASK, 7, NO_ID), + (OTHER, 5, NO_ID), + ]; + set(path, ACCESS, &entries) } } -/// Lets another account remove or rename the entries of the directory `path` through an extended ACL. -fn let_others_replace(path: &Utf8Path) -> bool { - if cfg!(target_os = "macos") { - run_acl_command("chmod", &["+a", "everyone allow delete_child", path.as_str()]) - } else { - run_acl_command("setfacl", &["-m", "u:65534:rwx", path.as_str()]) +/// Extended ACLs, set with `chmod +a`. +#[cfg(target_os = "macos")] +mod acl { + use std::process::Command; + + use camino::Utf8Path; + + fn add(path: &Utf8Path, entry: &str) -> bool { + Command::new("chmod") + .args(["+a", entry, path.as_str()]) + .status() + .is_ok_and(|status| status.success()) + } + + /// Gives everyone read access, which leaves the mode as it is. + pub(super) fn name_another_account(path: &Utf8Path) -> bool { + add(path, "everyone allow read") + } + + pub(super) fn make_inheritable(path: &Utf8Path) -> bool { + add(path, "everyone allow read,file_inherit,directory_inherit") + } + + pub(super) fn let_others_replace(path: &Utf8Path) -> bool { + add(path, "everyone allow delete_child") } } @@ -132,7 +203,7 @@ fn create_base_creates_each_missing_directory_owner_only() { assert!(Dir::open_base(&path, &rule()).unwrap().is_some()); } -// oracle: intent crates/private-fs/INTENT.md §paths +// oracle: reference crates/private-fs/src/lib.rs Dir::open_base #[test] fn open_base_returns_none_for_a_missing_directory() { let scratch = Scratch::new(); @@ -200,7 +271,7 @@ fn an_entry_that_is_a_symbolic_link_is_refused() { } // Another hard link lets whoever can reach it in another directory open the file. -// oracle: intent crates/private-fs/INTENT.md §mechanisms +// oracle: reference crates/private-fs/src/lib.rs Dir::open_file #[test] fn a_file_with_another_hard_link_is_refused() { let scratch = Scratch::new(); @@ -233,7 +304,7 @@ enum Special { } // Opening a FIFO for reading blocks until a writer comes, unless the open doesn't wait. -// oracle: intent crates/private-fs/INTENT.md §mechanisms +// oracle: reference crates/private-fs/src/error.rs Refusal::NotAFile #[rstest] #[case::fifo(Special::Fifo)] #[case::socket(Special::Socket)] @@ -276,6 +347,7 @@ fn a_directory_above_that_others_can_write_is_refused() { assert!(!shared.join("base").exists()); } +// Any member of the group can rename or replace the directory's entries, and administrators are root alone. // oracle: intent crates/private-fs/INTENT.md §paths #[test] fn a_directory_above_that_its_group_can_write_is_refused() { @@ -283,16 +355,30 @@ fn a_directory_above_that_its_group_can_write_is_refused() { let shared = scratch.path.join("shared"); fs::create_dir(&shared).unwrap(); set_mode(&shared, 0o770); - // Root's group, and `admin` on macOS, are administrators, whose write access doesn't count. - if [0, 80].contains(&fs::metadata(&shared).unwrap().gid()) { + + let error = Dir::open_base(&shared, &rule()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Replaceable)), "{error:?}"); + assert_eq!(error.path(), canonical(&shared)); +} + +// A member of root's group need not be root, as members of `wheel` and `admin` on macOS are not. +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn root_only_a_directory_above_that_roots_group_can_write_is_refused() { + let scratch = Scratch::new(); + if !scratch.as_root() { skip( - "a_directory_above_that_its_group_can_write_is_refused", - "needs a directory group other than administrators", + "root_only_a_directory_above_that_roots_group_can_write_is_refused", + "needs root", ); return; } + let shared = scratch.path.join("shared"); + fs::create_dir(&shared).unwrap(); + std::os::unix::fs::chown(&shared, None, Some(0)).unwrap(); + set_mode(&shared, 0o770); - let error = Dir::open_base(&shared, &rule()).unwrap_err(); + let error = Dir::create_base(&shared.join("base"), &rule()).unwrap_err(); assert!(matches!(refusal(&error), Some(Refusal::Replaceable)), "{error:?}"); assert_eq!(error.path(), canonical(&shared)); } @@ -317,10 +403,10 @@ fn a_directory_above_that_an_acl_lets_others_replace_is_refused() { let shared = scratch.path.join("shared"); fs::create_dir(&shared).unwrap(); set_mode(&shared, 0o755); - if !let_others_replace(&shared) { + if !acl::let_others_replace(&shared) { skip( "a_directory_above_that_an_acl_lets_others_replace_is_refused", - "needs ACL support and its tools", + "needs ACL support", ); return; } @@ -330,14 +416,34 @@ fn a_directory_above_that_an_acl_lets_others_replace_is_refused() { assert_eq!(error.path(), canonical(&shared)); } +// A POSIX ACL entry is group-class access, which the sticky bit limits as it does the mode's group bits. +// oracle: intent crates/private-fs/INTENT.md §paths +#[cfg(target_os = "linux")] +#[test] +fn a_directory_above_that_an_acl_lets_others_write_is_accepted_with_the_sticky_bit() { + let scratch = Scratch::new(); + let shared = scratch.path.join("shared"); + fs::create_dir(&shared).unwrap(); + set_mode(&shared, 0o1755); + if !acl::let_others_replace(&shared) { + skip( + "a_directory_above_that_an_acl_lets_others_write_is_accepted_with_the_sticky_bit", + "needs ACL support", + ); + return; + } + + Dir::create_base(&shared.join("base"), &rule()).unwrap(); +} + // A directory above whose entries can't be read could hide a replaceable directory from the check. // oracle: intent crates/private-fs/INTENT.md §paths #[test] -fn a_path_through_a_directory_that_cannot_be_searched_fails() { +fn a_directory_that_cannot_be_searched_is_unreadable() { let scratch = Scratch::new(); if scratch.as_root() { skip( - "a_path_through_a_directory_that_cannot_be_searched_fails", + "a_directory_that_cannot_be_searched_is_unreadable", "needs an account other than root", ); return; @@ -348,7 +454,9 @@ fn a_path_through_a_directory_that_cannot_be_searched_fails() { let result = Dir::create_base(&locked.join("inner").join("base"), &rule()); set_mode(&locked, 0o700); - assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Unreadable(_))), "{error:?}"); + assert_eq!(error.path(), locked); assert!(!locked.join("inner").join("base").exists()); } @@ -358,13 +466,12 @@ fn a_file_with_an_extended_acl_is_refused() { let scratch = Scratch::new(); let base = scratch.base(); drop(base.create_file("key", &rule(), FileOptions::new()).unwrap()); - if !grant_others_read(&scratch.path.join("key")) { - skip( - "a_file_with_an_extended_acl_is_refused", - "needs ACL support and its tools", - ); + if !acl::name_another_account(&scratch.path.join("key")) { + skip("a_file_with_an_extended_acl_is_refused", "needs ACL support"); return; } + // Only the ACL can fail the check. + assert_eq!(mode(&scratch.path.join("key")), 0o600); let error = base.open_file("key", &rule(), FileOptions::new()).unwrap_err(); assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); @@ -377,11 +484,8 @@ fn nothing_is_created_under_an_inheritable_acl() { let scratch = Scratch::new(); let base = scratch.base(); let parent = base.create_dir("parent", &rule(), Existing::Check).unwrap(); - if !make_inheritable(&scratch.path.join("parent")) { - skip( - "nothing_is_created_under_an_inheritable_acl", - "needs ACL support and its tools", - ); + if !acl::make_inheritable(&scratch.path.join("parent")) { + skip("nothing_is_created_under_an_inheritable_acl", "needs ACL support"); return; } @@ -390,6 +494,11 @@ fn nothing_is_created_under_an_inheritable_acl() { let error = parent.create_file("file", &rule(), FileOptions::new()).unwrap_err(); assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); assert_eq!(parent.entries().unwrap().len(), 0); + let error = parent.check(&rule()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); + + parent.repair(&rule()).unwrap(); + drop(parent.create_file("file", &rule(), FileOptions::new()).unwrap()); } // oracle: intent crates/private-fs/INTENT.md §paths @@ -398,8 +507,8 @@ fn repair_removes_an_extended_acl() { let scratch = Scratch::new(); let base = scratch.base(); drop(base.create_dir("dir", &rule(), Existing::Check).unwrap()); - if !grant_others_read(&scratch.path.join("dir")) || !make_inheritable(&scratch.path.join("dir")) { - skip("repair_removes_an_extended_acl", "needs ACL support and its tools"); + if !acl::name_another_account(&scratch.path.join("dir")) || !acl::make_inheritable(&scratch.path.join("dir")) { + skip("repair_removes_an_extended_acl", "needs ACL support"); return; } let error = base.create_dir("dir", &rule(), Existing::Check).unwrap_err(); @@ -438,7 +547,7 @@ fn root_only_an_item_owned_by_another_account_is_refused_until_repaired() { } // private-fs never chooses a broader access than the rule: an owner other than root can always change the mode. -// oracle: intent crates/private-fs/INTENT.md §rules +// oracle: reference crates/private-fs/src/rule.rs Access #[test] fn a_grant_the_platform_cannot_enforce_fails_without_a_fallback() { let scratch = Scratch::new(); @@ -464,7 +573,7 @@ fn a_grant_the_platform_cannot_enforce_fails_without_a_fallback() { Dir::create_base(&scratch.path.join("dir"), &with_fallback).unwrap(); } -// oracle: intent crates/private-fs/INTENT.md §rules +// oracle: reference crates/private-fs/src/rule.rs Access::AddOnly #[test] fn add_only_access_to_a_file_fails_without_a_fallback() { let scratch = Scratch::new(); @@ -478,7 +587,7 @@ fn add_only_access_to_a_file_fails_without_a_fallback() { } // private-fs creates what it creates owned by the current user, which the rule must allow. -// oracle: intent crates/private-fs/INTENT.md §paths +// oracle: reference crates/private-fs/src/rule.rs Rule::owner #[test] fn a_rule_that_does_not_let_the_current_user_own_cannot_create() { let scratch = Scratch::new(); @@ -491,7 +600,7 @@ fn a_rule_that_does_not_let_the_current_user_own_cannot_create() { assert!(!scratch.path.join("dir").exists()); } -// oracle: intent crates/private-fs/INTENT.md §paths +// oracle: reference crates/private-fs/src/lib.rs Dir::persist #[test] fn persist_installs_the_written_file_under_its_name() { let scratch = Scratch::new(); @@ -514,7 +623,7 @@ fn persist_installs_the_written_file_under_its_name() { ); } -// oracle: intent crates/private-fs/INTENT.md §paths +// oracle: reference crates/private-fs/src/lib.rs Replace #[test] fn a_rename_that_refuses_replacement_keeps_the_existing_entry() { let scratch = Scratch::new(); @@ -540,7 +649,7 @@ fn a_rename_that_refuses_replacement_keeps_the_existing_entry() { assert!(!scratch.path.join("new").exists()); } -// oracle: intent crates/private-fs/INTENT.md §paths +// oracle: reference crates/private-fs/src/lib.rs Dir::remove_file #[test] fn remove_file_reports_whether_the_file_existed() { let scratch = Scratch::new(); @@ -550,8 +659,25 @@ fn remove_file_reports_whether_the_file_existed() { assert!(!base.remove_file("key").unwrap()); } +// A discard that removed by name alone would remove whatever was installed under the name since. +// oracle: reference crates/private-fs/src/lib.rs Dir::discard +#[test] +fn discard_removes_only_the_file_it_was_given() { + let scratch = Scratch::new(); + let base = scratch.base(); + let created = base.create_file("key.tmp", &rule(), FileOptions::new()).unwrap(); + base.discard(&created, "key.tmp").unwrap(); + assert!(!scratch.path.join("key.tmp").exists()); + + let renamed = base.create_file("key.tmp", &rule(), FileOptions::new()).unwrap(); + base.rename("key.tmp", "key", Replace::Refuse).unwrap(); + drop(base.create_file("key.tmp", &rule(), FileOptions::new()).unwrap()); + base.discard(&renamed, "key.tmp").unwrap(); + assert!(scratch.path.join("key.tmp").exists()); +} + // A listing that followed links would report a link to a directory as a directory. -// oracle: intent crates/private-fs/INTENT.md §mechanisms +// oracle: reference crates/private-fs/src/lib.rs EntryKind #[test] fn entries_report_kinds_without_following_links() { let scratch = Scratch::new(); From 22d5926e5b1e3195719f644de885abc8240a5d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 16:34:57 +0900 Subject: [PATCH 12/60] fix(private-fs): read Linux ACLs at their size An ACL attribute is read into a buffer that fits a few dozen entries, and read again at the size the kernel reports when it is larger, instead of zero-filling 64 KiB for every check. The renameat2 system call gets each argument as the long that the variadic syscall function reads. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/linux.rs | 87 +++++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 11 deletions(-) diff --git a/crates/private-fs/src/linux.rs b/crates/private-fs/src/linux.rs index e8f4360df9..177d4185df 100644 --- a/crates/private-fs/src/linux.rs +++ b/crates/private-fs/src/linux.rs @@ -22,6 +22,10 @@ const DEFAULT_ACL: &CStr = c"system.posix_acl_default"; const ACL_VERSION: u32 = 2; /// `XATTR_SIZE_MAX`, the largest extended attribute value the kernel stores. const XATTR_SIZE_MAX: usize = 65536; +/// The size of the first read of an ACL attribute, which holds an ACL of up to 32 entries; a larger one is read again. +const ACL_READ_SIZE: usize = 4 /* a_version */ + 32 * 8 /* a_entries */; +/// [`libc::RENAME_NOREPLACE`] as the `long` that `syscall` reads its arguments as. +const RENAME_NOREPLACE: libc::c_long = 1; const ACL_USER_OBJ: u16 = 0x01; const ACL_USER: u16 = 0x02; @@ -169,15 +173,17 @@ pub(crate) fn owning_group_writes(acl: &AncestorAcl) -> Option { pub(crate) fn rename_exclusive(fd: libc::c_int, from: &CStr, to: &CStr) -> io::Result<()> { // The raw system call avoids glibc's renameat2 wrapper, which needs glibc 2.28; the release baseline is 2.27. + // `syscall` reads each argument as a `long`, so each is passed as one. + let fd = libc::c_long::from(fd); // SAFETY: The descriptor is open and both names are NUL-terminated. let status = unsafe { libc::syscall( libc::SYS_renameat2, fd, - from.as_ptr(), + from.as_ptr() as libc::c_long, fd, - to.as_ptr(), - libc::RENAME_NOREPLACE, + to.as_ptr() as libc::c_long, + RENAME_NOREPLACE, ) }; if status != 0 { @@ -219,15 +225,31 @@ fn read_acl(file: &File, name: &CStr) -> io::Result>> { } /// Reads an ACL attribute with `get`, which takes a buffer and its size as `getxattr` does. -fn read_xattr(get: impl FnOnce(*mut libc::c_void, usize) -> isize) -> io::Result>> { - let mut buffer = vec![0u8; XATTR_SIZE_MAX]; - let size = get(buffer.as_mut_ptr().cast(), buffer.len()); - let Ok(size) = usize::try_from(size) else { +fn read_xattr(get: impl Fn(*mut libc::c_void, usize) -> isize) -> io::Result>> { + let mut buffer = vec![0u8; ACL_READ_SIZE]; + // INVARIANT: the buffer grows on each retry and never past XATTR_SIZE_MAX, so the loop ends. + loop { + let size = get(buffer.as_mut_ptr().cast(), buffer.len()); + if let Ok(size) = usize::try_from(size) { + buffer.truncate(size); + return parse_acl(&buffer).map(Some); + } let error = io::Error::last_os_error(); - return if no_acl(&error) { Ok(None) } else { Err(error) }; - }; - buffer.truncate(size); - parse_acl(&buffer).map(Some) + if no_acl(&error) { + return Ok(None); + } + if error.raw_os_error() != Some(libc::ERANGE) || buffer.len() == XATTR_SIZE_MAX { + return Err(error); + } + // The attribute is larger than the buffer: ask for its size, which may grow again before the next read. + let needed = get(std::ptr::null_mut(), 0); + let Ok(needed) = usize::try_from(needed) else { + let error = io::Error::last_os_error(); + return if no_acl(&error) { Ok(None) } else { Err(error) }; + }; + let grown = needed.max(buffer.len() * 2).min(XATTR_SIZE_MAX); + buffer.resize(grown, 0); + } } fn no_acl(error: &io::Error) -> bool { @@ -315,6 +337,49 @@ mod tests { ); } + // A reader that kept the first, small buffer would fail on a large ACL, or read part of it. + // oracle: spec getxattr(2) §ERRORS + #[test] + fn reads_an_attribute_larger_than_the_first_buffer() { + let entries: Vec<[u8; 8]> = (0..40).map(|id| entry(ACL_USER, 4, id)).collect(); + let value = attribute(&entries); + assert!(ACL_READ_SIZE < value.len()); + // Behaves as getxattr does: a zero size asks for the value's size, and a smaller buffer fails with ERANGE. + let get = |buffer: *mut libc::c_void, size: usize| -> isize { + if size == 0 { + return isize::try_from(value.len()).expect("small value"); + } + if size < value.len() { + clear_errno(); + // SAFETY: __errno_location has no preconditions and returns the calling thread's errno. + let errno = unsafe { libc::__errno_location() }; + // SAFETY: The pointer is valid for the calling thread. + unsafe { *errno = libc::ERANGE }; + return -1; + } + // SAFETY: read_xattr passes a buffer writable for `size` bytes, which holds the value. + unsafe { std::ptr::copy_nonoverlapping(value.as_ptr(), buffer.cast::(), value.len()) }; + isize::try_from(value.len()).expect("small value") + }; + + let read = read_xattr(get).expect("read").expect("an ACL"); + assert_eq!(read.len(), 40); + assert_eq!( + read[39], + AclEntry { + tag: ACL_USER, + perm: 4, + id: 39 + } + ); + } + + // oracle: spec Linux include/uapi/linux/fs.h RENAME_NOREPLACE + #[test] + fn passes_the_kernel_flag_for_an_exclusive_rename() { + assert_eq!(u32::try_from(RENAME_NOREPLACE).ok(), Some(libc::RENAME_NOREPLACE)); + } + // oracle: spec Linux include/uapi/linux/posix_acl_xattr.h #[test] fn rejects_an_attribute_it_cannot_read() { From ac7da4c628a140c8704dbd128781370139ea6cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 17:10:03 +0900 Subject: [PATCH 13/60] fix(private-fs): report a link before an unreadable directory While looking for the first existing directory, a link is now refused as a link even when the directory it points to can't be searched, and only a directory is reported as unreadable. Tests also pin that an existing entry under an inheritable ACL is checked as it is, and that the stock macOS Application Support directory, under which the agent keeps its data, is a valid base. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/linux.rs | 2 +- crates/private-fs/src/unix.rs | 23 ++++++----- testsuite/tests/private_fs/unix.rs | 62 +++++++++++++++++++++++++++++- 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/crates/private-fs/src/linux.rs b/crates/private-fs/src/linux.rs index 177d4185df..b3f1768758 100644 --- a/crates/private-fs/src/linux.rs +++ b/crates/private-fs/src/linux.rs @@ -38,7 +38,7 @@ const ACL_WRITE: u16 = 0x02; const ACL_ALL: u16 = 0x07; /// The `statfs` types of volumes that ignore or fake ownership: FUSE, CIFS, SMB2, SMB, NFS, 9P, vboxsf, MSDOS/vfat, -/// exFAT, ntfs3, NTFS (ntfs and ntfs-3g's kernel type) and UDF. +/// exFAT, ntfs3, NTFS (the legacy in-kernel ntfs driver; ntfs-3g is FUSE) and UDF. const IGNORING_VOLUMES: [u32; 12] = [ 0x6573_5546, 0xFF53_4D42, diff --git a/crates/private-fs/src/unix.rs b/crates/private-fs/src/unix.rs index 9951d682fe..0325e25cfa 100644 --- a/crates/private-fs/src/unix.rs +++ b/crates/private-fs/src/unix.rs @@ -193,7 +193,13 @@ pub(crate) fn create_file( // An existing entry fails the creation as it would without the ACL, which callers such as // `Dir::open_or_create_file` rely on, so only a file that must be created can be refused for the parent's ACL. match stat_at(&parent.file, &c_name) { - Ok(_) => return Err(Error::io(path, "create file", io::ErrorKind::AlreadyExists.into())), + Ok(_) => { + return Err(Error::io( + path, + "create file", + io::Error::from_raw_os_error(libc::EEXIST), + )); + } Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) => return Err(Error::io(path, "create file", error)), } @@ -394,18 +400,17 @@ impl<'a> Base<'a> { } else { prefix }; - let found = std::fs::symlink_metadata(candidate); - if found.is_ok() - && let Some(error) = denied.take() - { - return Err(Error::refused(candidate, Refusal::Unreadable(error))); - } - let error = match found { + let error = match std::fs::symlink_metadata(candidate) { Ok(metadata) if metadata.file_type().is_symlink() => { return Err(Error::refused(candidate, Refusal::Link)); } Ok(metadata) if !metadata.is_dir() => return Err(Error::refused(candidate, Refusal::NotADirectory)), - Ok(_) => break (prefix.to_owned(), candidate), + Ok(_) => { + if let Some(error) = denied { + return Err(Error::refused(candidate, Refusal::Unreadable(error))); + } + break (prefix.to_owned(), candidate); + } Err(error) if permission_denied(&error) => { denied.get_or_insert(error); None diff --git a/testsuite/tests/private_fs/unix.rs b/testsuite/tests/private_fs/unix.rs index 74e8090242..98adb6face 100644 --- a/testsuite/tests/private_fs/unix.rs +++ b/testsuite/tests/private_fs/unix.rs @@ -460,6 +460,32 @@ fn a_directory_that_cannot_be_searched_is_unreadable() { assert!(!locked.join("inner").join("base").exists()); } +// A link reached while looking for the first existing directory is refused as a link, even when what it points to +// can't be searched. +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn a_link_to_a_directory_that_cannot_be_searched_is_a_link() { + let scratch = Scratch::new(); + if scratch.as_root() { + skip( + "a_link_to_a_directory_that_cannot_be_searched_is_a_link", + "needs an account other than root", + ); + return; + } + let locked = scratch.path.join("locked"); + fs::create_dir_all(locked.join("inner")).unwrap(); + let link = scratch.path.join("link"); + symlink(&locked, &link).unwrap(); + set_mode(&locked, 0o000); + + let result = Dir::create_base(&link.join("inner").join("base"), &rule()); + set_mode(&locked, 0o700); + let error = result.unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Link)), "{error:?}"); + assert_eq!(error.path(), link); +} + // oracle: intent crates/private-fs/INTENT.md §mechanisms #[test] fn a_file_with_an_extended_acl_is_refused() { @@ -484,6 +510,12 @@ fn nothing_is_created_under_an_inheritable_acl() { let scratch = Scratch::new(); let base = scratch.base(); let parent = base.create_dir("parent", &rule(), Existing::Check).unwrap(); + drop(parent.create_dir("existing", &rule(), Existing::Check).unwrap()); + drop( + parent + .create_file("existing-file", &rule(), FileOptions::new()) + .unwrap(), + ); if !acl::make_inheritable(&scratch.path.join("parent")) { skip("nothing_is_created_under_an_inheritable_acl", "needs ACL support"); return; @@ -493,7 +525,20 @@ fn nothing_is_created_under_an_inheritable_acl() { assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); let error = parent.create_file("file", &rule(), FileOptions::new()).unwrap_err(); assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); - assert_eq!(parent.entries().unwrap().len(), 0); + assert!(!scratch.path.join("parent").join("dir").exists()); + assert!(!scratch.path.join("parent").join("file").exists()); + + // What already exists inherited nothing, so it is checked as it is, and an existing file still fails a creation as + // existing. + parent.create_dir("existing", &rule(), Existing::Check).unwrap(); + let error = parent + .create_file("existing-file", &rule(), FileOptions::new()) + .unwrap_err(); + assert!( + matches!(error.kind(), ErrorKind::Io { source, .. } if source.kind() == std::io::ErrorKind::AlreadyExists), + "{error:?}" + ); + let error = parent.check(&rule()).unwrap_err(); assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); @@ -704,3 +749,18 @@ fn entries_report_kinds_without_following_links() { ] ); } + +// The agent's default macOS data directory lives under `/Library/Application Support`, as +// `crates/devolutions-agent-shared/src/lib.rs` sets it, so that directory and `/Library` must pass the check of the +// directories above a base on a stock Mac. +// The test fails rather than skips, so a stock macOS that fails the check shows on CI. +// oracle: intent crates/private-fs/INTENT.md §paths +#[cfg(target_os = "macos")] +#[test] +fn the_stock_macos_application_support_directory_is_a_valid_base() { + let rule = Rule::new() + .owner(Account::CurrentUser) + .grant(Account::System, Access::Full); + let base = Dir::open_base(Utf8Path::new("/Library/Application Support"), &rule).unwrap(); + assert!(base.is_some()); +} From cf84b7745ee617167266880e56a41cdb49cb0ff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 17:11:36 +0900 Subject: [PATCH 14/60] fix(agent): require the agent's account to own identity state The identity directories, authority directories, `identity.json`, rejection markers, in-progress records and the Unix lock file are now accepted only when the agent's account owns them, on every platform, as the agent-identity intent states. SYSTEM or root ownership no longer passes when the agent runs as another account; in production the agent is SYSTEM or root, so nothing changes there. Repairing an identity directory makes the agent's account its owner. Pending-enrollment files keep their owners: the agent's account, SYSTEM or root, and administrators. Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/agent-identity/src/private_file.rs | 24 +++++++++++------------ 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/crates/agent-identity/src/private_file.rs b/crates/agent-identity/src/private_file.rs index 1e85291085..ea97c73c05 100644 --- a/crates/agent-identity/src/private_file.rs +++ b/crates/agent-identity/src/private_file.rs @@ -88,12 +88,13 @@ fn granted(rule: Rule, grant_current_user: bool) -> Rule { /// The rule of ``, `identity`, `authorities`, `keys`, `rejected`, every authority directory and every /// `identity.json`. +/// +/// [`crates/agent-identity/INTENT.md`] ("Local state"): they are owned by the agent's account, which in production is +/// SYSTEM or root. +/// +/// [`crates/agent-identity/INTENT.md`]: ../INTENT.md pub(crate) fn tree_rule(grant_current_user: bool) -> Rule { - granted( - Rule::new().owner(Account::CurrentUser).owner(Account::System), - grant_current_user, - ) - .exact() + granted(Rule::new().owner(Account::CurrentUser), grant_current_user).exact() } /// The rule of `identity/pending`, a drop box administrators can add a file to without listing or reading it. @@ -101,13 +102,10 @@ fn drop_box_rule(grant_current_user: bool) -> Rule { tree_rule(grant_current_user).grant(Account::Administrators, Access::AddOnly) } -/// The rule of in-progress records, rejection markers and the Unix lock file, which are checked for giving no account -/// more than it allows. +/// The rule of in-progress records, rejection markers and the Unix lock file, which the agent's account owns and which +/// are checked for giving no account more than it allows. pub(crate) fn state_file_rule(grant_current_user: bool) -> Rule { - granted( - Rule::new().owner(Account::CurrentUser).owner(Account::System), - grant_current_user, - ) + granted(Rule::new().owner(Account::CurrentUser), grant_current_user) } /// The rule of a pending-enrollment file, which an administrator can own. @@ -396,8 +394,8 @@ fn persist_replacing(directory: &Dir, file: &File, temporary: &str, name: &str) /// Opens a rejection marker or an in-progress record once it passes the trust check. /// -/// The check accepts SYSTEM and the agent's account, whether or not the agent runs as SYSTEM, so that a reader that -/// doesn't know the configured grant can tell a file the agent wrote from one another account wrote. +/// The check lets the file give SYSTEM and the agent's account access, whether or not the agent runs as SYSTEM, so that +/// a reader that doesn't know the configured grant can tell a file the agent wrote from one another account wrote. pub(crate) fn open_read(path: &Utf8Path) -> anyhow::Result { open_checked(path, &state_file_rule(true)) } From 159b134ddcc7285a276e61a6ed1231d4a0ce2a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 17:12:10 +0900 Subject: [PATCH 15/60] fix(private-fs): require exact OWNER RIGHTS and repair with the owner's access An exact check now refuses any OWNER RIGHTS entry when the rule gives the owner full access, and otherwise requires exactly one, with exactly the owner's access, so an exact DACL is the one private-fs writes and nothing more. A repair opens the directory with only what the owner's implicit access and its parent grant, so a directory whose DACL no longer grants the current user anything can still be repaired; it is opened normally once repaired and checked exactly. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/windows/mod.rs | 91 +++++++++++++++++------ crates/private-fs/src/windows/security.rs | 34 ++++++++- testsuite/tests/private_fs/windows.rs | 16 ++++ 3 files changed, 113 insertions(+), 28 deletions(-) diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index 0e1615a167..037d551ff6 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -56,6 +56,15 @@ const OPEN_OPTIONS: NTCREATEFILE_CREATE_OPTIONS = NTCREATEFILE_CREATE_OPTIONS( FILE_OPEN_REPARSE_POINT.0 | FILE_OPEN_FOR_BACKUP_INTENT.0 | FILE_SYNCHRONOUS_IO_NONALERT.0, ); +/// The options of an open for a repair: as [`OPEN_OPTIONS`], but asynchronous, so the open doesn't need SYNCHRONIZE, +/// which an owner's implicit access doesn't include. +const REPAIR_OPTIONS: NTCREATEFILE_CREATE_OPTIONS = + NTCREATEFILE_CREATE_OPTIONS(FILE_OPEN_REPARSE_POINT.0 | FILE_OPEN_FOR_BACKUP_INTENT.0); + +/// The access a repair opens with: what it reads and writes, all within an owner's implicit READ_CONTROL and WRITE_DAC, +/// and FILE_READ_ATTRIBUTES, which a directory's parent grants with FILE_LIST_DIRECTORY. +const REPAIR_ACCESS: FILE_ACCESS_RIGHTS = FILE_ACCESS_RIGHTS(READ_CONTROL.0 | WRITE_DAC.0 | FILE_READ_ATTRIBUTES.0); + const SHARE_ALL: FILE_SHARE_MODE = FILE_SHARE_MODE(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0 | FILE_SHARE_DELETE.0); #[derive(Debug)] @@ -158,23 +167,42 @@ pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, nt::flush(handle.as_handle()).map_err(|error| Error::io(path, "flush directory", error))?; Ok(Dir::new(handle, true)) } - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { - let handle = open_entry( - parent.handle(), - path, - name, - DIRECTORY_ACCESS, - SHARE_ALL, - "open directory", - )? - .ok_or_else(|| Error::io(path, "open directory", io::Error::from(io::ErrorKind::NotFound)))?; - let dir = Dir::new(handle, false); - match existing { - Existing::Check => check_dir(&dir, path, rule)?, - Existing::Repair => repair_dir(&dir, path, rule)?, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => match existing { + Existing::Check => { + let handle = open_entry( + parent.handle(), + path, + name, + DIRECTORY_ACCESS, + SHARE_ALL, + "open directory", + )? + .ok_or_else(|| Error::io(path, "open directory", io::Error::from(io::ErrorKind::NotFound)))?; + let dir = Dir::new(handle, false); + check_dir(&dir, path, rule)?; + Ok(dir) } - Ok(dir) - } + Existing::Repair => { + // A directory whose DACL no longer grants the current user anything is still repairable by its owner, + // so it is opened with the repair's access only, and opened normally once repaired. + let request = Request { + access: REPAIR_ACCESS, + share: SHARE_ALL, + disposition: FILE_OPEN, + options: REPAIR_OPTIONS, + attributes: FILE_FLAGS_AND_ATTRIBUTES(0), + descriptor: None, + }; + let repairing = nt::create(parent.handle(), name, &request) + .map_err(|error| Error::io(path, "open directory for repair", error))?; + let policy = Policy::new(rule, Kind::Directory, path)?; + repair(repairing.as_handle(), path, &policy)?; + let handle = reopen(repairing.as_handle(), DIRECTORY_ACCESS) + .map_err(|error| Error::io(path, "open directory", error))?; + check_item(handle.as_handle(), path, Kind::Directory, &policy.exact())?; + Ok(Dir::new(handle, false)) + } + }, Err(error) => Err(Error::io(path, "create directory", error)), } } @@ -373,9 +401,16 @@ pub(crate) fn check_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { pub(crate) fn repair_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { let policy = Policy::new(rule, Kind::Directory, path)?; - classify(dir.handle(), path, Kind::Directory)?; + repair(dir.handle(), path, &policy)?; + check_item(dir.handle(), path, Kind::Directory, &policy.exact()) +} + +/// Brings the owner and DACL of the directory `handle` refers to into line with `policy`, through a handle opened again +/// with the repair's access only, without checking the result. +fn repair(handle: BorrowedHandle<'_>, path: &Utf8Path, policy: &Policy) -> Result<()> { + classify(handle, path, Kind::Directory)?; let security = - OwnerAndDacl::of_handle(dir.handle()).map_err(|error| Error::io(path, "read access control list", error))?; + OwnerAndDacl::of_handle(handle).map_err(|error| Error::io(path, "read access control list", error))?; let current_owner = security.owner(); let owner_allowed = match current_owner { Some(owner) => policy.owner_allowed(owner, path, &administrators_member)?, @@ -389,15 +424,24 @@ pub(crate) fn repair_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> }; let descriptor = Descriptor::new(&owner, &policy.dacl(&owner)) .map_err(|error| Error::io(path, "build security descriptor", error))?; - let mut access = READ_CONTROL.0 | WRITE_DAC.0 | FILE_READ_ATTRIBUTES.0 | SYNCHRONIZE.0; + let mut access = REPAIR_ACCESS.0; let mut information = DACL_SECURITY_INFORMATION; if !owner_allowed { access |= WRITE_OWNER.0; information |= OWNER_SECURITY_INFORMATION; } - let reopened = reopen(dir.handle(), FILE_ACCESS_RIGHTS(access)) - .map_err(|error| Error::io(path, "open directory for repair", error))?; - let original = nt::file_id(dir.handle()).map_err(|error| Error::io(path, "read file ID", error))?; + let request = Request { + access: FILE_ACCESS_RIGHTS(access), + share: SHARE_ALL, + disposition: FILE_OPEN, + options: REPAIR_OPTIONS, + attributes: FILE_FLAGS_AND_ATTRIBUTES(0), + descriptor: None, + }; + // An empty name relative to a handle names its object. + let reopened = + nt::create(handle, "", &request).map_err(|error| Error::io(path, "open directory for repair", error))?; + let original = nt::file_id(handle).map_err(|error| Error::io(path, "read file ID", error))?; let current = nt::file_id(reopened.as_handle()).map_err(|error| Error::io(path, "read file ID", error))?; if original != current { return Err(Error::io( @@ -410,8 +454,7 @@ pub(crate) fn repair_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> // repair never changes what lies below the directory. // SAFETY: The handle is open and the descriptor stays live for the call. unsafe { SetKernelObjectSecurity(raw(reopened.as_handle()), information, descriptor.as_raw()) } - .map_err(|error| Error::io(path, "set access control list", nt::win32_error(error)))?; - check_item(dir.handle(), path, Kind::Directory, &policy.exact()) + .map_err(|error| Error::io(path, "set access control list", nt::win32_error(error))) } pub(crate) fn check_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { diff --git a/crates/private-fs/src/windows/security.rs b/crates/private-fs/src/windows/security.rs index 6e48c25261..90b441fbb6 100644 --- a/crates/private-fs/src/windows/security.rs +++ b/crates/private-fs/src/windows/security.rs @@ -241,10 +241,12 @@ impl Policy { let mut expected = self.entries(); expected.sort(); others.sort(); - let owner_rights_exact = match owner_rights.as_slice() { - [] => true, - [(mask, flags)] => *mask == owner_rights_mask && *flags == 0, - _ => false, + // An owner with full access needs no OWNER RIGHTS entry, so an exact DACL has none; any other owner has + // exactly one, with exactly the owner's access. + let owner_rights_exact = match (owner_access, owner_rights.as_slice()) { + (Some(Access::Full), entries) => entries.is_empty(), + (_, [(mask, flags)]) => *mask == owner_rights_mask && *flags == 0, + (_, _) => false, }; if others != expected || !owner_rights_exact { return Err(refused(Refusal::Access)); @@ -806,6 +808,30 @@ mod tests { assert_eq!(refusal(checked), "Access"); } + // oracle: intent crates/private-fs/INTENT.md §vocabulary + #[test] + fn an_exact_check_refuses_owner_rights_for_an_owner_with_full_access() { + // The rule gives the owner, SYSTEM, full access, so the DACL private-fs writes has no OWNER RIGHTS entry, and an + // exact check refuses one of any mask. + let rule = Rule::new() + .owner(Account::System) + .grant(Account::System, Access::Full) + .exact(); + for owner_rights in ["", "FA"] { + let security = security(&format!("O:SYD:P(A;;FA;;;SY)(A;;{owner_rights};;;OW)")); + let checked = policy(&rule, Kind::File).check(&security, Utf8Path::new("item"), &no_member); + assert_eq!(refusal(checked), "Access", "OWNER RIGHTS mask {owner_rights:?}"); + } + } + + // oracle: intent crates/private-fs/INTENT.md §vocabulary + #[test] + fn an_exact_check_refuses_a_second_owner_rights_entry() { + let security = security("O:{user}D:P(A;;FA;;;SY)(A;;;;;OW)(A;;;;;OW)"); + let checked = policy(&system_rule().exact(), Kind::File).check(&security, Utf8Path::new("item"), &no_member); + assert_eq!(refusal(checked), "Access"); + } + // oracle: mutant grants to one account kept as two entries, which an exact check of what was created refuses #[test] fn grants_to_one_account_merge_into_one_entry() { diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index 0dedc21bd2..40d78f8901 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -452,6 +452,22 @@ fn a_repair_restores_the_rule_without_changing_what_lies_below() -> anyhow::Resu Ok(()) } +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn a_repair_restores_a_directory_whose_dacl_grants_its_owner_nothing() -> anyhow::Result<()> { + // Catches a repair that opens the directory with more access than its owner's implicit READ_CONTROL and WRITE_DAC, + // which such a directory doesn't grant. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + drop(base.create_dir("child", &user_rule(), Existing::Check)?); + let child = test.path().join("child"); + set_dacl(&child, &[(users(), READ, 0)], InheritableAclKind::Protected)?; + let rule = user_rule().exact(); + drop(base.create_dir("child", &rule, Existing::Repair)?); + assert_eq!(security(&child)?, (user(), vec![(user(), FULL, INHERITED_BY_CHILDREN)])); + Ok(()) +} + // oracle: intent crates/private-fs/INTENT.md §mechanisms #[test] fn a_repair_takes_ownership_from_an_owner_the_rule_does_not_allow() -> anyhow::Result<()> { From 46e5dee6bb59fb7c9f2e394631ea63fdf1ce79fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 17:20:34 +0900 Subject: [PATCH 16/60] fix(private-fs): report a missing base directory without checking above it A directory that doesn't exist has nothing to protect, so `open_base` reports it missing whatever the directories above allow; a caller that must refuse a path another account could hide creates the directory instead. The documentation also states what a base directory gets checked against, what `discard` removes, and when a Windows directory can be synced. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/error.rs | 6 +++--- crates/private-fs/src/lib.rs | 28 ++++++++++++++++++++++++--- testsuite/tests/private_fs/unix.rs | 14 ++++++++++++++ testsuite/tests/private_fs/windows.rs | 20 +++++++++++++++++++ 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/crates/private-fs/src/error.rs b/crates/private-fs/src/error.rs index 89de5d37c1..f70f23334d 100644 --- a/crates/private-fs/src/error.rs +++ b/crates/private-fs/src/error.rs @@ -36,10 +36,10 @@ pub enum Refusal { Access, /// Its volume ignores or fakes ownership, or doesn't keep access control lists. Volume, - /// It is a directory above, and an account other than the system account, administrators and the accounts the rule - /// allows can rename or replace it. + /// It is the first existing directory of the path or a directory above it, and an account other than the system + /// account, administrators and the accounts the rule allows can rename or replace it. Replaceable, - /// It is a directory above, and its owner or access can't be read. + /// It is the first existing directory of the path or a directory above it, and its owner or access can't be read. Unreadable(io::Error), } diff --git a/crates/private-fs/src/lib.rs b/crates/private-fs/src/lib.rs index c61552efdf..9480f1fba2 100644 --- a/crates/private-fs/src/lib.rs +++ b/crates/private-fs/src/lib.rs @@ -137,6 +137,12 @@ impl Dir { /// That first directory and everything below it must not be a link. /// Each directory created has exactly `rule` from the moment it exists, and one that another process creates first /// counts as created only if it passes `rule`. + /// + /// Only the directories this creates are checked against `rule`, including one another process creates after it was + /// found missing. + /// The first directory of `path` that exists, which is `path` itself when it exists, is the caller's starting point + /// rather than something `rule` covers: it gets only the owner and replacement check above, so a directory an + /// installer made can serve as a base. pub fn create_base(path: &Utf8Path, rule: &Rule) -> Result { sys::create_base(path, rule).map(|inner| Self { inner, @@ -144,9 +150,17 @@ impl Dir { }) } - /// Opens the existing directory at `path`, with the checks of [`Dir::create_base`] on it and the directories above it, - /// and returns `None` when it doesn't exist. + /// Opens the existing directory at `path`, and returns `None` when it doesn't exist, without checking anything. + /// + /// The directory, the directories above it up to the root, and those between them get the owner and replacement + /// check of [`Dir::create_base`], not `rule`, which only names the accounts that check allows. + /// Check the returned directory with [`Dir::check`] where it must itself satisfy `rule`. + /// A missing directory has nothing to protect, so a caller that must refuse a path an account could hide creates the + /// directory with [`Dir::create_base`] instead. pub fn open_base(path: &Utf8Path, rule: &Rule) -> Result> { + if std::fs::symlink_metadata(path).is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound) { + return Ok(None); + } Ok(sys::open_base(path, rule)?.map(|inner| Self { inner, path: path.to_owned(), @@ -209,13 +223,17 @@ impl Dir { /// Makes `file`, written under the name `from` in this directory, durable under the name `to`. /// /// Once this returns, a power cut leaves `to` with the content of `file`. + /// After a failure, `file` may be under either name. pub fn persist(&self, file: &File, from: &str, to: &str, replace: Replace) -> Result<()> { let path = self.child(from)?; self.child(to)?; sys::persist(&self.inner, &path, file, from, to, replace) } - /// Removes the file `name` that this handle created as `file`, through `file` itself where the platform allows. + /// Removes `file`, which this handle created as `name`, and never another file. + /// + /// On Linux and macOS, it removes `file` only while it is still at `name`; on Windows, it removes `file` through its + /// own handle, wherever it is. /// /// A file can be created with a rule that gives its creator no access once created, so removing it by name again /// could be refused. @@ -236,6 +254,10 @@ impl Dir { } /// Makes the entries created, renamed and removed in this directory durable. + /// + /// On Windows, flushing a directory needs `FILE_ADD_FILE` on it: a directory that [`Dir::create_dir`] created holds it + /// already, and any other is opened again with it, which fails when the directory is open exclusively or its rule + /// doesn't give the current user that access. pub fn sync(&self) -> Result<()> { sys::sync(&self.inner, &self.path) } diff --git a/testsuite/tests/private_fs/unix.rs b/testsuite/tests/private_fs/unix.rs index 98adb6face..d8ca10240d 100644 --- a/testsuite/tests/private_fs/unix.rs +++ b/testsuite/tests/private_fs/unix.rs @@ -347,6 +347,20 @@ fn a_directory_above_that_others_can_write_is_refused() { assert!(!shared.join("base").exists()); } +// A missing directory has nothing to protect, so opening it reports it missing whatever the directories above allow. +// oracle: intent crates/device-key/INTENT.md §Operations +#[test] +fn a_missing_directory_under_a_replaceable_one_is_reported_missing() { + let scratch = Scratch::new(); + let shared = scratch.path.join("shared"); + fs::create_dir(&shared).unwrap(); + set_mode(&shared, 0o777); + + assert!(Dir::open_base(&shared.join("missing"), &rule()).unwrap().is_none()); + let error = Dir::open_base(&shared, &rule()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Replaceable)), "{error:?}"); +} + // Any member of the group can rename or replace the directory's entries, and administrators are root alone. // oracle: intent crates/private-fs/INTENT.md §paths #[test] diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index 40d78f8901..ada2a0276b 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -577,6 +577,26 @@ fn a_directory_above_whose_entries_others_can_delete_is_refused() -> anyhow::Res Ok(()) } +// oracle: intent crates/device-key/INTENT.md §Operations +#[test] +fn a_missing_directory_under_a_replaceable_one_is_reported_missing() -> anyhow::Result<()> { + // Catches an open that refuses a missing directory, which has nothing to protect, because of a directory above it. + let test = TestDir::new(); + std::fs::create_dir(test.path())?; + set_dacl( + test.path(), + &[(user(), FULL, INHERITED_BY_CHILDREN), (users(), DELETE_CHILD, 0)], + InheritableAclKind::Protected, + )?; + assert!(Dir::open_base(&test.path().join("missing"), &user_rule())?.is_none()); + let error = refusal(Dir::open_base(test.path(), &user_rule())); + assert!( + is_refused(&error, |refusal| matches!(refusal, Refusal::Replaceable)), + "{error:?}" + ); + Ok(()) +} + // oracle: intent crates/private-fs/INTENT.md §paths #[test] fn a_directory_above_others_can_only_add_to_is_accepted() -> anyhow::Result<()> { From e5d286458d19a8bc5c3b1310a46a0c6804aa044f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 17:23:34 +0900 Subject: [PATCH 17/60] fix(agent): skip anything but regular files when cleaning up device keys device-key creates only regular files, so a link or other entry named like one of its temporaries is not its own; treating it as one failed every later operation on the key store. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/device-key/src/file.rs | 77 +++++++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 21 deletions(-) diff --git a/crates/device-key/src/file.rs b/crates/device-key/src/file.rs index e715a7c70b..dc659042ec 100644 --- a/crates/device-key/src/file.rs +++ b/crates/device-key/src/file.rs @@ -63,8 +63,10 @@ fn key_file_rule() -> Rule { .grant(Account::System, Access::Full) } +const KEY_FILE_SUFFIX: &str = ".p8"; + fn key_file_name(name: &str) -> String { - format!("{name}.p8") + format!("{name}{KEY_FILE_SUFFIX}") } #[cfg(windows)] @@ -152,15 +154,7 @@ pub(super) fn delete(dir: &Utf8Path, name: &str) -> anyhow::Result<()> { if !listed_with_exact_name(&dir, &file_name)? { return Ok(()); } - // On Windows, a removed file keeps its name until every handle on it is closed, so the key file is first renamed to - // free its name at once. - #[cfg(windows)] - let file_name = { - let tombstone = tombstone_name(name); - dir.rename(&file_name, &tombstone, Replace::Refuse) - .context("retire key file")?; - tombstone - }; + let file_name = retire(&dir, &file_name, name).context("retire key file")?; dir.remove_file(&file_name).context("delete key file")?; Ok(()) } @@ -176,7 +170,7 @@ pub(super) fn list(dir: &Utf8Path, prefix: &str) -> anyhow::Result> .into_iter() .filter(|entry| entry.kind == EntryKind::File) .filter_map(|entry| { - let name = entry.name.to_str()?.strip_suffix(".p8")?; + let name = entry.name.to_str()?.strip_suffix(KEY_FILE_SUFFIX)?; name_matches_prefix(name, prefix).then(|| name.to_owned()) }) .collect::>(); @@ -220,7 +214,8 @@ fn abandoned_name(file_name: &str) -> Option<&str> { fn cleanup_abandoned(dir: &Dir) -> anyhow::Result<()> { // A creator holds this lock until it removes its temporary, so every remaining temporary is abandoned. for entry in dir.entries().context("inspect abandoned key files")? { - if entry.kind == EntryKind::Directory { + // The crate creates only regular files, and anything else at one of its names is not its own. + if entry.kind != EntryKind::File { continue; } let Some(file_name) = entry.name.to_str() else { @@ -229,22 +224,32 @@ fn cleanup_abandoned(dir: &Dir) -> anyhow::Result<()> { let Some(name) = abandoned_name(file_name) else { continue; }; - #[cfg(not(windows))] - let _ = name; - #[cfg(windows)] - let file_name = &if file_name.ends_with(".tmp") { - let tombstone = tombstone_name(name); - dir.rename(file_name, &tombstone, Replace::Refuse) - .context("retire abandoned key file")?; - tombstone + let file_name = if file_name.ends_with(".tmp") { + retire(dir, file_name, name).context("retire abandoned key file")? } else { file_name.to_owned() }; - dir.remove_file(file_name).context("remove abandoned key file")?; + dir.remove_file(&file_name).context("remove abandoned key file")?; } Ok(()) } +/// Renames `file_name`, a file of the key `name`, to a tombstone on Windows, and returns the name to remove. +/// +/// Where Windows can't remove a file with POSIX semantics, such as on FAT or before Windows 10 1709, a removed file keeps +/// its name until every handle on it is closed, so it is renamed first to free its name at once. +#[cfg(windows)] +fn retire(dir: &Dir, file_name: &str, name: &str) -> anyhow::Result { + let tombstone = tombstone_name(name); + dir.rename(file_name, &tombstone, Replace::Refuse)?; + Ok(tombstone) +} + +#[cfg(not(windows))] +fn retire(_: &Dir, file_name: &str, _: &str) -> anyhow::Result { + Ok(file_name.to_owned()) +} + fn lock_options() -> FileOptions { FileOptions::new().read(true).write(true).share(Share::Exclusive) } @@ -580,6 +585,36 @@ mod tests { Ok(()) } + // oracle: intent crates/device-key/INTENT.md §Operations + #[test] + fn a_link_at_a_leftover_name_is_skipped_by_listing() -> anyhow::Result<()> { + // Catches a cleanup that treats a link named like a leftover as one, which then fails every operation. + let directory = KeyDirectory::new()?; + let leftover = create_leftover(&directory)?; + let target = directory.0.join("target"); + fs::create_dir(&target)?; + let link = directory + .0 + .join(format!(".{}-{}.tmp", new_key_name(TEST_PREFIX), Uuid::new_v4())); + #[cfg(unix)] + std::os::unix::fs::symlink(&target, &link)?; + // A directory junction needs no privilege, unlike a symbolic link. + #[cfg(windows)] + ensure!( + std::process::Command::new("cmd") + .args(["/C", "mklink", "/J", link.as_str(), target.as_str()]) + .output()? + .status + .success(), + "create junction" + ); + + assert!(list(&directory.backend(), TEST_PREFIX)?.is_empty()); + assert!(!leftover.exists()); + assert!(fs::symlink_metadata(&link).is_ok()); + Ok(()) + } + // oracle: intent crates/device-key/INTENT.md §Operations #[test] fn unexpected_entry_names_cause_no_panic() -> anyhow::Result<()> { From 9630c2bbf8518e4e11e496ae51d1902cfc33554a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 17:25:50 +0900 Subject: [PATCH 18/60] docs(private-fs): say how Windows reparse points are listed and refused Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/error.rs | 6 ++++-- crates/private-fs/src/lib.rs | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/private-fs/src/error.rs b/crates/private-fs/src/error.rs index f70f23334d..fe6090bca8 100644 --- a/crates/private-fs/src/error.rs +++ b/crates/private-fs/src/error.rs @@ -24,11 +24,13 @@ pub enum ErrorKind { /// Why a file or directory breaks its rule. #[derive(Debug)] pub enum Refusal { - /// It is a symbolic link, a reparse point or a hard link. + /// It is a symbolic link, a Windows reparse point whose tag is a name surrogate, such as a junction, or a file with + /// more than one hard link. Link, /// It is not a directory. NotADirectory, - /// It is not a regular file. + /// It is not a regular file, or, on Windows, it is a reparse point whose tag is not a name surrogate, such as a cloud + /// placeholder, whose content private-fs can't vouch for. NotAFile, /// An account that the rule doesn't allow owns it. Owner, diff --git a/crates/private-fs/src/lib.rs b/crates/private-fs/src/lib.rs index 9480f1fba2..dff7499e1b 100644 --- a/crates/private-fs/src/lib.rs +++ b/crates/private-fs/src/lib.rs @@ -89,8 +89,9 @@ pub struct Entry { pub enum EntryKind { File, Directory, - /// A symbolic link or another reparse point. + /// A symbolic link, or on Windows a reparse point whose tag is a name surrogate, such as a junction. Link, + /// Anything else, including a Windows reparse point whose tag is not a name surrogate, such as a cloud placeholder. Other, } @@ -258,6 +259,8 @@ impl Dir { /// On Windows, flushing a directory needs `FILE_ADD_FILE` on it: a directory that [`Dir::create_dir`] created holds it /// already, and any other is opened again with it, which fails when the directory is open exclusively or its rule /// doesn't give the current user that access. + /// Holding that access, a directory created this way stays open for writing, so another open of it that doesn't + /// share writing fails while this handle lives. pub fn sync(&self) -> Result<()> { sys::sync(&self.inner, &self.path) } From 2c2b1292196f05f95ec34546f2f0bc6acc909137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 17:28:08 +0900 Subject: [PATCH 19/60] test(private-fs): pin shareable and exclusive Windows directory handles A directory created through private-fs stays openable by others that don't share deletion, and a directory opened exclusively still lists its entries. The reparse-tag tests assert the refusal itself, and specification citations name their headings. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/windows/mod.rs | 39 +++++++++++++++-------- crates/private-fs/src/windows/nt.rs | 3 +- crates/private-fs/src/windows/security.rs | 4 ++- testsuite/tests/private_fs/windows.rs | 39 ++++++++++++++++++++++- 4 files changed, 68 insertions(+), 17 deletions(-) diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index 037d551ff6..0b2b087099 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -345,6 +345,9 @@ pub(crate) fn discard(_parent: &Dir, path: &Utf8Path, file: &File, _name: &str) pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { let listed = { // A listing restarts and moves the handle's one enumeration cursor, so listings take turns. + // UNTESTED: concurrent listings of one directory each return every entry. + // A test racing two threads would pass whether or not they overlap, so it couldn't catch a missing lock; when + // changing this, keep every listing through this handle under the lock, from the restart to the last entry. let _listing = dir.listing.lock().unwrap_or_else(PoisonError::into_inner); nt::list(dir.handle()).map_err(|error| Error::io(path, "list directory", error))? }; @@ -615,10 +618,10 @@ impl Drop for BackupPrivilege { // it would take away an impersonation this guard didn't set. // When the thread token can't be read, the earlier token is restored, since leaving the privilege enabled is // the worse failure. - // UNTESTED: a thread that impersonates another token since this guard was created keeps it. - // Tests can't nest another impersonation inside this guard without the backup privilege, which CI's elevated - // runs hold but these checks can't rely on; when changing this, keep comparing token IDs, not handles, since - // OpenThreadToken returns a new handle each time. + // UNTESTED: a thread that impersonates another token since this guard was created keeps it, unless its token + // can't be read. + // The guard can't be created without the backup privilege, which tests can't rely on; when changing this, keep + // comparing token IDs, not handles, since OpenThreadToken returns a new handle each time. match thread_token_id() { Ok(Some(current)) if current != self.token_id => return, Ok(None) => return, @@ -882,7 +885,9 @@ fn open_path(path: &Utf8Path, access: FILE_ACCESS_RIGHTS) -> io::Result String { - format!("{:?}", reparse_refusal(REPARSE_POINT, tag)) - } - // oracle: spec MS-FSCC §2.1.2.1 Reparse Tags + // oracle: reference crates/private-fs/src/error.rs Refusal #[test] fn a_name_surrogate_item_is_a_link() { - assert_eq!(refusal(SYMLINK), "Some(Link)"); - assert_eq!(refusal(MOUNT_POINT), "Some(Link)"); + for tag in [SYMLINK, MOUNT_POINT] { + assert!( + matches!(reparse_refusal(REPARSE_POINT, tag), Some(Refusal::Link)), + "{tag:#x}" + ); + } } // oracle: spec MS-FSCC §2.1.2.1 Reparse Tags + // oracle: reference crates/private-fs/src/error.rs Refusal #[test] fn any_other_reparse_point_item_is_not_a_file() { // Opened without following the reparse point, a stub's content is not the file's, and a FIFO is not a file. - assert_eq!(refusal(CLOUD), "Some(NotAFile)"); - assert_eq!(refusal(DEDUP), "Some(NotAFile)"); - assert_eq!(refusal(LX_FIFO), "Some(NotAFile)"); + for tag in [CLOUD, DEDUP, LX_FIFO] { + assert!( + matches!(reparse_refusal(REPARSE_POINT, tag), Some(Refusal::NotAFile)), + "{tag:#x}" + ); + } } // oracle: spec MS-FSCC §2.1.2.1 Reparse Tags + // oracle: reference crates/private-fs/src/lib.rs EntryKind #[test] fn a_listed_reparse_point_is_a_link_only_when_it_stands_for_another_name() { assert_eq!(entry_kind(REPARSE_POINT, SYMLINK), EntryKind::Link); diff --git a/crates/private-fs/src/windows/nt.rs b/crates/private-fs/src/windows/nt.rs index 0e7b832f81..c9e27c93f0 100644 --- a/crates/private-fs/src/windows/nt.rs +++ b/crates/private-fs/src/windows/nt.rs @@ -384,7 +384,8 @@ fn parse_listing(bytes: &[u8], entries: &mut Vec<(OsString, u32, u32)>) -> io::R }; let next = field(offset_of!(FILE_ID_BOTH_DIR_INFO, NextEntryOffset))?; let attributes = field(offset_of!(FILE_ID_BOTH_DIR_INFO, FileAttributes))?; - // A reparse point's entry holds its reparse tag in EaSize, per [MS-FSCC] §2.4.17. + // A reparse point's entry holds its reparse tag in EaSize, per [MS-FSCC] "FileIdBothDirectoryInformation", + // EaSize. let tag = if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { field(offset_of!(FILE_ID_BOTH_DIR_INFO, EaSize))? } else { diff --git a/crates/private-fs/src/windows/security.rs b/crates/private-fs/src/windows/security.rs index 90b441fbb6..dde6f5becb 100644 --- a/crates/private-fs/src/windows/security.rs +++ b/crates/private-fs/src/windows/security.rs @@ -524,7 +524,9 @@ unsafe fn read_acl(acl: *mut ACL) -> io::Result> { Ok(entries) } -/// Returns the size of a SID with `sub_authority_count` subauthorities, per [MS-DTYP] §2.4.2.2 SID. +/// Returns the size of a SID with `sub_authority_count` subauthorities, per [MS-DTYP] "SID--Packet Representation". +/// +/// [MS-DTYP]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dtyp/ fn sid_size(sub_authority_count: usize) -> usize { let header = 1 /* Revision */ + 1 /* SubAuthorityCount */ + 6 /* IdentifierAuthority */; header + 4 /* SubAuthority */ * sub_authority_count diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index ada2a0276b..4894fd6cd4 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -1,7 +1,10 @@ //! Tests of the Windows backend of private-fs, which read and set owners and DACLs through the Windows API rather than //! through private-fs. //! -//! The access masks are the file and directory access rights of [MS-SMB2] §2.2.13.1, which NTFS shares. +//! The access masks are the file and directory access rights of [MS-SMB2] "File_Pipe_Printer_Access_Mask" and +//! "Directory_Access_Mask", which NTFS shares. +//! +//! [MS-SMB2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/ use std::io::{Read as _, Write as _}; use std::os::windows::fs::OpenOptionsExt as _; @@ -794,6 +797,40 @@ fn removing_a_file_another_process_holds_open_frees_its_name() -> anyhow::Result Ok(()) } +// oracle: mutant DELETE held by a created directory's handle, which fails every other open that doesn't share deletion +#[test] +fn a_created_directory_opens_elsewhere_without_sharing_deletion() -> anyhow::Result<()> { + // Catches a directory handle that holds DELETE, so that `cd` or `_wopen` fail while the `Dir` lives. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + let child = base.create_dir("child", &user_rule(), Existing::Check)?; + let other = std::fs::OpenOptions::new() + .read(true) + .share_mode(0x3) // FILE_SHARE_READ | FILE_SHARE_WRITE + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0) + .open(test.path().join("child")); + assert!(other.is_ok(), "{other:?}"); + drop(child); + Ok(()) +} + +// oracle: reference crates/private-fs/src/lib.rs Dir::entries +#[test] +fn a_directory_opened_exclusively_lists_its_entries() -> anyhow::Result<()> { + // Catches a listing through a second handle, which the exclusive open refuses. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + let child = base.create_dir("child", &user_rule(), Existing::Check)?; + drop(child.create_file("file", &user_rule(), FileOptions::new())?); + drop(child); + let child = base + .open_dir("child", &user_rule(), Share::Exclusive)? + .context("the directory exists")?; + let names: Vec<_> = child.entries()?.into_iter().map(|entry| entry.name).collect(); + assert_eq!(names, ["file"]); + Ok(()) +} + // oracle: mutant guards nested on one thread, whose drops out of order leave the thread with the privilege #[test] fn a_thread_holds_one_backup_privilege_at_a_time() -> anyhow::Result<()> { From 20da4ea02c7ceb1fac3664c3f13df6a2ff871c7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 17:47:14 +0900 Subject: [PATCH 20/60] fix(private-fs): discard a Windows file only while it keeps its name Discarding deleted the file through its handle wherever it was, so after a persist whose rename succeeded but whose flush failed, it deleted the file just installed while the file it replaced was already gone. It now deletes the file only while its path is still the name in this directory, compared case-insensitively, and still through the file's own handle, which an account that may only add to the directory needs. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/windows/mod.rs | 17 ++++++++++++++-- testsuite/tests/private_fs/windows.rs | 29 +++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index 0b2b087099..0e2768c3c4 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -337,8 +337,21 @@ pub(crate) fn remove_file(parent: &Dir, path: &Utf8Path, name: &str) -> Result Result<()> { +/// Removes `file` while it is still the entry `name` of this directory, through the file's own handle, and does +/// nothing once it has been renamed, as by a persist whose flush failed after its rename. +/// +/// The file's own handle deletes it because an account that may only add to the directory has no access by name. +pub(crate) fn discard(parent: &Dir, path: &Utf8Path, file: &File, name: &str) -> Result<()> { + validate_name(path, name)?; + let read_path = |handle| nt::final_path(handle).map_err(|error| Error::io(path, "read file path", error)); + let directory = read_path(parent.handle())?; + let current = read_path(file.as_handle())?; + let separator = if directory.ends_with('\\') { "" } else { "\\" }; + let expected = format!("{directory}{separator}{name}"); + // Windows file names are case-insensitive. + if current.to_uppercase() != expected.to_uppercase() { + return Ok(()); + } nt::delete(file.as_handle()).map_err(|error| Error::io(path, "remove file", error)) } diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index 4894fd6cd4..dc41b4fd7d 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -814,6 +814,35 @@ fn a_created_directory_opens_elsewhere_without_sharing_deletion() -> anyhow::Res Ok(()) } +// oracle: reference crates/private-fs/src/lib.rs Dir::discard +#[test] +fn discard_keeps_a_file_persisted_under_another_name() -> anyhow::Result<()> { + // Catches a discard after a persist whose flush failed, which would delete the file just installed while the file + // it replaced is already gone. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + let mut file = base.create_file("new", &user_rule(), FileOptions::new())?; + file.write_all(b"new")?; + base.persist(&file, "new", "installed", Replace::Allow)?; + base.discard(&file, "new")?; + drop(file); + assert_eq!(std::fs::read(test.path().join("installed"))?, b"new"); + Ok(()) +} + +// oracle: reference crates/private-fs/src/lib.rs Dir::discard +#[test] +fn discard_removes_a_file_still_under_its_name() -> anyhow::Result<()> { + // Catches a discard that never removes anything, given a name of another case than the one created. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + let file = base.create_file("staged", &user_rule(), FileOptions::new())?; + base.discard(&file, "STAGED")?; + drop(file); + assert!(!test.path().join("staged").exists()); + Ok(()) +} + // oracle: reference crates/private-fs/src/lib.rs Dir::entries #[test] fn a_directory_opened_exclusively_lists_its_entries() -> anyhow::Result<()> { From 718087d6fb47a95a368eb4082b986205903bd6fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 17:47:33 +0900 Subject: [PATCH 21/60] docs(private-fs): state that discard keeps a file renamed by persist Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/lib.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/private-fs/src/lib.rs b/crates/private-fs/src/lib.rs index dff7499e1b..827f15e99a 100644 --- a/crates/private-fs/src/lib.rs +++ b/crates/private-fs/src/lib.rs @@ -231,10 +231,9 @@ impl Dir { sys::persist(&self.inner, &path, file, from, to, replace) } - /// Removes `file`, which this handle created as `name`, and never another file. + /// Removes `file`, which this handle created as `name`, while it is still at `name`, and never another file. /// - /// On Linux and macOS, it removes `file` only while it is still at `name`; on Windows, it removes `file` through its - /// own handle, wherever it is. + /// After a failed [`Dir::persist`], it removes the file only if the rename didn't happen. /// /// A file can be created with a rule that gives its creator no access once created, so removing it by name again /// could be refused. From 50848b308e222d125a46f85a6d34a4fa6f8e7013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 18:01:47 +0900 Subject: [PATCH 22/60] refactor(agent): tighten the identity drop box and its checks An administrator's submission keeps the backup privilege only while it opens the identity directories, creates its staging file unshared, and checks the drop box against the rules the service provisions it with, built from one rule parameterised by the owning account. A stored identity is read through the authority directory handle its check opened, rather than by opening the path again. The Windows state lock and the administrator's submission share one sharing-violation retry. Tests share one temporary data directory helper, an elevated test submits into a drop box that only SYSTEM can read and replays the same token, and the conformance test runs in its own temporary directory. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/agent-identity/src/pending.rs | 19 +- crates/agent-identity/src/private_file.rs | 369 ++++++++++++++-------- crates/agent-identity/src/state.rs | 27 +- crates/agent-identity/src/state_lock.rs | 54 +--- testsuite/tests/agent_identity/mod.rs | 10 +- 5 files changed, 276 insertions(+), 203 deletions(-) diff --git a/crates/agent-identity/src/pending.rs b/crates/agent-identity/src/pending.rs index fca571a6da..7d5a4f31e3 100644 --- a/crates/agent-identity/src/pending.rs +++ b/crates/agent-identity/src/pending.rs @@ -282,16 +282,6 @@ mod tests { use super::*; - /// Creates an empty data directory under the system temporary directory, whose directories above pass the checks - /// of the agent's state, which a workspace `target` directory may not. - fn test_data_dir() -> anyhow::Result { - let dir = Utf8PathBuf::from_path_buf(std::env::temp_dir()) - .map_err(|_| anyhow::anyhow!("the temporary directory path is not UTF-8"))? - .join(format!("ai-{}", uuid::Uuid::new_v4().simple())); - fs::create_dir_all(&dir)?; - Ok(dir) - } - // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files #[test] fn only_matching_version_one_tokens_can_be_malformed() -> anyhow::Result<()> { @@ -362,7 +352,8 @@ mod tests { URL_SAFE_NO_PAD.encode(r#"{"u":"https://example.test"}"#), "A".repeat(43) ))?; - let dir = test_data_dir()?; + let test_dir = private_file::TestDir::new()?; + let dir = test_dir.0.clone(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; @@ -402,7 +393,8 @@ mod tests { URL_SAFE_NO_PAD.encode(r#"{"u":"https://example.test"}"#), "A".repeat(43) ); - let dir = test_data_dir()?; + let test_dir = private_file::TestDir::new()?; + let dir = test_dir.0.clone(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; @@ -462,7 +454,8 @@ mod tests { "A".repeat(43) ); let token = Token::parse(&raw)?; - let dir = test_data_dir()?; + let test_dir = private_file::TestDir::new()?; + let dir = test_dir.0.clone(); let hash = hex::encode(Sha256::digest(raw.as_bytes())); assert_eq!( file_path(&dir, &hash)?, diff --git a/crates/agent-identity/src/private_file.rs b/crates/agent-identity/src/private_file.rs index ea97c73c05..c487c0fd2f 100644 --- a/crates/agent-identity/src/private_file.rs +++ b/crates/agent-identity/src/private_file.rs @@ -86,20 +86,27 @@ fn granted(rule: Rule, grant_current_user: bool) -> Rule { } } -/// The rule of ``, `identity`, `authorities`, `keys`, `rejected`, every authority directory and every -/// `identity.json`. +/// The rule of `identity`, `authorities`, `keys`, `rejected`, every authority directory and every `identity.json`, and +/// of `` when the agent creates it; an existing `` gets only the check of the directories above. /// /// [`crates/agent-identity/INTENT.md`] ("Local state"): they are owned by the agent's account, which in production is /// SYSTEM or root. /// /// [`crates/agent-identity/INTENT.md`]: ../INTENT.md pub(crate) fn tree_rule(grant_current_user: bool) -> Rule { - granted(Rule::new().owner(Account::CurrentUser), grant_current_user).exact() + tree_rule_owned_by(Account::CurrentUser, grant_current_user) } -/// The rule of `identity/pending`, a drop box administrators can add a file to without listing or reading it. -fn drop_box_rule(grant_current_user: bool) -> Rule { - tree_rule(grant_current_user).grant(Account::Administrators, Access::AddOnly) +/// The rule of the tree directories as `owner` provisions them; an administrator's submission checks them with SYSTEM +/// as the owner. +fn tree_rule_owned_by(owner: Account, grant_current_user: bool) -> Rule { + granted(Rule::new().owner(owner), grant_current_user).exact() +} + +/// The rule of `identity/pending` as `owner` provisions it, a drop box administrators can add a file to without listing +/// or reading it. +fn drop_box_rule_owned_by(owner: Account, grant_current_user: bool) -> Rule { + tree_rule_owned_by(owner, grant_current_user).grant(Account::Administrators, Access::AddOnly) } /// The rule of in-progress records, rejection markers and the Unix lock file, which the agent's account owns and which @@ -113,6 +120,12 @@ pub(crate) fn state_file_rule(grant_current_user: bool) -> Rule { /// [`crates/agent-identity/INTENT.md`] ("Pending-enrollment files"): on Windows, the exact check accepts the rule's DACL, /// plus a zero-access OWNER RIGHTS entry when an administrator who is not the agent's account owns the file. /// +/// UNTESTED: on Linux and macOS, a pending file owned by root is accepted when the agent runs as another account, since +/// SYSTEM and Administrators are root there. +/// Only root can create a root-owned file, and a test run as root is the agent's own account, so no test separates the +/// two; when changing the owners, keep System and Administrators among them, because an `identity enroll` run as root +/// writes the file as root. +/// /// [`crates/agent-identity/INTENT.md`]: ../INTENT.md fn pending_file_rule(grant_current_user: bool) -> Rule { granted( @@ -175,7 +188,11 @@ pub(crate) fn provision_directories(data_dir: &Utf8Path, grant_current_user: boo for name in ["authorities", "keys", "rejected"] { provision(&identity, name, &rule)?; } - provision(&identity, "pending", &drop_box_rule(grant_current_user))?; + provision( + &identity, + "pending", + &drop_box_rule_owned_by(Account::CurrentUser, grant_current_user), + )?; return Ok(data); fn provision(parent: &Dir, name: &str, rule: &Rule) -> anyhow::Result { @@ -188,11 +205,6 @@ pub(crate) fn provision_directories(data_dir: &Utf8Path, grant_current_user: boo } } -#[cfg(test)] -pub(crate) fn prepare_directories(data_dir: &Utf8Path, grant_current_user: bool) -> anyhow::Result<()> { - provision_directories(data_dir, grant_current_user).map(drop) -} - /// Checks, without changing anything, that every authority directory and its `identity.json` are owned by the agent's /// account and protected from other users, and returns the authority IDs of the directories that exist. pub(crate) fn check_stored_identities(data_dir: &Utf8Path, grant_current_user: bool) -> anyhow::Result> { @@ -228,15 +240,15 @@ pub(crate) fn check_stored_identities(data_dir: &Utf8Path, grant_current_user: b Ok(authority_ids) } -/// Checks, without changing it, that an authority directory is owned by the agent's account and protected from other -/// users, and returns whether it exists. -pub(crate) fn check_authority_directory(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result { +/// Opens an authority directory, without changing it, once it is owned by the agent's account and protected from other +/// users, and returns `None` when it doesn't exist. +pub(crate) fn open_authority_directory(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result> { let (authorities, name) = match open_parent(path) { Ok(opened) => opened, - Err(error) if is_not_found(&error) => return Ok(false), + Err(error) if is_not_found(&error) => return Ok(None), Err(error) => return Err(untrusted_stored_identity(path, error)), }; - Ok(open_authority(&authorities, name, path, grant_current_user)?.is_some()) + open_authority(&authorities, name, path, grant_current_user) } fn open_authority( @@ -250,27 +262,29 @@ fn open_authority( .map_err(|error| untrusted_stored_identity(path, error.into())) } -/// Opens an authority directory's `identity.json`, without changing it, once it passes the trust check, and returns -/// `None` when it doesn't exist. +/// Opens the `identity.json` at `path` in its checked `authority` directory, without changing it, once it passes the +/// trust check, and returns `None` when it doesn't exist. /// /// [`crates/agent-identity/INTENT.md`] ("Local state"): the file must pass the check of its authority directory, so it is /// a regular file owned by the agent's account and is, on Unix, closed to group and other, or on Windows, protected by /// exactly the DACL the agent provisions for it. /// /// [`crates/agent-identity/INTENT.md`]: ../INTENT.md -pub(crate) fn open_identity_file(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result> { - open_identity(path, grant_current_user, FileOptions::new().read(true)) -} - -fn open_identity(path: &Utf8Path, grant_current_user: bool, options: FileOptions) -> anyhow::Result> { - let authority = match open_parent(path) { - Ok((authority, _)) => authority, - Err(error) if is_not_found(&error) => return Ok(None), - Err(error) => return Err(untrusted_stored_identity(path, error)), - }; - open_identity_in(&authority, path, grant_current_user, options) +pub(crate) fn open_identity_file( + authority: &Dir, + path: &Utf8Path, + grant_current_user: bool, +) -> anyhow::Result> { + open_identity_in(authority, path, grant_current_user, FileOptions::new().read(true)) } +/// Opens `identity.json` in its checked authority directory once it passes the trust check. +/// +/// UNTESTED: an `identity.json` owned by another account fails the check, because that owner can always change its +/// access. +/// Only an elevated process on Windows or root on Linux and macOS can give a test file another owner, and the tests that +/// do run that way cover the authority directory, which the same rule governs; when changing this, keep the agent's +/// account the only owner the rule allows. fn open_identity_in( authority: &Dir, path: &Utf8Path, @@ -283,12 +297,6 @@ fn open_identity_in( .map_err(|error| untrusted_stored_identity(path, error.into())) } -/// Creates a missing authority directory, and only checks an existing one, which is never repaired. -#[cfg(test)] -pub(crate) fn create_authority_directory(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result<()> { - create_authority(path, grant_current_user).map(drop) -} - fn create_authority(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result { #[cfg(windows)] ensure!( @@ -486,18 +494,12 @@ struct DropBox { #[cfg(windows)] impl DropBox { - /// The directories as the service running as SYSTEM provisions them. - fn provisioned_by_system() -> Self { - let system = || { - Rule::new() - .owner(Account::System) - .grant(Account::System, Access::Full) - .exact() - }; + /// The directories as the service provisions them running as `owner`, with the rules it provisions them with. + fn provisioned_by(owner: Account, grant_current_user: bool) -> Self { Self { - base: system(), - identity: system(), - pending: system().grant(Account::Administrators, Access::AddOnly), + base: tree_rule_owned_by(owner, grant_current_user), + identity: tree_rule_owned_by(owner, grant_current_user), + pending: drop_box_rule_owned_by(owner, grant_current_user), } } } @@ -515,21 +517,26 @@ pub(crate) fn submit_pending_as_administrator( path: &Utf8Path, contents: &[u8], ) -> anyhow::Result<()> { - let started = Instant::now(); - // The backup privilege lets an administrator open the identity directories, which grant it no list or read access, - // to check who owns them and who can access them. - let _backup = private_fs::BackupPrivilege::enable().context("enable administrator backup privilege")?; - submit_into_drop_box(data_dir, path, contents, &DropBox::provisioned_by_system(), started) + submit_into_drop_box( + data_dir, + path, + contents, + &DropBox::provisioned_by(Account::System, false), + true, + ) } +/// Submits a pending-enrollment file into the drop box under `data_dir` once its directories pass `rules`, opening them +/// with the backup privilege when `backup` is set. #[cfg(windows)] fn submit_into_drop_box( data_dir: &Utf8Path, path: &Utf8Path, contents: &[u8], rules: &DropBox, - started: Instant, + backup: bool, ) -> anyhow::Result<()> { + let started = Instant::now(); let pending_path = data_dir.join("identity").join("pending"); ensure!( path.parent() == Some(pending_path.as_path()), @@ -537,10 +544,18 @@ fn submit_into_drop_box( ); let name = path.file_name().context("pending file has no name")?; let pending = { - let data = retry_sharing_violation(started, || Ok(Dir::open_base(data_dir, &rules.base)?)) - .and_then(|data| data.ok_or_else(not_found)) - .context("inspect identity data directory") - .map_err(explain_unprepared_drop_box)?; + // The backup privilege lets an administrator open the identity directories, which grant it no list or read + // access, to check who owns them and who can access them; it ends before anything is written. + let _backup = backup + .then(private_fs::BackupPrivilege::enable) + .transpose() + .context("enable administrator backup privilege")?; + let data = retry_sharing_violation(started, SUBMISSION_TIMEOUT, || { + Ok(Dir::open_base(data_dir, &rules.base)?) + }) + .and_then(|data| data.ok_or_else(not_found)) + .context("inspect identity data directory") + .map_err(explain_unprepared_drop_box)?; let identity = open_drop_box_directory(&data, "identity", &rules.identity, started) .context("inspect protected identity directory") .map_err(explain_unprepared_drop_box)?; @@ -550,7 +565,11 @@ fn submit_into_drop_box( }; let staging = format!(".{name}-{}.tmp", Uuid::new_v4()); let mut file = pending - .create_file(&staging, &pending_file_rule(false), FileOptions::new()) + .create_file( + &staging, + &pending_file_rule(false), + FileOptions::new().share(Share::Exclusive), + ) .context("submit pending enrollment")?; let published = file .write_all(contents) @@ -575,23 +594,34 @@ fn submit_into_drop_box( published.map(drop) } +/// How long an administrator's submission waits for the service to release the identity directory. +#[cfg(windows)] +const SUBMISSION_TIMEOUT: Duration = Duration::from_secs(10); + #[cfg(windows)] fn open_drop_box_directory(parent: &Dir, name: &str, rule: &Rule, started: Instant) -> anyhow::Result { - retry_sharing_violation(started, || Ok(parent.open_dir(name, rule, Share::Shared)?))?.ok_or_else(not_found) + retry_sharing_violation(started, SUBMISSION_TIMEOUT, || { + Ok(parent.open_dir(name, rule, Share::Shared)?) + })? + .ok_or_else(not_found) } -/// Retries `operation` while it fails with a sharing violation, as while the service holds the state lock, for 10 s -/// from `started`. +/// Retries `operation` while it fails with a sharing violation, as while another process holds the identity directory, +/// until `timeout` after `started`. #[cfg(windows)] -fn retry_sharing_violation(started: Instant, mut operation: impl FnMut() -> anyhow::Result) -> anyhow::Result { +pub(crate) fn retry_sharing_violation( + started: Instant, + timeout: Duration, + mut operation: impl FnMut() -> anyhow::Result, +) -> anyhow::Result { loop { match operation() { Err(error) if is_sharing_violation(&error) => { - let remaining = Duration::from_secs(10).saturating_sub(started.elapsed()); + let remaining = timeout.saturating_sub(started.elapsed()); if remaining.is_zero() { return Err(error); } - std::thread::sleep(remaining.min(Duration::from_millis(100))); + std::thread::sleep(remaining.min(Duration::from_millis(25))); } result => return result, } @@ -599,7 +629,7 @@ fn retry_sharing_violation(started: Instant, mut operation: impl FnMut() -> a } #[cfg(windows)] -fn is_sharing_violation(error: &anyhow::Error) -> bool { +pub(crate) fn is_sharing_violation(error: &anyhow::Error) -> bool { use windows::Win32::Foundation::ERROR_SHARING_VIOLATION; error.chain().any(|cause| { @@ -627,6 +657,42 @@ fn explain_unprepared_drop_box(error: anyhow::Error) -> anyhow::Error { } } +#[cfg(test)] +pub(crate) fn prepare_directories(data_dir: &Utf8Path, grant_current_user: bool) -> anyhow::Result<()> { + provision_directories(data_dir, grant_current_user).map(drop) +} + +/// Creates a missing authority directory, and only checks an existing one, which is never repaired. +#[cfg(test)] +pub(crate) fn create_authority_directory(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result<()> { + create_authority(path, grant_current_user).map(drop) +} + +/// An empty data directory under the system temporary directory, removed on drop. +/// +/// The system temporary directory keeps test paths under Windows' 260-character limit wherever the repository is checked +/// out, and its directories above pass the checks of the agent's state, which a workspace `target` directory's may not. +#[cfg(test)] +pub(crate) struct TestDir(pub(crate) Utf8PathBuf); + +#[cfg(test)] +impl TestDir { + pub(crate) fn new() -> anyhow::Result { + let path = Utf8PathBuf::from_path_buf(std::env::temp_dir()) + .map_err(|_| anyhow::anyhow!("the temporary directory path is not UTF-8"))? + .join(format!("ai-{}", Uuid::new_v4().simple())); + std::fs::create_dir_all(&path)?; + Ok(Self(path)) + } +} + +#[cfg(test)] +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + #[cfg(test)] mod tests { use super::*; @@ -653,18 +719,11 @@ mod unix_tests { use super::*; - /// An empty data directory under the system temporary directory, removed on drop. - struct TestDir(Utf8PathBuf); - impl TestDir { - fn new() -> anyhow::Result { - let path = Utf8PathBuf::from_path_buf(std::env::temp_dir()) - .map_err(|_| anyhow::anyhow!("the temporary directory path is not UTF-8"))? - .join(format!("ai-{}", Uuid::new_v4().simple())); - fs::create_dir_all(&path)?; - fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?; - prepare_directories(&path, true)?; - Ok(Self(path)) + fn provisioned() -> anyhow::Result { + let directory = Self::new()?; + prepare_directories(&directory.0, true)?; + Ok(directory) } fn pending_file(&self) -> anyhow::Result { @@ -674,12 +733,6 @@ mod unix_tests { } } - impl Drop for TestDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - /// Gives `path` an owner other than root and the current user, which only root can do. fn give_to_another_account(path: &Utf8Path) -> anyhow::Result { // SAFETY: Reading the process's effective UID has no preconditions. @@ -695,7 +748,7 @@ mod unix_tests { // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files #[test] fn a_pending_file_owned_by_another_account_is_ignored() -> anyhow::Result<()> { - let directory = TestDir::new()?; + let directory = TestDir::provisioned()?; let path = directory.pending_file()?; if !give_to_another_account(&path)? { return Ok(()); @@ -715,7 +768,7 @@ mod unix_tests { #[case::group_and_other_read(0o644)] #[case::group_write(0o620)] fn a_pending_file_open_to_group_or_other_is_ignored(#[case] mode: u32) -> anyhow::Result<()> { - let directory = TestDir::new()?; + let directory = TestDir::provisioned()?; let path = directory.pending_file()?; fs::set_permissions(&path, fs::Permissions::from_mode(mode))?; @@ -728,7 +781,7 @@ mod unix_tests { // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files #[test] fn an_owner_only_pending_file_is_read() -> anyhow::Result<()> { - let directory = TestDir::new()?; + let directory = TestDir::provisioned()?; let path = directory.pending_file()?; open_pending_read(&path, true)?; @@ -740,7 +793,7 @@ mod unix_tests { // oracle: intent crates/agent-identity/INTENT.md §Local state #[test] fn an_authority_directory_owned_by_another_account_fails_the_check() -> anyhow::Result<()> { - let directory = TestDir::new()?; + let directory = TestDir::provisioned()?; let authority = directory .0 .join("identity") @@ -751,8 +804,9 @@ mod unix_tests { return Ok(()); } - let error = check_authority_directory(&authority, true) - .expect_err("an authority directory owned by another account passed the check"); + let error = open_authority_directory(&authority, true) + .err() + .context("an authority directory owned by another account passed the check")?; let untrusted = error .downcast_ref::() @@ -776,20 +830,7 @@ mod windows_tests { use super::*; use crate::windows::test::{self as acl, Ace}; - /// An empty data directory under the system temporary directory, removed on drop. - struct TestDir(Utf8PathBuf); - impl TestDir { - fn new() -> anyhow::Result { - // The system temporary directory keeps test paths under Windows' 260-character limit wherever the repository - // is checked out. - let path = Utf8PathBuf::from_path_buf(std::env::temp_dir()) - .map_err(|_| anyhow::anyhow!("the temporary directory path is not UTF-8"))? - .join(format!("ai-{}", Uuid::new_v4().simple())); - fs::create_dir_all(&path)?; - Ok(Self(path)) - } - fn provisioned() -> anyhow::Result { let directory = Self::new()?; prepare_directories(&directory.0, true)?; @@ -809,20 +850,10 @@ mod windows_tests { } } - impl Drop for TestDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - /// The rules of a drop box that the current user provisioned under `__debug__.identity.acl_grant_current_user`, as a /// test session without SYSTEM can. fn provisioned_by_current_user() -> DropBox { - DropBox { - base: tree_rule(true), - identity: tree_rule(true), - pending: drop_box_rule(true), - } + DropBox::provisioned_by(Account::CurrentUser, true) } const INHERITED: u32 = CONTAINER_INHERIT_ACE.0 | OBJECT_INHERIT_ACE.0; @@ -860,7 +891,8 @@ mod windows_tests { Ok(()) } - // oracle: intent crates/agent-identity/INTENT.md §Local state + // oracle: mutant dropping the guard lets an account other than SYSTEM create identity directories that only SYSTEM can + // open #[test] fn provisioning_without_system_or_the_debug_grant_is_refused() -> anyhow::Result<()> { if crate::windows::is_system()? { @@ -1048,17 +1080,103 @@ mod windows_tests { let path = directory.pending_file('a'); let rules = provisioned_by_current_user(); - submit_into_drop_box(&directory.0, &path, b"test payload", &rules, Instant::now())?; + submit_into_drop_box(&directory.0, &path, b"test payload", &rules, false)?; assert!(fs::symlink_metadata(&path)?.file_type().is_file()); assert!(File::open(&path).is_err()); assert!(fs::OpenOptions::new().write(true).open(&path).is_err()); assert!(fs::OpenOptions::new().access_mode(WRITE_DAC.0).open(&path).is_err()); - submit_into_drop_box(&directory.0, &path, b"different payload", &rules, Instant::now())?; + submit_into_drop_box(&directory.0, &path, b"different payload", &rules, false)?; assert_eq!(fs::read_dir(directory.pending())?.count(), 1); Ok(()) } + /// Runs `operation` impersonating a copy of the process token with the restore and take-ownership privileges enabled, + /// which let an elevated test give a directory SYSTEM as its owner and take it back. + fn with_restore_privilege(operation: impl FnOnce() -> anyhow::Result) -> anyhow::Result { + use ::windows::Win32::Security::{ + SecurityImpersonation, TOKEN_ADJUST_PRIVILEGES, TOKEN_DUPLICATE, TOKEN_IMPERSONATE, TOKEN_QUERY, + TokenImpersonation, + }; + use win_api_wrappers::process::Process; + use win_api_wrappers::security::privilege; + + let mut token = Process::current_process() + .token(TOKEN_DUPLICATE | TOKEN_QUERY)? + .duplicate( + TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY | TOKEN_IMPERSONATE, + None, + SecurityImpersonation, + TokenImpersonation, + )?; + token.enable_privilege(privilege::SE_RESTORE_NAME)?; + token.enable_privilege(privilege::SE_TAKE_OWNERSHIP_NAME)?; + let _impersonation = token.impersonate()?; + operation() + } + + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + // oracle: intent devolutions-agent/src/identity/INTENT.md §CLI + #[test] + fn elevated_only_an_administrator_submits_into_the_drop_box_system_provisioned_and_a_replay_is_a_no_op() + -> anyhow::Result<()> { + use win_api_wrappers::token::Token; + + if !Token::current_process_token().is_elevated()? || crate::windows::is_system()? { + writeln!( + io::stderr(), + "SKIP: elevated_only_an_administrator_submits_into_the_drop_box_system_provisioned_and_a_replay_is_a_no_op \ + needs an elevated administrator other than SYSTEM" + )?; + return Ok(()); + } + let directory = TestDir::provisioned()?; + let system = acl::system_sid()?; + let user = acl::user_sid()?; + let add_only = ( + Sid::from_well_known(WinBuiltinAdministratorsSid, None)?, + FILE_ADD_FILE.0 | SYNCHRONIZE.0, + NO_INHERITANCE.0, + ); + let path = directory.pending_file('a'); + // The directories as the service running as SYSTEM provisions them: SYSTEM owns them, only SYSTEM can list or + // read them, and administrators can only add a file to the drop box. + with_restore_privilege(|| { + for directory in [directory.identity(), directory.pending()] { + acl::set_owner(&directory, &system)?; + } + acl::set_dacl(&directory.pending(), &[full(&system, INHERITED), add_only.clone()])?; + acl::set_dacl(&directory.identity(), &[full(&system, INHERITED)]) + })?; + + let submitted = submit_pending_as_administrator(&directory.0, &path, b"test payload"); + // The same token again finds its pending file there, so the second submission discards its staging file through + // the handle that created it, which is all the access an administrator has to it. + let replayed = submit_pending_as_administrator(&directory.0, &path, b"test payload"); + // Taking ownership back gives the current user the owner's implicit right to change the DACLs. + with_restore_privilege(|| { + for directory in [directory.identity(), directory.pending()] { + acl::set_owner(&directory, &user)?; + } + acl::set_dacl( + &directory.identity(), + &[full(&system, INHERITED), full(&user, INHERITED)], + )?; + acl::set_dacl( + &directory.pending(), + &[full(&system, INHERITED), full(&user, INHERITED), add_only.clone()], + ) + })?; + + submitted?; + replayed?; + let entries = fs::read_dir(directory.pending())? + .map(|entry| Ok(entry?.file_name())) + .collect::>>()?; + assert_eq!(entries, [path.file_name().context("pending file has no name")?]); + Ok(()) + } + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files #[test] fn interrupted_staging_does_not_block_a_replayed_enrollment() -> anyhow::Result<()> { @@ -1073,7 +1191,7 @@ mod windows_tests { &path, b"complete DPAPI blob", &provisioned_by_current_user(), - Instant::now(), + false, )?; assert!(fs::symlink_metadata(&path)?.file_type().is_file()); @@ -1108,7 +1226,7 @@ mod windows_tests { }; let path = directory.pending_file('c'); - let submitted = submit_into_drop_box(&directory.0, &path, b"test payload", &rules, Instant::now()); + let submitted = submit_into_drop_box(&directory.0, &path, b"test payload", &rules, false); acl::set_dacl( &directory.pending(), &[full(&acl::system_sid()?, INHERITED), full(&acl::user_sid()?, INHERITED)], @@ -1143,7 +1261,7 @@ mod windows_tests { &directory.pending_file('d'), b"test payload", &provisioned_by_current_user(), - Instant::now(), + false, ) .expect_err("a redirected drop box accepted a pending enrollment"); @@ -1171,13 +1289,13 @@ mod windows_tests { // The test's data directory is the current user's, unlike the directories the service provisions. let rules = DropBox { base: tree_rule(true), - ..DropBox::provisioned_by_system() + ..DropBox::provisioned_by(Account::System, false) }; - let missing = submit_into_drop_box(&directory.0, &path, b"test payload", &rules, Instant::now()) + let missing = submit_into_drop_box(&directory.0, &path, b"test payload", &rules, false) .expect_err("a missing identity directory was accepted"); prepare_directories(&directory.0, true)?; - let untrusted = submit_into_drop_box(&directory.0, &path, b"test payload", &rules, Instant::now()) + let untrusted = submit_into_drop_box(&directory.0, &path, b"test payload", &rules, false) .expect_err("a directory the current user provisioned was trusted as SYSTEM's"); assert_eq!(missing.to_string(), ENROLLMENT_DIRECTORY_NOT_READY); @@ -1197,8 +1315,6 @@ mod windows_tests { Ok(()) } - // oracle: intent crates/agent-identity/INTENT.md §Local state - // oracle: intent devolutions-agent/src/identity/INTENT.md §CLI #[test] fn retries_only_sharing_violations() -> anyhow::Result<()> { use ::windows::Win32::Foundation::{ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION}; @@ -1208,15 +1324,17 @@ mod windows_tests { i32::try_from(ERROR_SHARING_VIOLATION.0).expect("Win32 error codes fit an i32"), )) }; + // oracle: mutant a sharing violation, as while the service holds the identity directory, fails at once let mut attempts = 0; - retry_sharing_violation(Instant::now(), || { + retry_sharing_violation(Instant::now(), SUBMISSION_TIMEOUT, || { attempts += 1; if attempts == 1 { Err(sharing()) } else { Ok(()) } })?; assert_eq!(attempts, 2); + // oracle: mutant any failure is retried until the timeout let mut attempts = 0; - let result: anyhow::Result<()> = retry_sharing_violation(Instant::now(), || { + let result: anyhow::Result<()> = retry_sharing_violation(Instant::now(), SUBMISSION_TIMEOUT, || { attempts += 1; Err(anyhow::Error::new(io::Error::from_raw_os_error(i32::try_from( ERROR_ACCESS_DENIED.0, @@ -1225,6 +1343,7 @@ mod windows_tests { assert!(result.is_err()); assert_eq!(attempts, 1); + // oracle: intent devolutions-agent/src/identity/INTENT.md §CLI let inaccessible = explain_unprepared_drop_box( anyhow::Error::new(io::Error::from(io::ErrorKind::PermissionDenied)) .context("inspect protected pending drop box"), diff --git a/crates/agent-identity/src/state.rs b/crates/agent-identity/src/state.rs index cafcfeb501..add5b5c2d4 100644 --- a/crates/agent-identity/src/state.rs +++ b/crates/agent-identity/src/state.rs @@ -194,13 +194,14 @@ impl Store { /// Reads an identity once every stored identity has passed [`Store::check_stored_identities`]. fn read_stored_identity(&self, authority_id: Uuid) -> anyhow::Result> { let path = self.identity_path(authority_id); - if !private_file::check_authority_directory( + let Some(authority) = private_file::open_authority_directory( path.parent().context("identity file has no parent")?, self.acl_grant_current_user, - )? { + )? + else { return Ok(None); - } - let Some(file) = private_file::open_identity_file(&path, self.acl_grant_current_user)? else { + }; + let Some(file) = private_file::open_identity_file(&authority, &path, self.acl_grant_current_user)? else { return Ok(None); }; ensure!( @@ -881,19 +882,9 @@ mod tests { use super::*; - struct TestDir(Utf8PathBuf); + use crate::private_file::TestDir; impl TestDir { - fn new() -> anyhow::Result { - // The system temporary directory keeps the deepest state path, a deleted key's tombstone, under Windows' - // 260-character limit wherever the repository is checked out. - let path = Utf8PathBuf::from_path_buf(std::env::temp_dir()) - .map_err(|_| anyhow::anyhow!("the temporary directory path is not UTF-8"))? - .join(format!("ai-{}", Uuid::new_v4().simple())); - fs::create_dir_all(&path)?; - Ok(Self(path)) - } - fn supports_owner_only_files(&self) -> anyhow::Result { #[cfg(unix)] { @@ -919,12 +910,6 @@ mod tests { } } - impl Drop for TestDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - // oracle: intent crates/agent-identity/INTENT.md §Identities #[cfg(not(windows))] #[test] diff --git a/crates/agent-identity/src/state_lock.rs b/crates/agent-identity/src/state_lock.rs index 66a38ecbdb..486296062e 100644 --- a/crates/agent-identity/src/state_lock.rs +++ b/crates/agent-identity/src/state_lock.rs @@ -2,8 +2,6 @@ use std::fs::File; use anyhow::Context as _; -#[cfg(windows)] -use anyhow::ensure; use camino::Utf8Path; #[cfg(windows)] use private_fs::Dir; @@ -73,40 +71,23 @@ fn lock_file(data_dir: &Utf8Path, grant_current_user: bool) -> anyhow::Result anyhow::Result { use std::time::{Duration, Instant}; - use windows::Win32::Foundation::ERROR_SHARING_VIOLATION; - - let started = Instant::now(); - let sharing_violation = i32::try_from(ERROR_SHARING_VIOLATION.0)?; - loop { - let locked = private_file::provision_directories(data_dir, grant_current_user).and_then(|data| { - data.open_dir( + private_file::retry_sharing_violation(Instant::now(), Duration::from_secs(30), || { + private_file::provision_directories(data_dir, grant_current_user)? + .open_dir( "identity", &private_file::tree_rule(grant_current_user), private_fs::Share::Exclusive, ) .context("open identity state lock") - }); - match locked { - Ok(Some(directory)) => return Ok(directory), - Ok(None) => anyhow::bail!("identity directory disappeared after its creation"), - Err(error) if is_sharing_violation(&error, sharing_violation) => { - ensure!( - started.elapsed() < Duration::from_secs(30), - "identity state lock remained busy" - ); - std::thread::sleep(Duration::from_millis(25)); - } - Err(error) => return Err(error), + }) + .map_err(|error| { + if private_file::is_sharing_violation(&error) { + error.context("identity state lock remained busy") + } else { + error } - } -} - -#[cfg(windows)] -fn is_sharing_violation(error: &anyhow::Error, code: i32) -> bool { - error - .chain() - .filter_map(|cause| cause.downcast_ref::()) - .any(|error| error.raw_os_error() == Some(code)) + })? + .context("identity directory disappeared after its creation") } #[cfg(all(test, windows))] @@ -119,23 +100,17 @@ impl StateLock { #[cfg(all(test, windows))] mod tests { - use std::fs; use std::time::Duration; - use camino::Utf8PathBuf; - use super::*; // oracle: intent crates/agent-identity/INTENT.md §Local state #[test] fn trusted_lock_serializes_concurrent_writers() -> anyhow::Result<()> { - let data_dir = Utf8PathBuf::from_path_buf(std::env::temp_dir()) - .map_err(|_| anyhow::anyhow!("the temporary directory path is not UTF-8"))? - .join(format!("ai-{}", uuid::Uuid::new_v4().simple())); - fs::create_dir_all(&data_dir)?; - let lock = acquire(&data_dir, true)?; + let directory = private_file::TestDir::new()?; + let worker_dir = directory.0.clone(); + let lock = acquire(&directory.0, true)?; let (sender, receiver) = std::sync::mpsc::channel(); - let worker_dir = data_dir.clone(); let worker = std::thread::spawn(move || { let result = acquire(&worker_dir, true).map(|_lock| ()); let _ = sender.send(result); @@ -152,7 +127,6 @@ mod tests { worker .join() .map_err(|_| anyhow::anyhow!("identity lock worker panicked"))?; - fs::remove_dir_all(data_dir)?; Ok(()) } } diff --git a/testsuite/tests/agent_identity/mod.rs b/testsuite/tests/agent_identity/mod.rs index 05c53bc989..c77e140b47 100644 --- a/testsuite/tests/agent_identity/mod.rs +++ b/testsuite/tests/agent_identity/mod.rs @@ -314,10 +314,11 @@ async fn conformance() -> anyhow::Result<()> { ensure!(!agent_version.is_empty(), "workspace VERSION is empty"); // The agent refuses a data directory with a directory above it that another account can rename, as a workspace // `target` directory's can be, and the system temporary directory's are not. - let work_dir = std::env::temp_dir().join("agent-identity-integration"); - std::fs::create_dir_all(&work_dir)?; - let mut first_dir = tempfile::Builder::new().prefix("mock-1-").tempdir_in(&work_dir)?; - let second_dir = tempfile::Builder::new().prefix("mock-2-").tempdir_in(&work_dir)?; + let mut work_dir = tempfile::Builder::new() + .prefix("agent-identity-integration-") + .tempdir()?; + let mut first_dir = tempfile::Builder::new().prefix("mock-1-").tempdir_in(work_dir.path())?; + let second_dir = tempfile::Builder::new().prefix("mock-2-").tempdir_in(work_dir.path())?; let first = start_mock(first_dir.path()).await?; let second = start_mock(second_dir.path()).await?; let stdout_path = first_dir.path().join("runner-stdout.log"); @@ -358,6 +359,7 @@ async fn conformance() -> anyhow::Result<()> { // Without it, the `KEPT` paths that the runner printed point to deleted directories. // No test fails the runner on purpose, so check by hand with a failing agent test when changing this. first_dir.disable_cleanup(true); + work_dir.disable_cleanup(true); eprintln!( "Kept the Agent Identity conformance directory {}", first_dir.path().display() From d998affc4d9ea9d884fd4053a62a5c61236a1210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 18:13:24 +0900 Subject: [PATCH 23/60] docs(private-fs): say how discard compares names on Windows Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/private-fs/src/lib.rs b/crates/private-fs/src/lib.rs index 827f15e99a..97bc08907e 100644 --- a/crates/private-fs/src/lib.rs +++ b/crates/private-fs/src/lib.rs @@ -234,6 +234,7 @@ impl Dir { /// Removes `file`, which this handle created as `name`, while it is still at `name`, and never another file. /// /// After a failed [`Dir::persist`], it removes the file only if the rename didn't happen. + /// On Windows, names compare as NTFS compares them, ignoring case. /// /// A file can be created with a rule that gives its creator no access once created, so removing it by name again /// could be refused. From d92087c772a83f24ebb775d7fbd80b579f4157f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 18:21:36 +0900 Subject: [PATCH 24/60] fix(private-fs): compare Windows names as NTFS does when discarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unicode's full case mapping uppercases ß to SS, so a file renamed from `xß` to `xSS` compared as still under its name and was deleted. Names now compare with CompareStringOrdinal ignoring case, which uses the simple one-to-one upcase table NTFS uses. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/Cargo.toml | 1 + crates/private-fs/src/windows/mod.rs | 29 ++++++++++++++++++++++----- testsuite/tests/private_fs/windows.rs | 16 +++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/crates/private-fs/Cargo.toml b/crates/private-fs/Cargo.toml index d8f407be10..bcca305088 100644 --- a/crates/private-fs/Cargo.toml +++ b/crates/private-fs/Cargo.toml @@ -21,6 +21,7 @@ windows = { version = "0.61", features = [ "Wdk_Foundation", "Wdk_Storage_FileSystem", "Win32_Foundation", + "Win32_Globalization", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index 0e2768c3c4..eb2ccaf6a5 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -21,6 +21,7 @@ use windows::Wdk::Storage::FileSystem::{ FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT, NTCREATEFILE_CREATE_OPTIONS, }; use windows::Win32::Foundation::{ERROR_NO_TOKEN, ERROR_NOT_ALL_ASSIGNED, HANDLE, LUID}; +use windows::Win32::Globalization::{CSTR_EQUAL, CompareStringOrdinal}; use windows::Win32::Security::{ AdjustTokenPrivileges, DACL_SECURITY_INFORMATION, DuplicateTokenEx, LUID_AND_ATTRIBUTES, LookupPrivilegeValueW, OWNER_SECURITY_INFORMATION, RevertToSelf, SE_BACKUP_NAME, SE_PRIVILEGE_ENABLED, SecurityImpersonation, @@ -343,18 +344,26 @@ pub(crate) fn remove_file(parent: &Dir, path: &Utf8Path, name: &str) -> Result Result<()> { validate_name(path, name)?; - let read_path = |handle| nt::final_path(handle).map_err(|error| Error::io(path, "read file path", error)); - let directory = read_path(parent.handle())?; - let current = read_path(file.as_handle())?; + let directory = nt::final_path(parent.handle()).map_err(|error| Error::io(path, "read directory path", error))?; + let current = nt::final_path(file.as_handle()).map_err(|error| Error::io(path, "read file path", error))?; let separator = if directory.ends_with('\\') { "" } else { "\\" }; let expected = format!("{directory}{separator}{name}"); - // Windows file names are case-insensitive. - if current.to_uppercase() != expected.to_uppercase() { + if !same_name(¤t, &expected) { return Ok(()); } nt::delete(file.as_handle()).map_err(|error| Error::io(path, "remove file", error)) } +/// Reports whether two names are the same file name, ignoring case as NTFS does: with the simple, one-to-one upcase +/// table, under which `ß` and `SS` differ, unlike Unicode's full case mapping. +fn same_name(left: &str, right: &str) -> bool { + let left: Vec = left.encode_utf16().collect(); + let right: Vec = right.encode_utf16().collect(); + // SAFETY: CompareStringOrdinal only reads both slices for their lengths. + let compared = unsafe { CompareStringOrdinal(&left, &right, true) }; + compared == CSTR_EQUAL +} + pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { let listed = { // A listing restarts and moves the handle's one enumeration cursor, so listings take turns. @@ -909,6 +918,16 @@ mod tests { const REPARSE_POINT: u32 = FILE_ATTRIBUTE_REPARSE_POINT.0; + // oracle: reference crates/private-fs/src/lib.rs Dir::discard + #[test] + fn names_compare_ignoring_case_one_character_at_a_time() { + assert!(same_name("C:\\Dir\\Staged", "c:\\dir\\STAGED")); + assert!(same_name("\u{e9}t\u{e9}", "\u{c9}T\u{c9}")); + // Full case mapping uppercases ß to SS; the simple mapping NTFS uses leaves it. + assert!(!same_name("x\u{df}", "xSS")); + assert!(!same_name("staged", "staged2")); + } + // oracle: spec MS-FSCC §2.1.2.1 Reparse Tags // oracle: reference crates/private-fs/src/error.rs Refusal #[test] diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index dc41b4fd7d..1f33d8e3e9 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -830,6 +830,22 @@ fn discard_keeps_a_file_persisted_under_another_name() -> anyhow::Result<()> { Ok(()) } +// oracle: reference crates/private-fs/src/lib.rs Dir::discard +#[test] +fn discard_keeps_a_file_renamed_to_a_name_only_full_case_mapping_matches() -> anyhow::Result<()> { + // Catches names compared with Unicode's full case mapping, under which `xß` and `xSS` match although NTFS keeps + // them apart, so that a discard deletes the file installed under the other name. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + let mut file = base.create_file("x\u{df}", &user_rule(), FileOptions::new())?; + file.write_all(b"new")?; + base.persist(&file, "x\u{df}", "xSS", Replace::Allow)?; + base.discard(&file, "x\u{df}")?; + drop(file); + assert_eq!(std::fs::read(test.path().join("xSS"))?, b"new"); + Ok(()) +} + // oracle: reference crates/private-fs/src/lib.rs Dir::discard #[test] fn discard_removes_a_file_still_under_its_name() -> anyhow::Result<()> { From bf200d2b28042b174ada228f2514a87936da9884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 18:23:31 +0900 Subject: [PATCH 25/60] test(agent): run the elevated drop-box test against the test's data directory The elevated test of an administrator's submission now checks the test's own data directory with the current user's rule, as its sibling test does, while `identity` and the drop box keep the SYSTEM rules and the backup privilege. It provisions `identity` before the drop box and gives both back to the current user even when the test fails. The administrator's staging handle, which shares nothing, is closed as soon as the file is published, and the submission and the state lock each keep their own sharing-violation retry interval. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/agent-identity/src/pending.rs | 68 +++++----- crates/agent-identity/src/private_file.rs | 155 +++++++++++++++------- crates/agent-identity/src/state_lock.rs | 61 +++++---- 3 files changed, 176 insertions(+), 108 deletions(-) diff --git a/crates/agent-identity/src/pending.rs b/crates/agent-identity/src/pending.rs index 7d5a4f31e3..55d6e04187 100644 --- a/crates/agent-identity/src/pending.rs +++ b/crates/agent-identity/src/pending.rs @@ -353,18 +353,18 @@ mod tests { "A".repeat(43) ))?; let test_dir = private_file::TestDir::new()?; - let dir = test_dir.0.clone(); + let dir = test_dir.path(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; - if fs::metadata(&dir)?.permissions().mode() & 0o022 != 0 { - fs::remove_dir_all(&dir)?; + if fs::metadata(dir)?.permissions().mode() & 0o022 != 0 { + fs::remove_dir_all(dir)?; return Ok(()); } } - private_file::prepare_directories(&dir, true)?; - let path = file_path(&dir, &token.sha256_hex())?; + private_file::prepare_directories(dir, true)?; + let path = file_path(dir, &token.sha256_hex())?; let body = format!(r#"{{"version":1,"token":"{}"}}"#, token.as_str()); // JSON allows trailing white space, which pads the payload to an exact size. for (size, readable) in [(16 * 1024, true), (16 * 1024 + 1, false)] { @@ -373,14 +373,14 @@ mod tests { let contents = crate::windows::protect(padded.as_bytes())?; #[cfg(not(windows))] let contents = padded.into_bytes(); - private_file::write_atomic(&dir, &path, &contents, true)?; + private_file::write_atomic(dir, &path, &contents, true)?; assert_eq!( matches!(read(&path, true)?, PendingRead::Ready(found) if found.as_str() == token.as_str()), readable, "{size}" ); } - fs::remove_dir_all(&dir)?; + fs::remove_dir_all(dir)?; Ok(()) } @@ -394,19 +394,19 @@ mod tests { "A".repeat(43) ); let test_dir = private_file::TestDir::new()?; - let dir = test_dir.0.clone(); + let dir = test_dir.path(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; - if fs::metadata(&dir)?.permissions().mode() & 0o022 != 0 { - fs::remove_dir_all(&dir)?; + if fs::metadata(dir)?.permissions().mode() & 0o022 != 0 { + fs::remove_dir_all(dir)?; return Ok(()); } } let hash = token::sha256_hex(&token); - let path = file_path(&dir, &hash)?; - private_file::prepare_directories(&dir, true)?; + let path = file_path(dir, &hash)?; + private_file::prepare_directories(dir, true)?; for oversized in [ format!( r#"{{"version":1,"token":"{token}","padding":"{}"}}"#, @@ -423,7 +423,7 @@ mod tests { let contents = crate::windows::protect(oversized.as_bytes())?; #[cfg(not(windows))] let contents = oversized.into_bytes(); - private_file::write_atomic(&dir, &path, &contents, true)?; + private_file::write_atomic(dir, &path, &contents, true)?; assert!(matches!(read(&path, true)?, PendingRead::Unusable)); } #[cfg(windows)] @@ -435,10 +435,10 @@ mod tests { .into_bytes(); let ciphertext = crate::windows::protect(&oversized)?; assert!(ciphertext.len() > MAX_PENDING_BYTES + 4096); - private_file::write_atomic(&dir, &path, &ciphertext, true)?; + private_file::write_atomic(dir, &path, &ciphertext, true)?; assert!(matches!(read(&path, true)?, PendingRead::Unusable)); } - fs::remove_dir_all(&dir)?; + fs::remove_dir_all(dir)?; Ok(()) } @@ -455,43 +455,43 @@ mod tests { ); let token = Token::parse(&raw)?; let test_dir = private_file::TestDir::new()?; - let dir = test_dir.0.clone(); + let dir = test_dir.path(); let hash = hex::encode(Sha256::digest(raw.as_bytes())); assert_eq!( - file_path(&dir, &hash)?, - directory(&dir).join(format!("{hash}.{EXTENSION}")) + file_path(dir, &hash)?, + directory(dir).join(format!("{hash}.{EXTENSION}")) ); - let result = write(&dir, &token, true); + let result = write(dir, &token, true); #[cfg(unix)] - if fs::metadata(&dir)?.permissions().mode() & 0o022 != 0 { + if fs::metadata(dir)?.permissions().mode() & 0o022 != 0 { assert!( result.is_err(), "pending file written to volume without owner-only permissions" ); - assert!(list(&dir)?.is_empty()); - fs::remove_dir_all(&dir)?; + assert!(list(dir)?.is_empty()); + fs::remove_dir_all(dir)?; return Ok(()); } let path = result?; - assert_eq!(path, file_path(&dir, &hash)?); + assert_eq!(path, file_path(dir, &hash)?); assert!(matches!(read(&path, true)?, PendingRead::Ready(found) if found.as_str() == raw)); - let overwritten = write(&dir, &token, true)?; + let overwritten = write(dir, &token, true)?; assert_eq!(overwritten, path); - assert_eq!(list(&dir)?, vec![path.clone()]); - let token_named = directory(&dir).join(format!("{raw}.{EXTENSION}")); + assert_eq!(list(dir)?, vec![path.clone()]); + let token_named = directory(dir).join(format!("{raw}.{EXTENSION}")); fs::write(&token_named, b"not encrypted")?; #[cfg(unix)] fs::set_permissions(&token_named, fs::Permissions::from_mode(0o644))?; - assert_eq!(list(&dir)?, vec![path.clone()]); + assert_eq!(list(dir)?, vec![path.clone()]); let error = read(&token_named, true).expect_err("untrusted token-named file was read"); assert!(!format!("{error:#}").contains(&raw)); fs::remove_file(token_named)?; - private_file::write_atomic(&dir, &path, b"not a token", true)?; + private_file::write_atomic(dir, &path, b"not a token", true)?; #[cfg(windows)] { assert!(matches!(read(&path, true)?, PendingRead::Undecryptable)); let encrypted = crate::windows::protect(b"not valid JSON")?; - private_file::write_atomic(&dir, &path, &encrypted, true)?; + private_file::write_atomic(dir, &path, &encrypted, true)?; assert!(matches!(read(&path, true)?, PendingRead::Unusable)); } #[cfg(not(windows))] @@ -513,7 +513,7 @@ mod tests { URL_SAFE_NO_PAD.encode(r#"{"u":"https://other.test"}"#), "A".repeat(43) ))?; - let independent = file_path(&dir, &other.sha256_hex())?; + let independent = file_path(dir, &other.sha256_hex())?; fs::write(&independent, b"independently supplied blob")?; assert!( read(&independent, true).is_err(), @@ -523,16 +523,16 @@ mod tests { crate::windows::test::assert_pending_protection(&path, true)?; let protected_path = independent; if crate::windows::is_system()? { - assert_eq!(write(&dir, &other, false)?, protected_path); - assert_eq!(write(&dir, &other, false)?, protected_path); + assert_eq!(write(dir, &other, false)?, protected_path); + assert_eq!(write(dir, &other, false)?, protected_path); crate::windows::test::assert_pending_protection(&protected_path, false)?; fs::remove_file(protected_path)?; } else { - assert!(write(&dir, &other, false).is_err()); + assert!(write(dir, &other, false).is_err()); assert!(!protected_path.exists()); } } - fs::remove_dir_all(&dir)?; + fs::remove_dir_all(dir)?; Ok(()) } } diff --git a/crates/agent-identity/src/private_file.rs b/crates/agent-identity/src/private_file.rs index c487c0fd2f..fe5f3536a2 100644 --- a/crates/agent-identity/src/private_file.rs +++ b/crates/agent-identity/src/private_file.rs @@ -550,7 +550,7 @@ fn submit_into_drop_box( .then(private_fs::BackupPrivilege::enable) .transpose() .context("enable administrator backup privilege")?; - let data = retry_sharing_violation(started, SUBMISSION_TIMEOUT, || { + let data = retry_sharing_violation(started, SUBMISSION_TIMEOUT, SUBMISSION_RETRY_INTERVAL, || { Ok(Dir::open_base(data_dir, &rules.base)?) }) .and_then(|data| data.ok_or_else(not_found)) @@ -591,6 +591,8 @@ fn submit_into_drop_box( // The rule gives the administrator no access to the file, so only the handle that created it can remove it. let _ = pending.discard(&file, &staging); } + // The handle shares nothing, so the service can read the published file only once it is closed. + drop(file); published.map(drop) } @@ -598,20 +600,25 @@ fn submit_into_drop_box( #[cfg(windows)] const SUBMISSION_TIMEOUT: Duration = Duration::from_secs(10); +/// How often an administrator's submission tries again while the service holds the identity directory. +#[cfg(windows)] +const SUBMISSION_RETRY_INTERVAL: Duration = Duration::from_millis(100); + #[cfg(windows)] fn open_drop_box_directory(parent: &Dir, name: &str, rule: &Rule, started: Instant) -> anyhow::Result { - retry_sharing_violation(started, SUBMISSION_TIMEOUT, || { + retry_sharing_violation(started, SUBMISSION_TIMEOUT, SUBMISSION_RETRY_INTERVAL, || { Ok(parent.open_dir(name, rule, Share::Shared)?) })? .ok_or_else(not_found) } -/// Retries `operation` while it fails with a sharing violation, as while another process holds the identity directory, -/// until `timeout` after `started`. +/// Retries `operation` every `interval` while it fails with a sharing violation, as while another process holds the +/// identity directory, until `timeout` after `started`. #[cfg(windows)] pub(crate) fn retry_sharing_violation( started: Instant, timeout: Duration, + interval: Duration, mut operation: impl FnMut() -> anyhow::Result, ) -> anyhow::Result { loop { @@ -621,7 +628,7 @@ pub(crate) fn retry_sharing_violation( if remaining.is_zero() { return Err(error); } - std::thread::sleep(remaining.min(Duration::from_millis(25))); + std::thread::sleep(remaining.min(interval)); } result => return result, } @@ -677,6 +684,10 @@ pub(crate) struct TestDir(pub(crate) Utf8PathBuf); #[cfg(test)] impl TestDir { + pub(crate) fn path(&self) -> &Utf8Path { + &self.0 + } + pub(crate) fn new() -> anyhow::Result { let path = Utf8PathBuf::from_path_buf(std::env::temp_dir()) .map_err(|_| anyhow::anyhow!("the temporary directory path is not UTF-8"))? @@ -1091,8 +1102,9 @@ mod windows_tests { Ok(()) } - /// Runs `operation` impersonating a copy of the process token with the restore and take-ownership privileges enabled, - /// which let an elevated test give a directory SYSTEM as its owner and take it back. + /// Runs `operation` impersonating a copy of the process token with the backup, restore and take-ownership privileges + /// enabled, which let an elevated test give a directory SYSTEM as its owner, read the directories below it while + /// their inheritance is applied again, and take it back. fn with_restore_privilege(operation: impl FnOnce() -> anyhow::Result) -> anyhow::Result { use ::windows::Win32::Security::{ SecurityImpersonation, TOKEN_ADJUST_PRIVILEGES, TOKEN_DUPLICATE, TOKEN_IMPERSONATE, TOKEN_QUERY, @@ -1109,12 +1121,85 @@ mod windows_tests { SecurityImpersonation, TokenImpersonation, )?; + token.enable_privilege(privilege::SE_BACKUP_NAME)?; token.enable_privilege(privilege::SE_RESTORE_NAME)?; token.enable_privilege(privilege::SE_TAKE_OWNERSHIP_NAME)?; let _impersonation = token.impersonate()?; operation() } + /// Gives a test's `identity` and drop box the owner and DACLs the service running as SYSTEM provisions them with, + /// until restored or dropped, so that a failing test leaves no directory in the temporary directory that only SYSTEM + /// can remove. + struct SystemProvisioned<'a> { + directory: &'a TestDir, + restored: bool, + } + + impl<'a> SystemProvisioned<'a> { + fn new(directory: &'a TestDir) -> anyhow::Result { + let mut provisioned = Self { + directory, + restored: false, + }; + let system = acl::system_sid()?; + // SYSTEM owns them, only SYSTEM can list or read them, and administrators can only add a file to the drop + // box. + // `identity` comes first: changing its DACL applies its inheritance again below it, which reads the drop box + // while the current user still has access to it. + let provisioning = with_restore_privilege(|| { + for path in [directory.identity(), directory.pending()] { + acl::set_owner(&path, &system)?; + } + acl::set_dacl(&directory.identity(), &[full(&system, INHERITED)])?; + acl::set_dacl(&directory.pending(), &[full(&system, INHERITED), add_only()?]) + }); + if let Err(error) = provisioning { + let _ = provisioned.restore(); + return Err(error); + } + Ok(provisioned) + } + + /// Gives the directories back to the current user, as `TestDir::provisioned` made them. + fn restore(&mut self) -> anyhow::Result<()> { + self.restored = true; + let system = acl::system_sid()?; + let user = acl::user_sid()?; + let directory = self.directory; + // Taking ownership back gives the current user the owner's implicit right to change the DACLs. + with_restore_privilege(|| { + for path in [directory.identity(), directory.pending()] { + acl::set_owner(&path, &user)?; + } + acl::set_dacl( + &directory.identity(), + &[full(&system, INHERITED), full(&user, INHERITED)], + )?; + acl::set_dacl( + &directory.pending(), + &[full(&system, INHERITED), full(&user, INHERITED), add_only()?], + ) + }) + } + } + + impl Drop for SystemProvisioned<'_> { + fn drop(&mut self) { + if !self.restored { + let _ = self.restore(); + } + } + } + + fn add_only() -> anyhow::Result { + Ok(( + Sid::from_well_known(WinBuiltinAdministratorsSid, None)?, + FILE_ADD_FILE.0 | SYNCHRONIZE.0, + NO_INHERITANCE.0, + )) + } + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files // oracle: intent devolutions-agent/src/identity/INTENT.md §CLI #[test] @@ -1131,42 +1216,19 @@ mod windows_tests { return Ok(()); } let directory = TestDir::provisioned()?; - let system = acl::system_sid()?; - let user = acl::user_sid()?; - let add_only = ( - Sid::from_well_known(WinBuiltinAdministratorsSid, None)?, - FILE_ADD_FILE.0 | SYNCHRONIZE.0, - NO_INHERITANCE.0, - ); let path = directory.pending_file('a'); - // The directories as the service running as SYSTEM provisions them: SYSTEM owns them, only SYSTEM can list or - // read them, and administrators can only add a file to the drop box. - with_restore_privilege(|| { - for directory in [directory.identity(), directory.pending()] { - acl::set_owner(&directory, &system)?; - } - acl::set_dacl(&directory.pending(), &[full(&system, INHERITED), add_only.clone()])?; - acl::set_dacl(&directory.identity(), &[full(&system, INHERITED)]) - })?; + let mut provisioned = SystemProvisioned::new(&directory)?; + // The test's data directory is the current user's, unlike the directories the service provisions. + let rules = DropBox { + base: tree_rule(true), + ..DropBox::provisioned_by(Account::System, false) + }; - let submitted = submit_pending_as_administrator(&directory.0, &path, b"test payload"); + let submitted = submit_into_drop_box(&directory.0, &path, b"test payload", &rules, true); // The same token again finds its pending file there, so the second submission discards its staging file through // the handle that created it, which is all the access an administrator has to it. - let replayed = submit_pending_as_administrator(&directory.0, &path, b"test payload"); - // Taking ownership back gives the current user the owner's implicit right to change the DACLs. - with_restore_privilege(|| { - for directory in [directory.identity(), directory.pending()] { - acl::set_owner(&directory, &user)?; - } - acl::set_dacl( - &directory.identity(), - &[full(&system, INHERITED), full(&user, INHERITED)], - )?; - acl::set_dacl( - &directory.pending(), - &[full(&system, INHERITED), full(&user, INHERITED), add_only.clone()], - ) - })?; + let replayed = submit_into_drop_box(&directory.0, &path, b"test payload", &rules, true); + provisioned.restore()?; submitted?; replayed?; @@ -1326,7 +1388,7 @@ mod windows_tests { }; // oracle: mutant a sharing violation, as while the service holds the identity directory, fails at once let mut attempts = 0; - retry_sharing_violation(Instant::now(), SUBMISSION_TIMEOUT, || { + retry_sharing_violation(Instant::now(), SUBMISSION_TIMEOUT, SUBMISSION_RETRY_INTERVAL, || { attempts += 1; if attempts == 1 { Err(sharing()) } else { Ok(()) } })?; @@ -1334,12 +1396,13 @@ mod windows_tests { // oracle: mutant any failure is retried until the timeout let mut attempts = 0; - let result: anyhow::Result<()> = retry_sharing_violation(Instant::now(), SUBMISSION_TIMEOUT, || { - attempts += 1; - Err(anyhow::Error::new(io::Error::from_raw_os_error(i32::try_from( - ERROR_ACCESS_DENIED.0, - )?))) - }); + let result: anyhow::Result<()> = + retry_sharing_violation(Instant::now(), SUBMISSION_TIMEOUT, SUBMISSION_RETRY_INTERVAL, || { + attempts += 1; + Err(anyhow::Error::new(io::Error::from_raw_os_error(i32::try_from( + ERROR_ACCESS_DENIED.0, + )?))) + }); assert!(result.is_err()); assert_eq!(attempts, 1); diff --git a/crates/agent-identity/src/state_lock.rs b/crates/agent-identity/src/state_lock.rs index 486296062e..70c1fcd4d2 100644 --- a/crates/agent-identity/src/state_lock.rs +++ b/crates/agent-identity/src/state_lock.rs @@ -71,15 +71,20 @@ fn lock_file(data_dir: &Utf8Path, grant_current_user: bool) -> anyhow::Result anyhow::Result { use std::time::{Duration, Instant}; - private_file::retry_sharing_violation(Instant::now(), Duration::from_secs(30), || { - private_file::provision_directories(data_dir, grant_current_user)? - .open_dir( - "identity", - &private_file::tree_rule(grant_current_user), - private_fs::Share::Exclusive, - ) - .context("open identity state lock") - }) + private_file::retry_sharing_violation( + Instant::now(), + Duration::from_secs(30), + Duration::from_millis(25), + || { + private_file::provision_directories(data_dir, grant_current_user)? + .open_dir( + "identity", + &private_file::tree_rule(grant_current_user), + private_fs::Share::Exclusive, + ) + .context("open identity state lock") + }, + ) .map_err(|error| { if private_file::is_sharing_violation(&error) { error.context("identity state lock remained busy") @@ -108,25 +113,25 @@ mod tests { #[test] fn trusted_lock_serializes_concurrent_writers() -> anyhow::Result<()> { let directory = private_file::TestDir::new()?; - let worker_dir = directory.0.clone(); - let lock = acquire(&directory.0, true)?; + let lock = acquire(directory.path(), true)?; let (sender, receiver) = std::sync::mpsc::channel(); - let worker = std::thread::spawn(move || { - let result = acquire(&worker_dir, true).map(|_lock| ()); - let _ = sender.send(result); - }); - std::thread::sleep(Duration::from_millis(50)); - match receiver.try_recv() { - Err(std::sync::mpsc::TryRecvError::Empty) => {} - Err(std::sync::mpsc::TryRecvError::Disconnected) => anyhow::bail!("identity lock worker disconnected"), - Ok(Ok(())) => anyhow::bail!("concurrent writer acquired the identity lock before release"), - Ok(Err(error)) => return Err(error).context("concurrent writer failed before release"), - } - drop(lock); - receiver.recv_timeout(Duration::from_secs(5))??; - worker - .join() - .map_err(|_| anyhow::anyhow!("identity lock worker panicked"))?; - Ok(()) + std::thread::scope(|scope| { + let worker = scope.spawn(|| { + let result = acquire(directory.path(), true).map(|_lock| ()); + let _ = sender.send(result); + }); + std::thread::sleep(Duration::from_millis(50)); + match receiver.try_recv() { + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => anyhow::bail!("identity lock worker disconnected"), + Ok(Ok(())) => anyhow::bail!("concurrent writer acquired the identity lock before release"), + Ok(Err(error)) => return Err(error).context("concurrent writer failed before release"), + } + drop(lock); + receiver.recv_timeout(Duration::from_secs(5))??; + worker + .join() + .map_err(|_| anyhow::anyhow!("identity lock worker panicked")) + }) } } From 5110f33614cd20328f6d3f8f18a7298790e29392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 19:08:22 +0900 Subject: [PATCH 26/60] test(private-fs): cover the parent of the first existing directory, unprotected DACLs and exact masks Add tests for check decisions no existing test isolated: the directory directly above the first existing one is checked on Windows and Unix, a DACL holding exactly the rule's entries but not protected is refused, and an exact check refuses a narrower mask in place of the rule's. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/windows/security.rs | 18 ++++++++++++++++++ testsuite/tests/private_fs/unix.rs | 16 ++++++++++++++++ testsuite/tests/private_fs/windows.rs | 23 +++++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/crates/private-fs/src/windows/security.rs b/crates/private-fs/src/windows/security.rs index dde6f5becb..e6f8fa65aa 100644 --- a/crates/private-fs/src/windows/security.rs +++ b/crates/private-fs/src/windows/security.rs @@ -801,6 +801,24 @@ mod tests { assert_eq!(refusal(checked), "Access"); } + // oracle: intent crates/private-fs/INTENT.md §vocabulary + #[test] + fn an_exact_check_refuses_a_narrower_mask_in_place_of_the_rule_s() { + // Catches an exact check that compares how many entries there are rather than what they grant. + let security = security("O:{user}D:P(A;;FR;;;SY)(A;;;;;OW)"); + let checked = policy(&system_rule().exact(), Kind::File).check(&security, Utf8Path::new("item"), &no_member); + assert_eq!(refusal(checked), "Access"); + } + + // oracle: intent crates/private-fs/INTENT.md §mechanisms + #[test] + fn a_check_refuses_the_rule_s_entries_in_an_unprotected_dacl() { + // An unprotected DACL takes the parent's inheritable entries, so its entries alone don't decide who has access. + let security = security("O:{user}D:(A;;FA;;;SY)(A;;;;;OW)"); + let checked = policy(&system_rule(), Kind::File).check(&security, Utf8Path::new("item"), &no_member); + assert_eq!(refusal(checked), "Access"); + } + // oracle: intent crates/private-fs/INTENT.md §vocabulary #[test] fn a_check_refuses_owner_rights_wider_than_the_owner_s_access() { diff --git a/testsuite/tests/private_fs/unix.rs b/testsuite/tests/private_fs/unix.rs index d8ca10240d..63656fbdb0 100644 --- a/testsuite/tests/private_fs/unix.rs +++ b/testsuite/tests/private_fs/unix.rs @@ -375,6 +375,22 @@ fn a_directory_above_that_its_group_can_write_is_refused() { assert_eq!(error.path(), canonical(&shared)); } +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn the_parent_of_the_first_existing_directory_is_checked() { + // Catches an ancestor walk that starts above the parent of the directory it opened. + let scratch = Scratch::new(); + let shared = scratch.path.join("shared"); + fs::create_dir(&shared).unwrap(); + fs::create_dir(shared.join("base")).unwrap(); + set_mode(&shared.join("base"), 0o700); + set_mode(&shared, 0o770); + + let error = Dir::open_base(&shared.join("base"), &rule()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Replaceable)), "{error:?}"); + assert_eq!(error.path(), canonical(&shared)); +} + // A member of root's group need not be root, as members of `wheel` and `admin` on macOS are not. // oracle: intent crates/private-fs/INTENT.md §paths #[test] diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index 1f33d8e3e9..77830ea5c8 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -580,6 +580,29 @@ fn a_directory_above_whose_entries_others_can_delete_is_refused() -> anyhow::Res Ok(()) } +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn the_parent_of_the_first_existing_directory_is_checked() -> anyhow::Result<()> { + // Catches an ancestor walk that starts above the parent of the directory it opened. + let test = TestDir::new(); + std::fs::create_dir(test.path())?; + set_dacl( + test.path(), + &[(user(), FULL, INHERITED_BY_CHILDREN), (users(), DELETE_CHILD, 0)], + InheritableAclKind::Protected, + )?; + let existing = test.path().join("existing"); + std::fs::create_dir(&existing)?; + let error = refusal(Dir::create_base(&existing.join("base"), &user_rule())); + assert!( + is_refused(&error, |refusal| matches!(refusal, Refusal::Replaceable)), + "{error:?}" + ); + // A directory above the first existing one is checked, and so reported, on its resolved path. + assert_eq!(error.path().file_name(), test.path().file_name()); + Ok(()) +} + // oracle: intent crates/device-key/INTENT.md §Operations #[test] fn a_missing_directory_under_a_replaceable_one_is_reported_missing() -> anyhow::Result<()> { From 50da61a60fad32a127e2c768d184784194567319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 19:13:56 +0900 Subject: [PATCH 27/60] fix(agent): repair an identity directory whose access denies the agent Such a directory fails to open rather than failing its rule, so start-up left it as it was; repair needs only the access its owner always keeps. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/agent-identity/src/private_file.rs | 30 +++++++++++++++++++++-- crates/agent-identity/src/state_lock.rs | 5 ++-- crates/private-fs/src/lib.rs | 3 ++- testsuite/tests/private_fs/windows.rs | 2 +- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/agent-identity/src/private_file.rs b/crates/agent-identity/src/private_file.rs index fe5f3536a2..8d9db5f73e 100644 --- a/crates/agent-identity/src/private_file.rs +++ b/crates/agent-identity/src/private_file.rs @@ -197,8 +197,10 @@ pub(crate) fn provision_directories(data_dir: &Utf8Path, grant_current_user: boo fn provision(parent: &Dir, name: &str, rule: &Rule) -> anyhow::Result { // Repairing only a directory that fails its rule leaves a correct one unwritten. + // A directory whose access denies the agent fails to open rather than failing the rule, and is repaired too, + // since repair needs only the access its owner always keeps. match parent.create_dir(name, rule, Existing::Check) { - Err(error) if error.is_refused() => parent.create_dir(name, rule, Existing::Repair), + Err(error) if fails_trust_check(&error) => parent.create_dir(name, rule, Existing::Repair), created => created, } .context("provision identity directory") @@ -835,7 +837,9 @@ mod windows_tests { use ::windows::Win32::Security::{ CONTAINER_INHERIT_ACE, NO_INHERITANCE, OBJECT_INHERIT_ACE, WinBuiltinAdministratorsSid, WinBuiltinUsersSid, }; - use ::windows::Win32::Storage::FileSystem::{FILE_ADD_FILE, FILE_ALL_ACCESS, SYNCHRONIZE, WRITE_DAC}; + use ::windows::Win32::Storage::FileSystem::{ + FILE_ADD_FILE, FILE_ALL_ACCESS, FILE_GENERIC_READ, SYNCHRONIZE, WRITE_DAC, + }; use win_api_wrappers::identity::sid::Sid; use super::*; @@ -945,6 +949,28 @@ mod windows_tests { Ok(()) } + // oracle: intent crates/agent-identity/INTENT.md §Local state + #[test] + fn repairs_a_directory_whose_access_denies_the_agent() -> anyhow::Result<()> { + // Catches a start-up that repairs only a directory failing its rule, not one it can't open. + let directory = TestDir::new()?; + let identity = directory.identity(); + fs::create_dir(&identity)?; + let users = Sid::from_well_known(WinBuiltinUsersSid, None)?; + acl::set_dacl(&identity, &[(users, FILE_GENERIC_READ.0, 0)])?; + + prepare_directories(&directory.0, true)?; + + assert_eq!( + acl::protected_dacl_of(&identity)?, + Some(acl::sorted(&[ + full(&acl::system_sid()?, INHERITED), + full(&acl::user_sid()?, INHERITED) + ])) + ); + Ok(()) + } + // oracle: intent crates/agent-identity/INTENT.md §Local state #[test] fn repairs_a_directory_with_an_extra_allow_principal() -> anyhow::Result<()> { diff --git a/crates/agent-identity/src/state_lock.rs b/crates/agent-identity/src/state_lock.rs index 70c1fcd4d2..690ccfeecf 100644 --- a/crates/agent-identity/src/state_lock.rs +++ b/crates/agent-identity/src/state_lock.rs @@ -115,9 +115,10 @@ mod tests { let directory = private_file::TestDir::new()?; let lock = acquire(directory.path(), true)?; let (sender, receiver) = std::sync::mpsc::channel(); + let path = directory.path(); std::thread::scope(|scope| { - let worker = scope.spawn(|| { - let result = acquire(directory.path(), true).map(|_lock| ()); + let worker = scope.spawn(move || { + let result = acquire(path, true).map(|_lock| ()); let _ = sender.send(result); }); std::thread::sleep(Duration::from_millis(50)); diff --git a/crates/private-fs/src/lib.rs b/crates/private-fs/src/lib.rs index 97bc08907e..9e6794dd8b 100644 --- a/crates/private-fs/src/lib.rs +++ b/crates/private-fs/src/lib.rs @@ -234,7 +234,8 @@ impl Dir { /// Removes `file`, which this handle created as `name`, while it is still at `name`, and never another file. /// /// After a failed [`Dir::persist`], it removes the file only if the rename didn't happen. - /// On Windows, names compare as NTFS compares them, ignoring case. + /// On Windows, names compare as NTFS compares them, ignoring case, so a rename that changes only case counts as the + /// same name. /// /// A file can be created with a rule that gives its creator no access once created, so removing it by name again /// could be refused. diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index 77830ea5c8..bf5ed23c69 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -377,7 +377,7 @@ fn a_check_refuses_an_account_the_rule_does_not_name() -> anyhow::Result<()> { // oracle: intent crates/private-fs/INTENT.md §mechanisms #[test] -fn a_check_refuses_an_unprotected_dacl() -> anyhow::Result<()> { +fn a_check_refuses_inherited_entries() -> anyhow::Result<()> { // Catches a check that ignores inherited entries, which the parent directory decides. let test = TestDir::new(); let base = Dir::create_base(test.path(), &user_rule())?; From 14f7cf2609e346f362d2914d71570a212c7b76d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 19:18:00 +0900 Subject: [PATCH 28/60] style(private-fs): format Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/agent-identity/src/state.rs | 1 - crates/private-fs/src/windows/mod.rs | 9 ++++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/agent-identity/src/state.rs b/crates/agent-identity/src/state.rs index add5b5c2d4..ceb8abe7c6 100644 --- a/crates/agent-identity/src/state.rs +++ b/crates/agent-identity/src/state.rs @@ -881,7 +881,6 @@ mod tests { use x509_cert::time::{Time, Validity}; use super::*; - use crate::private_file::TestDir; impl TestDir { diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index eb2ccaf6a5..b878d13f9b 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -23,12 +23,11 @@ use windows::Wdk::Storage::FileSystem::{ use windows::Win32::Foundation::{ERROR_NO_TOKEN, ERROR_NOT_ALL_ASSIGNED, HANDLE, LUID}; use windows::Win32::Globalization::{CSTR_EQUAL, CompareStringOrdinal}; use windows::Win32::Security::{ - AdjustTokenPrivileges, DACL_SECURITY_INFORMATION, DuplicateTokenEx, LUID_AND_ATTRIBUTES, LookupPrivilegeValueW, - OWNER_SECURITY_INFORMATION, RevertToSelf, SE_BACKUP_NAME, SE_PRIVILEGE_ENABLED, SecurityImpersonation, - SetKernelObjectSecurity, TOKEN_ADJUST_PRIVILEGES, TOKEN_DUPLICATE, TOKEN_IMPERSONATE, TOKEN_PRIVILEGES, - TOKEN_QUERY, TokenImpersonation, + AdjustTokenPrivileges, DACL_SECURITY_INFORMATION, DuplicateTokenEx, GetTokenInformation, LUID_AND_ATTRIBUTES, + LookupPrivilegeValueW, OWNER_SECURITY_INFORMATION, RevertToSelf, SE_BACKUP_NAME, SE_PRIVILEGE_ENABLED, + SecurityImpersonation, SetKernelObjectSecurity, TOKEN_ADJUST_PRIVILEGES, TOKEN_DUPLICATE, TOKEN_IMPERSONATE, + TOKEN_PRIVILEGES, TOKEN_QUERY, TOKEN_STATISTICS, TokenImpersonation, TokenStatistics, }; -use windows::Win32::Security::{GetTokenInformation, TOKEN_STATISTICS, TokenStatistics}; use windows::Win32::Storage::FileSystem::{ DELETE, FILE_ACCESS_RIGHTS, FILE_ADD_FILE, FILE_ATTRIBUTE_DEVICE, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAGS_AND_ATTRIBUTES, From d52105ed983212f80111d2108752522b680c6a9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 19:39:09 +0900 Subject: [PATCH 29/60] test(private-fs): name the Linux headers the ACL and volume tests cite Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/linux.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/private-fs/src/linux.rs b/crates/private-fs/src/linux.rs index b3f1768758..db65342a6e 100644 --- a/crates/private-fs/src/linux.rs +++ b/crates/private-fs/src/linux.rs @@ -322,7 +322,7 @@ mod tests { } // A parser that misread the header or an entry would hide a named entry from the checks. - // oracle: spec Linux include/uapi/linux/posix_acl_xattr.h + // oracle: spec Linux UAPI posix_acl_xattr.h §posix_acl_xattr_entry #[test] fn parses_the_kernel_attribute_format() { // Version 2, then user nobody with read and write: tag 0x0002, perm 0x0006, ID 65534 (0x0000FFFE). @@ -374,13 +374,13 @@ mod tests { ); } - // oracle: spec Linux include/uapi/linux/fs.h RENAME_NOREPLACE + // oracle: spec Linux UAPI fs.h §RENAME_NOREPLACE #[test] fn passes_the_kernel_flag_for_an_exclusive_rename() { assert_eq!(u32::try_from(RENAME_NOREPLACE).ok(), Some(libc::RENAME_NOREPLACE)); } - // oracle: spec Linux include/uapi/linux/posix_acl_xattr.h + // oracle: spec Linux UAPI posix_acl_xattr.h §posix_acl_xattr_entry #[test] fn rejects_an_attribute_it_cannot_read() { // Version 1. @@ -435,7 +435,7 @@ mod tests { assert_eq!(owning_group_writes(&AncestorAcl(None)), None); } - // oracle: spec Linux include/uapi/linux/magic.h + // oracle: spec Linux UAPI magic.h §CIFS_SUPER_MAGIC #[test] fn recognizes_volumes_that_fake_ownership() { // CIFS_SUPER_MAGIC as a 64-bit platform reports it, and as a 32-bit one does, sign-extended. From c5873757fa908827fff48df34b7ecb26e4edf6cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 19:39:51 +0900 Subject: [PATCH 30/60] fix(private-fs): refuse remote volumes and read directories above with READ_CONTROL only A file or directory on a remote volume, such as an SMB share, is refused: the share keeps ACLs, but its server decides access with its own accounts. Each directory above the first existing one is opened with READ_CONTROL only, so traversing and reading its permissions suffice; a directory that now resolves to another path than its resolved one is refused as replaceable, which replaces reading its attributes. repair_file brings an open file's owner and DACL into line with its rule, as a directory's repair does, and checks it exactly. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/windows/mod.rs | 119 +++++++++++++++++++++----- crates/private-fs/src/windows/nt.rs | 45 +++++++++- testsuite/tests/private_fs/windows.rs | 34 ++++++++ 3 files changed, 174 insertions(+), 24 deletions(-) diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index b878d13f9b..7e58bf71a2 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -20,6 +20,7 @@ use windows::Wdk::Storage::FileSystem::{ FILE_CREATE, FILE_DIRECTORY_FILE, FILE_NON_DIRECTORY_FILE, FILE_OPEN, FILE_OPEN_FOR_BACKUP_INTENT, FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT, NTCREATEFILE_CREATE_OPTIONS, }; +use windows::Win32::Foundation::ERROR_DIRECTORY; use windows::Win32::Foundation::{ERROR_NO_TOKEN, ERROR_NOT_ALL_ASSIGNED, HANDLE, LUID}; use windows::Win32::Globalization::{CSTR_EQUAL, CompareStringOrdinal}; use windows::Win32::Security::{ @@ -196,7 +197,7 @@ pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, let repairing = nt::create(parent.handle(), name, &request) .map_err(|error| Error::io(path, "open directory for repair", error))?; let policy = Policy::new(rule, Kind::Directory, path)?; - repair(repairing.as_handle(), path, &policy)?; + repair(repairing.as_handle(), path, Kind::Directory, &policy)?; let handle = reopen(repairing.as_handle(), DIRECTORY_ACCESS) .map_err(|error| Error::io(path, "open directory", error))?; check_item(handle.as_handle(), path, Kind::Directory, &policy.exact())?; @@ -425,14 +426,25 @@ pub(crate) fn check_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { pub(crate) fn repair_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { let policy = Policy::new(rule, Kind::Directory, path)?; - repair(dir.handle(), path, &policy)?; + repair(dir.handle(), path, Kind::Directory, &policy)?; check_item(dir.handle(), path, Kind::Directory, &policy.exact()) } -/// Brings the owner and DACL of the directory `handle` refers to into line with `policy`, through a handle opened again +/// Brings an open file's owner and DACL into line with `rule`, then checks it exactly. +#[cfg_attr( + not(test), + expect(dead_code, reason = "lib.rs exposes it as private_fs::repair_file") +)] +pub(crate) fn repair_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { + let policy = Policy::new(rule, Kind::File, path)?; + repair(file.as_handle(), path, Kind::File, &policy)?; + check_item(file.as_handle(), path, Kind::File, &policy.exact()) +} + +/// Brings the owner and DACL of the item `handle` refers to into line with `policy`, through a handle opened again /// with the repair's access only, without checking the result. -fn repair(handle: BorrowedHandle<'_>, path: &Utf8Path, policy: &Policy) -> Result<()> { - classify(handle, path, Kind::Directory)?; +fn repair(handle: BorrowedHandle<'_>, path: &Utf8Path, kind: Kind, policy: &Policy) -> Result<()> { + classify(handle, path, kind)?; let security = OwnerAndDacl::of_handle(handle).map_err(|error| Error::io(path, "read access control list", error))?; let current_owner = security.owner(); @@ -463,19 +475,18 @@ fn repair(handle: BorrowedHandle<'_>, path: &Utf8Path, policy: &Policy) -> Resul descriptor: None, }; // An empty name relative to a handle names its object. - let reopened = - nt::create(handle, "", &request).map_err(|error| Error::io(path, "open directory for repair", error))?; + let reopened = nt::create(handle, "", &request).map_err(|error| Error::io(path, "open for repair", error))?; let original = nt::file_id(handle).map_err(|error| Error::io(path, "read file ID", error))?; let current = nt::file_id(reopened.as_handle()).map_err(|error| Error::io(path, "read file ID", error))?; if original != current { return Err(Error::io( path, - "open directory for repair", - io::Error::other("the directory was replaced while reopening it"), + "open for repair", + io::Error::other("the item was replaced while reopening it"), )); } // Unlike SetSecurityInfo, SetKernelObjectSecurity doesn't propagate inheritable entries to existing children, so a - // repair never changes what lies below the directory. + // repair never changes what lies below a directory. // SAFETY: The handle is open and the descriptor stays live for the call. unsafe { SetKernelObjectSecurity(raw(reopened.as_handle()), information, descriptor.as_raw()) } .map_err(|error| Error::io(path, "set access control list", nt::win32_error(error))) @@ -772,7 +783,7 @@ fn open_entry( } } -/// Refuses an item that is a reparse point, isn't of `kind`, or is on a volume that doesn't enforce ACLs. +/// Refuses an item that is a reparse point, isn't of `kind`, or is on a volume that doesn't enforce ACLs or is remote. fn classify(handle: BorrowedHandle<'_>, path: &Utf8Path, kind: Kind) -> Result<()> { let (attributes, tag) = nt::attributes_and_tag(handle).map_err(|error| Error::io(path, "read attributes", error))?; @@ -793,7 +804,8 @@ fn classify(handle: BorrowedHandle<'_>, path: &Utf8Path, kind: Kind) -> Result<( } Kind::Directory => {} } - if !nt::persistent_acls(handle).map_err(|error| Error::io(path, "read volume information", error))? { + let volume_error = |error| Error::io(path, "read volume information", error); + if !nt::persistent_acls(handle).map_err(volume_error)? || nt::is_remote(handle).map_err(volume_error)? { return Err(Error::refused(path, Refusal::Volume)); } Ok(()) @@ -875,16 +887,44 @@ fn open_first_existing(path: &Utf8Path, policy: &Policy) -> Result { Ok(Dir::new(handle, false)) } -/// Checks a directory above the first existing one, at its resolved path. +/// Checks a directory above the first existing one, at its resolved path, such as `\\?\C:\Users`. +/// +/// The directory is opened with READ_CONTROL only, so that reading its owner and DACL is all the check needs, besides +/// traversing the directories above it. fn check_above(path: &Utf8Path, policy: &Policy) -> Result<()> { let unreadable = |error| Error::refused(path, Refusal::Unreadable(error)); - let handle = open_path(path, CHECK_ACCESS).map_err(unreadable)?; - let (attributes, tag) = nt::attributes_and_tag(handle.as_handle()).map_err(unreadable)?; - // The path was resolved through every link, so a link on it now was put there since. - // A volume mounted on a folder is a name surrogate on the resolved path too, and is refused although nobody put it - // there since. - let link = attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 && nt::is_name_surrogate(tag); - if link || attributes & FILE_ATTRIBUTE_DIRECTORY.0 == 0 { + let nt_path = path + .as_str() + .strip_prefix(r"\\?\") + .map(|rest| format!(r"\??\{rest}")) + .ok_or_else(|| { + unreadable(io::Error::new( + io::ErrorKind::InvalidInput, + "the path is not a resolved path", + )) + })?; + let request = Request { + // Not FILE_READ_ATTRIBUTES or SYNCHRONIZE, which the directory may not grant, and which CreateFileW would add. + access: READ_CONTROL, + share: SHARE_ALL, + disposition: FILE_OPEN, + // A reparse point is followed, since its attributes can't be read without FILE_READ_ATTRIBUTES. + options: NTCREATEFILE_CREATE_OPTIONS(FILE_DIRECTORY_FILE.0 | FILE_OPEN_FOR_BACKUP_INTENT.0), + attributes: FILE_FLAGS_AND_ATTRIBUTES(0), + descriptor: None, + }; + let handle = match nt::create_absolute(&nt_path, &request) { + Ok(handle) => handle, + // STATUS_NOT_A_DIRECTORY: a file has replaced the directory since its path was resolved. + Err(error) if error.raw_os_error() == Some(ERROR_DIRECTORY.0.cast_signed()) => { + return Err(Error::refused(path, Refusal::Replaceable)); + } + Err(error) => return Err(unreadable(error)), + }; + // The path was resolved through every link, so a directory that now resolves elsewhere, through a link put there + // since or a folder on which a volume with a drive letter is mounted, is refused. + let resolved = nt::final_path(handle.as_handle()).map_err(unreadable)?; + if !same_name(&resolved, path.as_str()) { return Err(Error::refused(path, Refusal::Replaceable)); } let security = OwnerAndDacl::of_handle(handle.as_handle()).map_err(unreadable)?; @@ -905,6 +945,45 @@ fn open_path(path: &Utf8Path, access: FILE_ACCESS_RIGHTS) -> io::Result Self { + let temp = Utf8PathBuf::from_path_buf(std::env::temp_dir()).expect("the temporary directory path is UTF-8"); + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("the clock is after 1970") + .as_nanos(); + Self(temp.join(format!("pfs-unit-{}-{unique}", std::process::id()))) + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + // oracle: intent crates/private-fs/INTENT.md §paths + #[test] + fn repair_file_brings_a_file_into_line_with_its_rule() -> Result<()> { + // Catches a file repair that leaves an entry the rule doesn't name. + let test = TestDir::new(); + let user_full = Rule::new() + .owner(Account::CurrentUser) + .grant(Account::CurrentUser, Access::Full); + let base = crate::Dir::create_base(&test.0, &user_full)?; + let wider = user_full.clone().grant(Account::System, Access::Full); + let file = base.create_file("file", &wider, FileOptions::new())?; + let exact = user_full.exact(); + let path = test.0.join("file"); + assert!(check_file(&file, &path, &exact).is_err()); + repair_file(&file, &path, &exact)?; + check_file(&file, &path, &exact) + } /// Reparse tags, from [MS-FSCC] "Reparse Tags". /// diff --git a/crates/private-fs/src/windows/nt.rs b/crates/private-fs/src/windows/nt.rs index c9e27c93f0..543aa31871 100644 --- a/crates/private-fs/src/windows/nt.rs +++ b/crates/private-fs/src/windows/nt.rs @@ -11,8 +11,9 @@ use windows::Wdk::Foundation::OBJECT_ATTRIBUTES; use windows::Wdk::Storage::FileSystem::{ FILE_DISPOSITION_DELETE, FILE_DISPOSITION_POSIX_SEMANTICS, FILE_INFORMATION_CLASS, FILE_RENAME_INFORMATION, FILE_RENAME_POSIX_SEMANTICS, FILE_RENAME_REPLACE_IF_EXISTS, FileDispositionInformation, - FileDispositionInformationEx, FileRenameInformation, FileRenameInformationEx, NTCREATEFILE_CREATE_DISPOSITION, - NTCREATEFILE_CREATE_OPTIONS, NtCreateFile, NtSetInformationFile, + FileDispositionInformationEx, FileFsDeviceInformation, FileRenameInformation, FileRenameInformationEx, + NTCREATEFILE_CREATE_DISPOSITION, NTCREATEFILE_CREATE_OPTIONS, NtCreateFile, NtQueryVolumeInformationFile, + NtSetInformationFile, }; use windows::Win32::Foundation::{ ERROR_NO_MORE_FILES, HANDLE, NTSTATUS, OBJ_CASE_INSENSITIVE, RtlNtStatusToDosError, STATUS_INVALID_DEVICE_REQUEST, @@ -69,6 +70,15 @@ pub(super) fn win32_error(error: windows::core::Error) -> io::Error { /// Opens or creates the entry `name` of the directory `root`, without resolving any path but `name`. pub(super) fn create(root: BorrowedHandle<'_>, name: &str, request: &Request<'_>) -> io::Result { + create_in(raw(root), name, request) +} + +/// Opens or creates the object at the NT path `path`, such as `\??\C:\Users`. +pub(super) fn create_absolute(path: &str, request: &Request<'_>) -> io::Result { + create_in(HANDLE::default(), path, request) +} + +fn create_in(root: HANDLE, name: &str, request: &Request<'_>) -> io::Result { let mut wide: Vec = name.encode_utf16().collect(); let length = wide .len() @@ -82,7 +92,7 @@ pub(super) fn create(root: BorrowedHandle<'_>, name: &str, request: &Request<'_> }; let attributes = OBJECT_ATTRIBUTES { Length: u32::try_from(size_of::()).expect("OBJECT_ATTRIBUTES is a few dozen bytes"), - RootDirectory: raw(root), + RootDirectory: root, ObjectName: &raw const object_name, // Win32 file names are case-insensitive, and so are the names private-fs opens. Attributes: OBJ_CASE_INSENSITIVE, @@ -92,7 +102,7 @@ pub(super) fn create(root: BorrowedHandle<'_>, name: &str, request: &Request<'_> let mut handle = HANDLE::default(); let mut status_block = IO_STATUS_BLOCK::default(); // SAFETY: The name, its buffer, the object attributes and the security descriptor stay live for the call, the root - // handle is open, and the handle and status block outputs are writable. + // handle is open or null, and the handle and status block outputs are writable. let status = unsafe { NtCreateFile( &raw mut handle, @@ -171,6 +181,33 @@ pub(super) fn persistent_acls(handle: BorrowedHandle<'_>) -> io::Result { Ok(flags & FILE_PERSISTENT_ACLS != 0) } +/// Reports whether the volume holding `handle` is remote, such as an SMB share, whose server decides access with its own +/// accounts rather than by the DACLs this computer reads. +/// +/// UNTESTED: a file on a mapped or UNC SMB share reports `true`, and one on a local disk `false`. +/// Tests have no share to mount; when changing this, check both, because a share keeps ACLs and would otherwise pass the +/// volume check. +pub(super) fn is_remote(handle: BorrowedHandle<'_>) -> io::Result { + // FILE_FS_DEVICE_INFORMATION: DeviceType, then Characteristics. + let mut information = [0u32; 2]; + let mut status_block = IO_STATUS_BLOCK::default(); + // SAFETY: The handle is open, and the information and status block outputs are writable for their sizes. + let status = unsafe { + NtQueryVolumeInformationFile( + raw(handle), + &raw mut status_block, + information.as_mut_ptr().cast(), + 4 /* DeviceType */ + 4, /* Characteristics */ + FileFsDeviceInformation, + ) + }; + if status.0 < 0 { + return Err(status_error(status)); + } + // FILE_REMOTE_DEVICE. + Ok(information[1] & 0x10 != 0) +} + /// Returns the normalized path of `handle` with its DOS volume name, such as `\\?\C:\Users`. pub(super) fn final_path(handle: BorrowedHandle<'_>) -> io::Result { let mut buffer = vec![0u16; 512]; diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index bf5ed23c69..4d11e37689 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -45,6 +45,10 @@ const FULL: u32 = 0x001F_01FF; const READ_WRITE_DIRECTORY: u32 = 0x0013_01FF; /// `READ_CONTROL | SYNCHRONIZE | FILE_READ_DATA | FILE_READ_EA | FILE_READ_ATTRIBUTES`, `FILE_GENERIC_READ`. const READ: u32 = 0x0012_0089; +/// `READ_CONTROL`. +const READ_CONTROL: u32 = 0x0002_0000; +/// `FILE_TRAVERSE`. +const TRAVERSE: u32 = 0x20; /// `FILE_DELETE_CHILD`. const DELETE_CHILD: u32 = 0x40; /// `FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | SYNCHRONIZE`. @@ -638,6 +642,36 @@ fn a_directory_above_others_can_only_add_to_is_accepted() -> anyhow::Result<()> Ok(()) } +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn a_directory_above_that_lets_the_user_only_read_its_access_is_accepted() -> anyhow::Result<()> { + // Catches an ancestor check that asks a directory above for more than reading its owner and DACL, such as + // SYNCHRONIZE or FILE_READ_ATTRIBUTES, which a directory the user may only traverse doesn't grant. + let test = TestDir::new(); + let above = test.path().join("above"); + let existing = above.join("existing"); + std::fs::create_dir_all(&existing)?; + set_dacl( + &existing, + &[(user(), FULL, INHERITED_BY_CHILDREN)], + InheritableAclKind::Protected, + )?; + set_dacl( + &above, + &[(user(), READ_CONTROL | TRAVERSE, 0)], + InheritableAclKind::Protected, + )?; + let created = Dir::create_base(&existing.join("base"), &user_rule()); + // The owner keeps WRITE_DAC, so the test can give itself access again to clean up. + set_dacl( + &above, + &[(user(), FULL, INHERITED_BY_CHILDREN)], + InheritableAclKind::Protected, + )?; + created?; + Ok(()) +} + // oracle: intent crates/private-fs/INTENT.md §paths #[test] fn open_base_returns_none_for_a_missing_directory() -> anyhow::Result<()> { From 5cf04e8b7cbf463dbafd223389bffdb4ef4af40f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 19:52:46 +0900 Subject: [PATCH 31/60] fix(agent): hold the state lock through a drop-box submission An administrator's `identity enroll` on Windows now opens the identity directory unshared, as the service's state lock does, and holds it until its pending file is published, so a submission and a state change never overlap; the service's lock retry covers a submission in flight. Listing device keys no longer fails on a lock file that breaks its rule, which listing doesn't need, and no device-key operation fails on a leftover file it can't remove; both are skipped as unexpected entries. Opening, generating and deleting still fail on a bad lock file. Two elevated-only tests now build the states they name: a directory the agent owns, and a pending file read with the access SYSTEM has. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/agent-identity/src/private_file.rs | 101 +++++++++++++++++----- crates/device-key/src/file.rs | 82 +++++++++++++++++- 2 files changed, 156 insertions(+), 27 deletions(-) diff --git a/crates/agent-identity/src/private_file.rs b/crates/agent-identity/src/private_file.rs index 8d9db5f73e..6f23868fb1 100644 --- a/crates/agent-identity/src/private_file.rs +++ b/crates/agent-identity/src/private_file.rs @@ -538,33 +538,14 @@ fn submit_into_drop_box( rules: &DropBox, backup: bool, ) -> anyhow::Result<()> { - let started = Instant::now(); let pending_path = data_dir.join("identity").join("pending"); ensure!( path.parent() == Some(pending_path.as_path()), "pending file is outside the drop box" ); let name = path.file_name().context("pending file has no name")?; - let pending = { - // The backup privilege lets an administrator open the identity directories, which grant it no list or read - // access, to check who owns them and who can access them; it ends before anything is written. - let _backup = backup - .then(private_fs::BackupPrivilege::enable) - .transpose() - .context("enable administrator backup privilege")?; - let data = retry_sharing_violation(started, SUBMISSION_TIMEOUT, SUBMISSION_RETRY_INTERVAL, || { - Ok(Dir::open_base(data_dir, &rules.base)?) - }) - .and_then(|data| data.ok_or_else(not_found)) - .context("inspect identity data directory") - .map_err(explain_unprepared_drop_box)?; - let identity = open_drop_box_directory(&data, "identity", &rules.identity, started) - .context("inspect protected identity directory") - .map_err(explain_unprepared_drop_box)?; - open_drop_box_directory(&identity, "pending", &rules.pending, started) - .context("inspect protected pending drop box") - .map_err(explain_unprepared_drop_box)? - }; + // The state lock stays held until the file is published. + let (_state_lock, pending) = open_drop_box(data_dir, rules, backup)?; let staging = format!(".{name}-{}.tmp", Uuid::new_v4()); let mut file = pending .create_file( @@ -598,6 +579,37 @@ fn submit_into_drop_box( published.map(drop) } +/// Opens the drop box under `data_dir` once its directories pass `rules`, with the backup privilege when `backup` is +/// set, and returns the state lock on `identity` with it. +/// +/// [`crates/agent-identity/INTENT.md`] ("Local state"): every state change holds the exclusive lock on the identity +/// directory, which on Windows is an unshared handle on it. +/// +/// [`crates/agent-identity/INTENT.md`]: ../INTENT.md +#[cfg(windows)] +fn open_drop_box(data_dir: &Utf8Path, rules: &DropBox, backup: bool) -> anyhow::Result<(Dir, Dir)> { + let started = Instant::now(); + // The backup privilege lets an administrator open the identity directories, which grant it no list or read access, + // to check who owns them and who can access them; it ends before anything is written. + let _backup = backup + .then(private_fs::BackupPrivilege::enable) + .transpose() + .context("enable administrator backup privilege")?; + let data = retry_sharing_violation(started, SUBMISSION_TIMEOUT, SUBMISSION_RETRY_INTERVAL, || { + Ok(Dir::open_base(data_dir, &rules.base)?) + }) + .and_then(|data| data.ok_or_else(not_found)) + .context("inspect identity data directory") + .map_err(explain_unprepared_drop_box)?; + let identity = open_drop_box_directory(&data, "identity", &rules.identity, Share::Exclusive, started) + .context("inspect protected identity directory") + .map_err(explain_unprepared_drop_box)?; + let pending = open_drop_box_directory(&identity, "pending", &rules.pending, Share::Shared, started) + .context("inspect protected pending drop box") + .map_err(explain_unprepared_drop_box)?; + Ok((identity, pending)) +} + /// How long an administrator's submission waits for the service to release the identity directory. #[cfg(windows)] const SUBMISSION_TIMEOUT: Duration = Duration::from_secs(10); @@ -607,9 +619,15 @@ const SUBMISSION_TIMEOUT: Duration = Duration::from_secs(10); const SUBMISSION_RETRY_INTERVAL: Duration = Duration::from_millis(100); #[cfg(windows)] -fn open_drop_box_directory(parent: &Dir, name: &str, rule: &Rule, started: Instant) -> anyhow::Result { +fn open_drop_box_directory( + parent: &Dir, + name: &str, + rule: &Rule, + share: Share, + started: Instant, +) -> anyhow::Result { retry_sharing_violation(started, SUBMISSION_TIMEOUT, SUBMISSION_RETRY_INTERVAL, || { - Ok(parent.open_dir(name, rule, Share::Shared)?) + Ok(parent.open_dir(name, rule, share)?) })? .ok_or_else(not_found) } @@ -956,6 +974,9 @@ mod windows_tests { let directory = TestDir::new()?; let identity = directory.identity(); fs::create_dir(&identity)?; + // An elevated token makes BUILTIN\Administrators the owner of what it creates, so the agent's ownership is set + // explicitly. + acl::set_owner(&identity, &acl::user_sid()?)?; let users = Sid::from_well_known(WinBuiltinUsersSid, None)?; acl::set_dacl(&identity, &[(users, FILE_GENERIC_READ.0, 0)])?; @@ -1086,7 +1107,12 @@ mod windows_tests { } acl::set_dacl(&path, &entries)?; - let read = open_pending_read(&path, false); + // The zero OWNER RIGHTS entry takes away even the owner's right to read the DACL, so the test reads the file as + // SYSTEM can, through the backup privilege, which private-fs opens with. + let read = { + let _backup = private_fs::BackupPrivilege::enable()?; + open_pending_read(&path, false) + }; assert_eq!(read.is_ok(), readable, "{read:?}"); Ok(()) @@ -1265,6 +1291,33 @@ mod windows_tests { Ok(()) } + // oracle: intent crates/agent-identity/INTENT.md §Local state + #[test] + fn a_submission_holds_the_state_lock_while_the_drop_box_is_open() -> anyhow::Result<()> { + let directory = TestDir::provisioned()?; + let data = Dir::open_base(directory.path(), &tree_rule(true))?.context("missing data directory")?; + + let (state_lock, pending) = open_drop_box(directory.path(), &provisioned_by_current_user(), false)?; + let while_held = data.open_dir("identity", &tree_rule(true), Share::Shared); + drop((state_lock, pending)); + + let error = anyhow::Error::from( + while_held + .err() + .context("the identity directory opened while a submission held the drop box")?, + ); + assert!(is_sharing_violation(&error), "{error:#}"); + // Another process, such as an antivirus scan of the new directory, can hold it briefly without sharing. + let reopened = retry_sharing_violation( + Instant::now(), + Duration::from_secs(5), + Duration::from_millis(25), + || Ok(data.open_dir("identity", &tree_rule(true), Share::Shared)?), + )?; + assert!(reopened.is_some()); + Ok(()) + } + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files #[test] fn interrupted_staging_does_not_block_a_replayed_enrollment() -> anyhow::Result<()> { diff --git a/crates/device-key/src/file.rs b/crates/device-key/src/file.rs index dc659042ec..e05435fd50 100644 --- a/crates/device-key/src/file.rs +++ b/crates/device-key/src/file.rs @@ -163,7 +163,25 @@ pub(super) fn list(dir: &Utf8Path, prefix: &str) -> anyhow::Result> let Some(dir) = open_key_directory(dir)? else { return Ok(Vec::new()); }; - let _lock = lock_and_reconcile(&dir)?; + // `crates/device-key/INTENT.md` ("Operations"): listing skips an unexpected entry, a lock file that fails its rule + // included. + // Listing needs the lock only to remove leftovers first, and their names never end in `.p8`, so without the lock it + // lists the same keys and leaves the leftovers to a later operation. + let _lock = match lock_directory(&dir) { + Ok(lock) => { + cleanup_abandoned(&dir)?; + Some(lock) + } + Err(error) + if error + .chain() + .filter_map(|cause| cause.downcast_ref::()) + .any(private_fs::Error::is_refused) => + { + None + } + Err(error) => return Err(error), + }; let mut names = dir .entries() .context("list key directory")? @@ -224,12 +242,20 @@ fn cleanup_abandoned(dir: &Dir) -> anyhow::Result<()> { let Some(name) = abandoned_name(file_name) else { continue; }; + // `crates/device-key/INTENT.md` ("Operations"): a leftover that can't be removed, as while another process + // holds it open, is an unexpected entry, which an operation skips; a later operation removes it. + // UNTESTED: on Linux and macOS, a leftover that can't be removed is skipped too. + // Only root can make an entry of the caller's own key directory unremovable, for example with an immutable + // attribute; when changing this, keep a failed removal from failing the operation on every platform. let file_name = if file_name.ends_with(".tmp") { - retire(dir, file_name, name).context("retire abandoned key file")? + match retire(dir, file_name, name) { + Ok(file_name) => file_name, + Err(_) => continue, + } } else { file_name.to_owned() }; - dir.remove_file(&file_name).context("remove abandoned key file")?; + let _ = dir.remove_file(&file_name); } Ok(()) } @@ -755,6 +781,56 @@ mod tests { Ok(()) } + // oracle: intent crates/device-key/INTENT.md §Operations + #[test] + fn a_lock_file_that_fails_its_rule_stops_every_operation_but_listing() -> anyhow::Result<()> { + let directory = KeyDirectory::new()?; + let backend = directory.backend(); + let name = new_key_name(TEST_PREFIX); + generate(&backend, &name)?; + let lock = directory.0.join(LOCK_NAME); + fs::remove_file(&lock)?; + // Created outside the crate, the file is readable by other accounts: on Unix through its mode, and on Windows + // through the entries it inherits from the key directory. + fs::write(&lock, b"")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(&lock, fs::Permissions::from_mode(0o644))?; + } + + assert_eq!(list(&backend, TEST_PREFIX)?, std::slice::from_ref(&name)); + assert!(open(&backend, &name).is_err()); + assert!(generate(&backend, &new_key_name(TEST_PREFIX)).is_err()); + assert!(delete(&backend, &name).is_err()); + Ok(()) + } + + // oracle: intent crates/device-key/INTENT.md §Operations + #[cfg(windows)] + #[test] + fn a_leftover_another_process_holds_open_is_skipped_by_listing() -> anyhow::Result<()> { + use std::os::windows::fs::OpenOptionsExt as _; + + let directory = KeyDirectory::new()?; + let backend = directory.backend(); + let name = new_key_name(TEST_PREFIX); + generate(&backend, &name)?; + let leftover = create_leftover(&directory)?; + // A handle that shares nothing makes the cleanup's rename of the leftover fail. + let held = fs::OpenOptions::new().read(true).share_mode(0).open(&leftover)?; + + let listed = list(&backend, TEST_PREFIX); + drop(held); + + assert_eq!(listed?, [name]); + assert!(leftover.exists()); + assert!(list(&backend, TEST_PREFIX).is_ok()); + assert!(!leftover.exists()); + Ok(()) + } + // oracle: intent crates/device-key/INTENT.md §Operations #[test] fn lock_wait_on_an_existing_lock_file_expires() -> anyhow::Result<()> { From 54e134f02a94fa092503822e7fe4217806e73a0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 19:36:37 +0900 Subject: [PATCH 32/60] feat(private-fs): repair an open file on Linux and macOS An open file's owner, mode and extended ACLs can be brought into line with a rule, as a directory's can, and it is then checked exactly. A file of the wrong type or with another hard link is refused before anything changes, since a mode change through one name reaches every other. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/unix.rs | 146 +++++++++++++++++++++++++++++++--- 1 file changed, 135 insertions(+), 11 deletions(-) diff --git a/crates/private-fs/src/unix.rs b/crates/private-fs/src/unix.rs index 0325e25cfa..fa5e13f4de 100644 --- a/crates/private-fs/src/unix.rs +++ b/crates/private-fs/src/unix.rs @@ -351,26 +351,43 @@ pub(crate) fn check_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { } pub(crate) fn repair_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { + repair_item(&dir.file, path, rule, Kind::Directory) +} + +#[cfg_attr(not(test), expect(dead_code, reason = "private_fs::repair_file is not wired yet"))] +pub(crate) fn repair_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { + repair_item(file, path, rule, Kind::File) +} + +/// Brings the owner, mode and ACLs of the open directory or file `file` into line with `rule`, then checks it. +fn repair_item(file: &File, path: &Utf8Path, rule: &Rule, kind: Kind) -> Result<()> { + let (mode, step) = match kind { + Kind::Directory => (DIR_MODE, "inspect directory"), + Kind::File => (FILE_MODE, "inspect file"), + }; let euid = effective_uid(); - let metadata = dir - .file - .metadata() - .map_err(|error| Error::io(path, "inspect directory", error))?; + let metadata = file.metadata().map_err(|error| Error::io(path, step, error))?; + // A repair changes nothing that the check would refuse whatever the repair did, such as another hard link, whose + // mode the change would also give to the other name. + match kind { + Kind::Directory if !metadata.is_dir() => return Err(Error::refused(path, Refusal::NotADirectory)), + Kind::File if !metadata.is_file() => return Err(Error::refused(path, Refusal::NotAFile)), + Kind::File if 1 < metadata.nlink() => return Err(Error::refused(path, Refusal::Link)), + Kind::Directory | Kind::File => {} + } let owner = if owner_allowed(rule, metadata.uid(), euid) { metadata.uid() } else { euid }; - check_rule(path, rule, owner, euid, Kind::Directory)?; + check_rule(path, rule, owner, euid, kind)?; if owner != metadata.uid() { - std::os::unix::fs::fchown(&dir.file, Some(owner), None) - .map_err(|error| Error::io(path, "change owner", error))?; + std::os::unix::fs::fchown(file, Some(owner), None).map_err(|error| Error::io(path, "change owner", error))?; } - os::remove_extended_acl(&dir.file, path)?; - dir.file - .set_permissions(Permissions::from_mode(DIR_MODE)) + os::remove_extended_acl(file, path)?; + file.set_permissions(Permissions::from_mode(mode)) .map_err(|error| Error::io(path, "change mode", error))?; - check_item(&dir.file, path, rule, Kind::Directory) + check_item(file, path, rule, kind) } pub(crate) fn check_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { @@ -988,4 +1005,111 @@ mod tests { assert!(!replaceable(0o1775, &acl, &allowed)); assert!(!replaceable(0o755, &acl, &allowed)); } + + /// A file under the system temporary directory, removed with its directory when dropped. + struct ScratchFile { + dir: Utf8PathBuf, + path: Utf8PathBuf, + } + + impl ScratchFile { + fn new(test: &str, mode: u32) -> Self { + let dir = Utf8PathBuf::from_path_buf(std::env::temp_dir()) + .expect("UTF-8 temporary directory") + .join(format!("private-fs-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir(&dir).expect("create a scratch directory"); + let path = dir.join("file"); + std::fs::write(&path, b"content").expect("create a scratch file"); + std::fs::set_permissions(&path, Permissions::from_mode(mode)).expect("set a scratch mode"); + Self { dir, path } + } + + fn open(&self) -> File { + File::open(&self.path).expect("open the scratch file") + } + + fn mode(&self) -> u32 { + std::fs::metadata(&self.path).expect("inspect the scratch file").mode() & 0o7777 + } + } + + impl Drop for ScratchFile { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } + } + + fn file_rule() -> Rule { + Rule::new() + .owner(Account::CurrentUser) + .grant(Account::CurrentUser, Access::Full) + } + + // oracle: intent crates/private-fs/INTENT.md §paths + #[test] + fn repair_brings_a_file_open_to_others_into_line() { + let scratch = ScratchFile::new("repair-file", 0o644); + let file = scratch.open(); + assert!(check_file(&file, &scratch.path, &file_rule()).is_err()); + + repair_file(&file, &scratch.path, &file_rule()).expect("repair"); + assert_eq!(scratch.mode(), 0o600); + check_file(&file, &scratch.path, &file_rule()).expect("passes after the repair"); + } + + // Removing a directory's default ACL is meaningless on a file, so a repair that failed on it would never fix a file. + // oracle: intent crates/private-fs/INTENT.md §mechanisms + #[cfg(target_os = "linux")] + #[test] + fn repair_removes_a_file_acl() { + let scratch = ScratchFile::new("repair-file-acl", 0o600); + // Version 2, then the owner rw-, nobody (65534) r--, the owning group ---, a mask ---, and other ---, as the + // kernel's include/uapi/linux/posix_acl_xattr.h lays them out. + let mut acl = 2u32.to_le_bytes().to_vec(); + for (tag, perm, id) in [ + (0x01u16, 6u16, u32::MAX), + (0x02, 4, 65534), + (0x04, 0, u32::MAX), + (0x10, 0, u32::MAX), + (0x20, 0, u32::MAX), + ] { + acl.extend_from_slice(&tag.to_le_bytes()); + acl.extend_from_slice(&perm.to_le_bytes()); + acl.extend_from_slice(&id.to_le_bytes()); + } + let c_path = CString::new(scratch.path.as_str()).expect("NUL-free path"); + // SAFETY: The path and name are NUL-terminated, and the value is readable for its length. + let status = unsafe { + libc::setxattr( + c_path.as_ptr(), + c"system.posix_acl_access".as_ptr(), + acl.as_ptr().cast(), + acl.len(), + 0, + ) + }; + assert_eq!(status, 0, "set a test ACL: {}", io::Error::last_os_error()); + let file = scratch.open(); + assert!(check_file(&file, &scratch.path, &file_rule()).is_err()); + + repair_file(&file, &scratch.path, &file_rule()).expect("repair"); + check_file(&file, &scratch.path, &file_rule()).expect("passes after the repair"); + } + + // Changing the mode through one name changes it for every other hard link to the file. + // oracle: reference crates/private-fs/src/lib.rs Dir::open_file + #[test] + fn repair_leaves_a_file_with_another_hard_link_unchanged() { + let scratch = ScratchFile::new("repair-linked-file", 0o644); + std::fs::hard_link(&scratch.path, scratch.dir.join("other")).expect("link the scratch file"); + let file = scratch.open(); + + let error = repair_file(&file, &scratch.path, &file_rule()).expect_err("refused"); + assert!( + matches!(error.kind(), crate::ErrorKind::Refused(Refusal::Link)), + "{error:?}" + ); + assert_eq!(scratch.mode(), 0o644); + } } From 4ed9a6fa7b0e0c1d00be0986be598754086ef258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 19:53:51 +0900 Subject: [PATCH 33/60] feat(private-fs): repair an open file Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/lib.rs | 7 +++++++ crates/private-fs/src/unix.rs | 1 - crates/private-fs/src/windows/mod.rs | 7 +------ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/private-fs/src/lib.rs b/crates/private-fs/src/lib.rs index 9e6794dd8b..1f51d5c283 100644 --- a/crates/private-fs/src/lib.rs +++ b/crates/private-fs/src/lib.rs @@ -294,6 +294,13 @@ pub fn check_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { sys::check_file(file, path, rule) } +/// Brings an open file, at `path`, into line with `rule`, then checks it exactly. +/// +/// A file with more than one hard link is refused rather than changed, since a change through one name applies to all. +pub fn repair_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { + sys::repair_file(file, path, rule) +} + impl Error { fn already_exists(&self) -> bool { matches!(self.kind(), ErrorKind::Io { source, .. } if source.kind() == std::io::ErrorKind::AlreadyExists) diff --git a/crates/private-fs/src/unix.rs b/crates/private-fs/src/unix.rs index fa5e13f4de..986321a7a9 100644 --- a/crates/private-fs/src/unix.rs +++ b/crates/private-fs/src/unix.rs @@ -354,7 +354,6 @@ pub(crate) fn repair_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> repair_item(&dir.file, path, rule, Kind::Directory) } -#[cfg_attr(not(test), expect(dead_code, reason = "private_fs::repair_file is not wired yet"))] pub(crate) fn repair_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { repair_item(file, path, rule, Kind::File) } diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index 7e58bf71a2..338b494b4b 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -20,8 +20,7 @@ use windows::Wdk::Storage::FileSystem::{ FILE_CREATE, FILE_DIRECTORY_FILE, FILE_NON_DIRECTORY_FILE, FILE_OPEN, FILE_OPEN_FOR_BACKUP_INTENT, FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT, NTCREATEFILE_CREATE_OPTIONS, }; -use windows::Win32::Foundation::ERROR_DIRECTORY; -use windows::Win32::Foundation::{ERROR_NO_TOKEN, ERROR_NOT_ALL_ASSIGNED, HANDLE, LUID}; +use windows::Win32::Foundation::{ERROR_DIRECTORY, ERROR_NO_TOKEN, ERROR_NOT_ALL_ASSIGNED, HANDLE, LUID}; use windows::Win32::Globalization::{CSTR_EQUAL, CompareStringOrdinal}; use windows::Win32::Security::{ AdjustTokenPrivileges, DACL_SECURITY_INFORMATION, DuplicateTokenEx, GetTokenInformation, LUID_AND_ATTRIBUTES, @@ -431,10 +430,6 @@ pub(crate) fn repair_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> } /// Brings an open file's owner and DACL into line with `rule`, then checks it exactly. -#[cfg_attr( - not(test), - expect(dead_code, reason = "lib.rs exposes it as private_fs::repair_file") -)] pub(crate) fn repair_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { let policy = Policy::new(rule, Kind::File, path)?; repair(file.as_handle(), path, Kind::File, &policy)?; From 661f8488061a7416a66764322273d39752a78218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 19:59:17 +0900 Subject: [PATCH 34/60] fix(private-fs): repair a directory its owner cannot open A repair of an existing directory on Linux and macOS first opened it, so a directory whose mode shut out its own owner, such as mode 0000, could never be repaired. When that open is denied, a directory the current user owns gets mode 0700 back by name in its checked parent, then is opened, repaired and checked as usual. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/unix.rs | 31 +++++++++++++++++++++++++++++- testsuite/tests/private_fs/unix.rs | 24 +++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/private-fs/src/unix.rs b/crates/private-fs/src/unix.rs index 986321a7a9..9fcaa273ba 100644 --- a/crates/private-fs/src/unix.rs +++ b/crates/private-fs/src/unix.rs @@ -106,7 +106,16 @@ pub(crate) fn open_base(path: &Utf8Path, rule: &Rule) -> Result> { pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, existing: Existing) -> Result { check_creatable(path, rule, Kind::Directory)?; let c_name = c_name(path, name)?; - let open = || open_at(parent, path, name, libc::O_RDONLY | libc::O_DIRECTORY, Kind::Directory); + let open = || { + let open = || open_at(parent, path, name, libc::O_RDONLY | libc::O_DIRECTORY, Kind::Directory); + match open() { + Err(error) if existing == Existing::Repair && is_permission_denied(&error) => { + restore_owner_access(parent, &c_name, error)?; + open() + } + opened => opened, + } + }; // A directory that exists is checked or repaired, so only one that must be created can inherit the parent's ACL. let (file, created) = match open()? { Some(file) => (file, false), @@ -150,6 +159,26 @@ pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, Ok(dir) } +/// Gives the current user back access to the directory `name` of `parent`, which it owns but couldn't open, so that it +/// can be repaired; fails with `denied` when `name` is not such a directory. +/// +/// The parent is a checked directory, in which no other account can replace an entry between the look and the change. +fn restore_owner_access(parent: &Dir, name: &CStr, denied: Error) -> Result<()> { + match stat_at(&parent.file, name) { + Ok(stat) if entry_kind(stat.st_mode) == EntryKind::Directory && stat.st_uid == effective_uid() => {} + _ => return Err(denied), + } + // SAFETY: The parent descriptor is open and the name is NUL-terminated. + if unsafe { libc::fchmodat(parent.file.as_raw_fd(), name.as_ptr(), MKDIR_MODE, 0) } != 0 { + return Err(Error::io(denied.path(), "change mode", io::Error::last_os_error())); + } + Ok(()) +} + +fn is_permission_denied(error: &Error) -> bool { + matches!(error.kind(), crate::ErrorKind::Io { source, .. } if permission_denied(source)) +} + pub(crate) fn open_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, _share: Share) -> Result> { let Some(file) = open_at( parent, diff --git a/testsuite/tests/private_fs/unix.rs b/testsuite/tests/private_fs/unix.rs index 63656fbdb0..bd504cf2d8 100644 --- a/testsuite/tests/private_fs/unix.rs +++ b/testsuite/tests/private_fs/unix.rs @@ -234,6 +234,30 @@ fn an_existing_directory_open_to_others_is_refused_until_repaired() { assert!(base.open_dir("shared", &rule(), Share::Shared).unwrap().is_some()); } +// A repair that needed to open the directory first could never fix one whose mode shuts out its own owner. +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn a_directory_its_owner_cannot_open_is_repaired() { + let scratch = Scratch::new(); + if scratch.as_root() { + skip( + "a_directory_its_owner_cannot_open_is_repaired", + "needs an account other than root, which opens any directory", + ); + return; + } + let base = scratch.base(); + drop(base.create_dir("locked", &rule(), Existing::Check).unwrap()); + let locked = scratch.path.join("locked"); + set_mode(&locked, 0o000); + + assert!(base.create_dir("locked", &rule(), Existing::Check).is_err()); + assert_eq!(mode(&locked), 0o000, "a check changed the directory"); + + base.create_dir("locked", &rule(), Existing::Repair).unwrap(); + assert_eq!(mode(&locked), 0o700); +} + // oracle: intent crates/private-fs/INTENT.md §paths #[test] fn a_symbolic_link_from_the_first_existing_directory_down_is_refused() { From b4976bb1ae803f8bf46ba7b4082204a1743ed747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 19:59:54 +0900 Subject: [PATCH 35/60] fix(private-fs): refuse a Windows rule that can't create as an owner refusal A rule that doesn't let the current user own what it creates is refused as Refusal::Owner, as on Unix and as Rule::owner states, rather than as an invalid input. The Windows tests write their SKIP lines to standard error, so a CI log shows them, and the name comparison test cites the documentation of ordinal comparison. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/windows/mod.rs | 11 ++----- testsuite/tests/private_fs/windows.rs | 44 ++++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index 338b494b4b..d37343eee9 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -673,14 +673,7 @@ impl Drop for BackupPrivilege { fn creation_policy(rule: &Rule, kind: Kind, path: &Utf8Path) -> Result { let policy = Policy::new(rule, kind, path)?; if !policy.allows_user_owner() { - return Err(Error::io( - path, - "use rule", - io::Error::new( - io::ErrorKind::InvalidInput, - "the rule doesn't allow the current user to own what it creates", - ), - )); + return Err(Error::refused(path, Refusal::Owner)); } Ok(policy) } @@ -991,7 +984,7 @@ mod tests { const REPARSE_POINT: u32 = FILE_ATTRIBUTE_REPARSE_POINT.0; - // oracle: reference crates/private-fs/src/lib.rs Dir::discard + // oracle: spec Microsoft Learn "Handling Sorting in Your Applications" §Sort Strings Ordinally #[test] fn names_compare_ignoring_case_one_character_at_a_time() { assert!(same_name("C:\\Dir\\Staged", "c:\\dir\\STAGED")); diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index 4d11e37689..d1c5f6b268 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -310,7 +310,10 @@ fn the_backup_privilege_opens_a_file_the_token_cannot() -> anyhow::Result<()> { let backup = match BackupPrivilege::enable() { Ok(backup) => backup, Err(error) => { - println!("SKIP: the process token doesn't hold the backup privilege: {error}"); + let _ = writeln!( + std::io::stderr(), + "SKIP: the process token doesn't hold the backup privilege: {error}" + ); return Ok(()); } }; @@ -325,6 +328,30 @@ fn the_backup_privilege_opens_a_file_the_token_cannot() -> anyhow::Result<()> { Ok(()) } +// oracle: reference crates/private-fs/src/rule.rs Rule::owner +#[test] +fn a_rule_that_does_not_let_the_current_user_own_cannot_create() -> anyhow::Result<()> { + // Catches a creation that makes the current user the owner although the rule doesn't allow it, or that reports it + // otherwise than the Unix backend does. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + let rule = Rule::new() + .owner(Account::System) + .grant(Account::CurrentUser, Access::Full); + let error = refusal(base.create_dir("dir", &rule, Existing::Check)); + assert!( + is_refused(&error, |refusal| matches!(refusal, Refusal::Owner)), + "{error:?}" + ); + let error = refusal(base.create_file("file", &rule, FileOptions::new())); + assert!( + is_refused(&error, |refusal| matches!(refusal, Refusal::Owner)), + "{error:?}" + ); + assert!(base.entries()?.is_empty()); + Ok(()) +} + // oracle: intent crates/private-fs/INTENT.md §rules #[test] fn add_only_access_to_a_file_without_a_fallback_is_unsupported() -> anyhow::Result<()> { @@ -484,7 +511,10 @@ fn a_repair_takes_ownership_from_an_owner_the_rule_does_not_allow() -> anyhow::R drop(base.create_dir("child", &user_rule(), Existing::Check)?); let child = test.path().join("child"); if !set_owner(&child, &well_known(Security::WinLocalServiceSid))? { - println!("SKIP: the process token doesn't hold the restore privilege"); + let _ = writeln!( + std::io::stderr(), + "SKIP: the process token doesn't hold the restore privilege" + ); return Ok(()); } let error = refusal(base.create_dir("child", &user_rule(), Existing::Check)); @@ -503,7 +533,10 @@ fn administrators_as_an_owner_allow_a_member_of_the_group() -> anyhow::Result<() // Catches an owner check that compares with the BUILTIN\Administrators SID only. let token = Process::current_process().token(TOKEN_QUERY | TOKEN_DUPLICATE)?; if !token.is_member(&administrators())? { - println!("SKIP: the current user is not a member of Administrators"); + let _ = writeln!( + std::io::stderr(), + "SKIP: the current user is not a member of Administrators" + ); return Ok(()); } let test = TestDir::new(); @@ -940,7 +973,10 @@ fn a_thread_holds_one_backup_privilege_at_a_time() -> anyhow::Result<()> { let first = match BackupPrivilege::enable() { Ok(first) => first, Err(error) => { - println!("SKIP: the process token doesn't hold the backup privilege: {error}"); + let _ = writeln!( + std::io::stderr(), + "SKIP: the process token doesn't hold the backup privilege: {error}" + ); return Ok(()); } }; From a9c9db7f372ab1b583c4686e7b5e57dfab2a56eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 20:09:02 +0900 Subject: [PATCH 36/60] fix(agent): leave the parents of a new data directory their inherited access When the identity data directory is missing, its missing parents are now created as the platform creates any directory, so that they keep the access they inherit, and only the data directory itself gets the identity rule. Before, every missing parent got that rule too. The pending-file form tests now name the expected outcome in each test rather than taking it as a parameter, and a test pins that a key directory can't be created under a parent the caller can only traverse, since creating it needs the parent open to sync the new entry. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/agent-identity/src/private_file.rs | 170 ++++++++++++++++------ crates/device-key/src/file.rs | 54 +++++++ 2 files changed, 180 insertions(+), 44 deletions(-) diff --git a/crates/agent-identity/src/private_file.rs b/crates/agent-identity/src/private_file.rs index 6f23868fb1..0f9aa8b76a 100644 --- a/crates/agent-identity/src/private_file.rs +++ b/crates/agent-identity/src/private_file.rs @@ -87,7 +87,8 @@ fn granted(rule: Rule, grant_current_user: bool) -> Rule { } /// The rule of `identity`, `authorities`, `keys`, `rejected`, every authority directory and every `identity.json`, and -/// of `` when the agent creates it; an existing `` gets only the check of the directories above. +/// of `` when the agent creates it; an existing `` gets only the check of the directories above, and +/// the directories above it that the agent creates keep the access they inherit. /// /// [`crates/agent-identity/INTENT.md`] ("Local state"): they are owned by the agent's account, which in production is /// SYSTEM or root. @@ -172,6 +173,9 @@ fn open_parent(path: &Utf8Path) -> anyhow::Result<(Dir, &str)> { /// Provisions the identity directories, and returns ``. /// +/// A missing `` is created with the identity rule, and its missing parents as the platform creates any +/// directory, so that they keep the access they inherit: they are not the agent's identity directories. +/// /// [`crates/agent-identity/INTENT.md`] ("Local state"): an identity directory with a wrong owner or wrong permissions is /// reset, without changing anything below it. /// @@ -182,6 +186,9 @@ pub(crate) fn provision_directories(data_dir: &Utf8Path, grant_current_user: boo grant_current_user || crate::windows::is_system()?, "identity directory must be created by SYSTEM" ); + if let Some(parent) = data_dir.parent().filter(|parent| !parent.as_str().is_empty()) { + std::fs::create_dir_all(parent).context("create the parent of the identity data directory")?; + } let rule = tree_rule(grant_current_user); let data = Dir::create_base(data_dir, &rule).context("open identity data directory")?; let identity = provision(&data, "identity", &rule)?; @@ -776,6 +783,26 @@ mod unix_tests { Ok(true) } + // oracle: intent crates/agent-identity/INTENT.md §Local state + #[test] + fn provisioning_under_a_missing_parent_leaves_the_parent_its_default_mode() -> anyhow::Result<()> { + let directory = TestDir::new()?; + let parent = directory.0.join("parent"); + let data_dir = parent.join("data"); + // A directory the platform creates by default gets the mode the process umask leaves. + let default = directory.0.join("default"); + fs::create_dir(&default)?; + + prepare_directories(&data_dir, true)?; + + assert_eq!( + fs::metadata(&parent)?.permissions().mode(), + fs::metadata(&default)?.permissions().mode() + ); + assert_eq!(fs::metadata(&data_dir)?.permissions().mode() & 0o777, 0o700); + Ok(()) + } + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files #[test] fn a_pending_file_owned_by_another_account_is_ignored() -> anyhow::Result<()> { @@ -942,6 +969,26 @@ mod windows_tests { Ok(()) } + // oracle: intent crates/agent-identity/INTENT.md §Local state + #[test] + fn provisioning_under_a_missing_parent_leaves_the_parent_its_inherited_access() -> anyhow::Result<()> { + let directory = TestDir::new()?; + let parent = directory.0.join("parent"); + let data_dir = parent.join("data"); + + prepare_directories(&data_dir, true)?; + + assert_eq!(acl::protected_dacl_of(&parent)?, None); + assert_eq!( + acl::protected_dacl_of(&data_dir)?, + Some(acl::sorted(&[ + full(&acl::system_sid()?, INHERITED), + full(&acl::user_sid()?, INHERITED) + ])) + ); + Ok(()) + } + // oracle: intent crates/agent-identity/INTENT.md §Local state #[test] fn replacing_a_file_waits_for_a_reader_to_close_it() -> anyhow::Result<()> { @@ -1042,55 +1089,65 @@ mod windows_tests { Ok(()) } - /// The DACL a test gives a pending file the current user owns. - #[derive(Clone, Copy, Debug)] - enum PendingDacl { - /// SYSTEM and the agent's account, as the agent writes it under `__debug__.identity.acl_grant_current_user`. - Provisioned, - /// The same with a zero-access OWNER RIGHTS entry, which only an administrator owner's file carries. - WithZeroOwnerRights, - /// The same with full control for Users. - WithUsers, - } - - // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files - #[rstest::rstest] - #[case::provisioned(PendingDacl::Provisioned, true)] - #[case::with_zero_owner_rights(PendingDacl::WithZeroOwnerRights, false)] - #[case::with_users(PendingDacl::WithUsers, false)] - fn a_pending_file_owned_by_the_agent_is_read_only_in_the_provisioned_form( - #[case] dacl: PendingDacl, - #[case] readable: bool, - ) -> anyhow::Result<()> { - let directory = TestDir::provisioned()?; + /// Writes a pending file the current user owns, with the DACL the agent gives it under + /// `__debug__.identity.acl_grant_current_user` plus `extra`. + fn pending_file_owned_by_the_agent(directory: &TestDir, extra: Option) -> anyhow::Result { let path = directory.pending_file('a'); write_atomic(&directory.0, &path, b"blob", true)?; let user = acl::user_sid()?; - let system = acl::system_sid()?; - let mut entries = vec![full(&system, NO_INHERITANCE.0), full(&user, NO_INHERITANCE.0)]; - match dacl { - PendingDacl::Provisioned => {} - PendingDacl::WithZeroOwnerRights => entries.push((acl::owner_rights_sid()?, 0, NO_INHERITANCE.0)), - PendingDacl::WithUsers => entries.push(full(&Sid::from_well_known(WinBuiltinUsersSid, None)?, 0)), - } + let mut entries = vec![ + full(&acl::system_sid()?, NO_INHERITANCE.0), + full(&user, NO_INHERITANCE.0), + ]; + entries.extend(extra); acl::set_owner(&path, &user)?; acl::set_dacl(&path, &entries)?; + Ok(path) + } - let read = open_pending_read(&path, true); + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + #[test] + fn a_pending_file_owned_by_the_agent_in_the_provisioned_form_is_read() -> anyhow::Result<()> { + let directory = TestDir::provisioned()?; + let path = pending_file_owned_by_the_agent(&directory, None)?; - assert_eq!(read.is_ok(), readable, "{dacl:?}: {read:?}"); + open_pending_read(&path, true)?; Ok(()) } + /// An entry a test adds to the DACL of a pending file the agent owns. + #[derive(Clone, Copy, Debug)] + enum ExtraEntry { + /// A zero-access OWNER RIGHTS entry, which only an administrator owner's file carries. + ZeroOwnerRights, + /// Full control for Users. + Users, + } + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files #[rstest::rstest] - #[case::with_zero_owner_rights(true, true)] - #[case::without_owner_rights(false, false)] - fn a_pending_file_owned_by_administrators_needs_a_zero_owner_rights_entry( - #[case] owner_rights: bool, - #[case] readable: bool, - ) -> anyhow::Result<()> { + #[case::zero_owner_rights(ExtraEntry::ZeroOwnerRights)] + #[case::users(ExtraEntry::Users)] + fn a_pending_file_owned_by_the_agent_in_another_form_is_ignored(#[case] extra: ExtraEntry) -> anyhow::Result<()> { let directory = TestDir::provisioned()?; + let entry = match extra { + ExtraEntry::ZeroOwnerRights => (acl::owner_rights_sid()?, 0, NO_INHERITANCE.0), + ExtraEntry::Users => full(&Sid::from_well_known(WinBuiltinUsersSid, None)?, NO_INHERITANCE.0), + }; + let path = pending_file_owned_by_the_agent(&directory, Some(entry))?; + + let error = open_pending_read(&path, true).expect_err("a pending file in another form was read"); + + assert!(is_untrusted(&error), "{extra:?}: {error:#}"); + Ok(()) + } + + /// Writes a pending file that BUILTIN\Administrators owns and that grants SYSTEM full control, with a zero-access + /// OWNER RIGHTS entry when `owner_rights` is set, or returns `None` when the token can't give a file that owner. + fn pending_file_owned_by_administrators( + directory: &TestDir, + owner_rights: bool, + ) -> anyhow::Result> { let path = directory.pending_file('b'); write_atomic(&directory.0, &path, b"blob", true)?; let administrators = Sid::from_well_known(WinBuiltinAdministratorsSid, None)?; @@ -1099,22 +1156,47 @@ mod windows_tests { io::stderr(), "SKIP: giving a file another owner needs an elevated token" )?; - return Ok(()); + return Ok(None); } let mut entries = vec![full(&acl::system_sid()?, NO_INHERITANCE.0)]; if owner_rights { entries.push((acl::owner_rights_sid()?, 0, NO_INHERITANCE.0)); } acl::set_dacl(&path, &entries)?; + Ok(Some(path)) + } - // The zero OWNER RIGHTS entry takes away even the owner's right to read the DACL, so the test reads the file as - // SYSTEM can, through the backup privilege, which private-fs opens with. - let read = { - let _backup = private_fs::BackupPrivilege::enable()?; - open_pending_read(&path, false) + /// Opens a pending file as SYSTEM can, through the backup privilege, which private-fs opens with. + /// + /// The zero OWNER RIGHTS entry takes away even the owner's right to read the DACL, and the test is not SYSTEM. + fn open_pending_as_system(path: &Utf8Path) -> anyhow::Result { + let _backup = private_fs::BackupPrivilege::enable()?; + open_pending_read(path, false) + } + + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + #[test] + fn a_pending_file_owned_by_administrators_with_a_zero_owner_rights_entry_is_read() -> anyhow::Result<()> { + let directory = TestDir::provisioned()?; + let Some(path) = pending_file_owned_by_administrators(&directory, true)? else { + return Ok(()); }; - assert_eq!(read.is_ok(), readable, "{read:?}"); + open_pending_as_system(&path)?; + Ok(()) + } + + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + #[test] + fn a_pending_file_owned_by_administrators_without_a_zero_owner_rights_entry_is_ignored() -> anyhow::Result<()> { + let directory = TestDir::provisioned()?; + let Some(path) = pending_file_owned_by_administrators(&directory, false)? else { + return Ok(()); + }; + + let error = open_pending_as_system(&path).expect_err("a pending file without OWNER RIGHTS was read"); + + assert!(is_untrusted(&error), "{error:#}"); Ok(()) } diff --git a/crates/device-key/src/file.rs b/crates/device-key/src/file.rs index e05435fd50..5e8a50f4d7 100644 --- a/crates/device-key/src/file.rs +++ b/crates/device-key/src/file.rs @@ -955,6 +955,60 @@ mod tests { Ok(()) } + // oracle: intent crates/private-fs/INTENT.md §paths + #[cfg(unix)] + #[test] + fn a_missing_key_directory_under_an_execute_only_parent_is_refused() -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt as _; + + // `crates/device-key/INTENT.md` ("File backend") asks only two rights on the ancestors for keeping and using keys + // once the key directory exists; creating it needs its parent open, to sync the new entry so that it survives a + // power cut. + struct RestorePermissions(Utf8PathBuf); + + impl Drop for RestorePermissions { + fn drop(&mut self) { + let _ = fs::set_permissions(&self.0, fs::Permissions::from_mode(0o700)); + } + } + + // SAFETY: Reading the process's effective UID has no preconditions. + if unsafe { libc::geteuid() } == 0 { + writeln!( + std::io::stderr(), + "SKIP: a_missing_key_directory_under_an_execute_only_parent_is_refused needs an account other than root" + )?; + return Ok(()); + } + let root = KeyDirectory::new()?; + let parent = root.0.join("execute-only"); + fs::create_dir(&parent)?; + fs::set_permissions(&parent, fs::Permissions::from_mode(0o100))?; + let restore = RestorePermissions(parent.clone()); + let backend = KeyBackend::File { + dir: parent.join("keys"), + }; + + let error = generate(&backend, &new_key_name(TEST_PREFIX)) + .err() + .context("a key directory was created under an execute-only parent")?; + drop(restore); + + let refusal = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .context("the failure is not from private-fs")?; + assert!( + matches!( + refusal.kind(), + private_fs::ErrorKind::Refused(private_fs::Refusal::Unreadable(_)) + ), + "{error:#}" + ); + assert!(!parent.join("keys").exists()); + Ok(()) + } + // oracle: intent crates/device-key/INTENT.md §Operations #[cfg(windows)] #[test] From 1310ce4e6e4c817e90fb56c290cb16b5614895e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 20:16:31 +0900 Subject: [PATCH 37/60] fix(private-fs): check the volume before a Unix repair A repair on Linux and macOS now refuses a volume that ignores or fakes ownership before it changes the owner, mode or ACLs, as the Windows repair does, rather than only in the check that follows. The tests of a file repair use the public API from the testsuite. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/unix.rs | 108 +---------------------------- testsuite/tests/private_fs/unix.rs | 53 ++++++++++++++ 2 files changed, 54 insertions(+), 107 deletions(-) diff --git a/crates/private-fs/src/unix.rs b/crates/private-fs/src/unix.rs index 9fcaa273ba..27249fe367 100644 --- a/crates/private-fs/src/unix.rs +++ b/crates/private-fs/src/unix.rs @@ -403,6 +403,7 @@ fn repair_item(file: &File, path: &Utf8Path, rule: &Rule, kind: Kind) -> Result< Kind::File if 1 < metadata.nlink() => return Err(Error::refused(path, Refusal::Link)), Kind::Directory | Kind::File => {} } + os::check_volume(file, path)?; let owner = if owner_allowed(rule, metadata.uid(), euid) { metadata.uid() } else { @@ -1033,111 +1034,4 @@ mod tests { assert!(!replaceable(0o1775, &acl, &allowed)); assert!(!replaceable(0o755, &acl, &allowed)); } - - /// A file under the system temporary directory, removed with its directory when dropped. - struct ScratchFile { - dir: Utf8PathBuf, - path: Utf8PathBuf, - } - - impl ScratchFile { - fn new(test: &str, mode: u32) -> Self { - let dir = Utf8PathBuf::from_path_buf(std::env::temp_dir()) - .expect("UTF-8 temporary directory") - .join(format!("private-fs-{test}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir(&dir).expect("create a scratch directory"); - let path = dir.join("file"); - std::fs::write(&path, b"content").expect("create a scratch file"); - std::fs::set_permissions(&path, Permissions::from_mode(mode)).expect("set a scratch mode"); - Self { dir, path } - } - - fn open(&self) -> File { - File::open(&self.path).expect("open the scratch file") - } - - fn mode(&self) -> u32 { - std::fs::metadata(&self.path).expect("inspect the scratch file").mode() & 0o7777 - } - } - - impl Drop for ScratchFile { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.dir); - } - } - - fn file_rule() -> Rule { - Rule::new() - .owner(Account::CurrentUser) - .grant(Account::CurrentUser, Access::Full) - } - - // oracle: intent crates/private-fs/INTENT.md §paths - #[test] - fn repair_brings_a_file_open_to_others_into_line() { - let scratch = ScratchFile::new("repair-file", 0o644); - let file = scratch.open(); - assert!(check_file(&file, &scratch.path, &file_rule()).is_err()); - - repair_file(&file, &scratch.path, &file_rule()).expect("repair"); - assert_eq!(scratch.mode(), 0o600); - check_file(&file, &scratch.path, &file_rule()).expect("passes after the repair"); - } - - // Removing a directory's default ACL is meaningless on a file, so a repair that failed on it would never fix a file. - // oracle: intent crates/private-fs/INTENT.md §mechanisms - #[cfg(target_os = "linux")] - #[test] - fn repair_removes_a_file_acl() { - let scratch = ScratchFile::new("repair-file-acl", 0o600); - // Version 2, then the owner rw-, nobody (65534) r--, the owning group ---, a mask ---, and other ---, as the - // kernel's include/uapi/linux/posix_acl_xattr.h lays them out. - let mut acl = 2u32.to_le_bytes().to_vec(); - for (tag, perm, id) in [ - (0x01u16, 6u16, u32::MAX), - (0x02, 4, 65534), - (0x04, 0, u32::MAX), - (0x10, 0, u32::MAX), - (0x20, 0, u32::MAX), - ] { - acl.extend_from_slice(&tag.to_le_bytes()); - acl.extend_from_slice(&perm.to_le_bytes()); - acl.extend_from_slice(&id.to_le_bytes()); - } - let c_path = CString::new(scratch.path.as_str()).expect("NUL-free path"); - // SAFETY: The path and name are NUL-terminated, and the value is readable for its length. - let status = unsafe { - libc::setxattr( - c_path.as_ptr(), - c"system.posix_acl_access".as_ptr(), - acl.as_ptr().cast(), - acl.len(), - 0, - ) - }; - assert_eq!(status, 0, "set a test ACL: {}", io::Error::last_os_error()); - let file = scratch.open(); - assert!(check_file(&file, &scratch.path, &file_rule()).is_err()); - - repair_file(&file, &scratch.path, &file_rule()).expect("repair"); - check_file(&file, &scratch.path, &file_rule()).expect("passes after the repair"); - } - - // Changing the mode through one name changes it for every other hard link to the file. - // oracle: reference crates/private-fs/src/lib.rs Dir::open_file - #[test] - fn repair_leaves_a_file_with_another_hard_link_unchanged() { - let scratch = ScratchFile::new("repair-linked-file", 0o644); - std::fs::hard_link(&scratch.path, scratch.dir.join("other")).expect("link the scratch file"); - let file = scratch.open(); - - let error = repair_file(&file, &scratch.path, &file_rule()).expect_err("refused"); - assert!( - matches!(error.kind(), crate::ErrorKind::Refused(Refusal::Link)), - "{error:?}" - ); - assert_eq!(scratch.mode(), 0o644); - } } diff --git a/testsuite/tests/private_fs/unix.rs b/testsuite/tests/private_fs/unix.rs index bd504cf2d8..2f3eee2cf2 100644 --- a/testsuite/tests/private_fs/unix.rs +++ b/testsuite/tests/private_fs/unix.rs @@ -748,6 +748,59 @@ fn a_rename_that_refuses_replacement_keeps_the_existing_entry() { assert!(!scratch.path.join("new").exists()); } +/// Writes the file `name` in `scratch` with `mode`, outside private-fs, and opens it. +fn plain_file(scratch: &Scratch, name: &str, mode: u32) -> (Utf8PathBuf, fs::File) { + let path = scratch.path.join(name); + fs::write(&path, b"content").unwrap(); + set_mode(&path, mode); + let file = fs::File::open(&path).unwrap(); + (path, file) +} + +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn repair_brings_a_file_open_to_others_into_line() { + let scratch = Scratch::new(); + let (path, file) = plain_file(&scratch, "key", 0o644); + let error = private_fs::check_file(&file, &path, &rule()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); + + private_fs::repair_file(&file, &path, &rule()).unwrap(); + assert_eq!(mode(&path), 0o600); + private_fs::check_file(&file, &path, &rule()).unwrap(); +} + +// A repair that failed on a file, for example on removing a default ACL that only a directory can have, would never +// fix one. +// oracle: intent crates/private-fs/INTENT.md §mechanisms +#[test] +fn repair_removes_a_file_acl() { + let scratch = Scratch::new(); + let (path, file) = plain_file(&scratch, "key", 0o600); + if !acl::name_another_account(&path) { + skip("repair_removes_a_file_acl", "needs ACL support"); + return; + } + let error = private_fs::check_file(&file, &path, &rule()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); + + private_fs::repair_file(&file, &path, &rule()).unwrap(); + private_fs::check_file(&file, &path, &rule()).unwrap(); +} + +// Changing the mode through one name changes it for every other hard link to the file. +// oracle: intent crates/private-fs/INTENT.md §mechanisms +#[test] +fn repair_leaves_a_file_with_another_hard_link_unchanged() { + let scratch = Scratch::new(); + let (path, file) = plain_file(&scratch, "key", 0o644); + fs::hard_link(&path, scratch.path.join("other")).unwrap(); + + let error = private_fs::repair_file(&file, &path, &rule()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Link)), "{error:?}"); + assert_eq!(mode(&path), 0o644); +} + // oracle: reference crates/private-fs/src/lib.rs Dir::remove_file #[test] fn remove_file_reports_whether_the_file_existed() { From 2c29fa88225d480e81d7c0758120201ca0c1828a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 20:17:08 +0900 Subject: [PATCH 38/60] fix(private-fs): refuse a Windows mount folder above a base directory A folder above the first existing directory on which a volume without a drive letter is mounted resolves to its own path but opens the mounted volume's root, whose DACL is not the folder's, so it is refused as replaceable. The test of a directory above that only lets the user read its access also takes FILE_LIST_DIRECTORY away from its parent, so it fails if the check asks for FILE_READ_ATTRIBUTES, and repair_file is tested through the public API. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/windows/mod.rs | 51 +++++++++------------------ crates/private-fs/src/windows/nt.rs | 15 ++++++-- testsuite/tests/private_fs/windows.rs | 46 ++++++++++++++++++------ 3 files changed, 65 insertions(+), 47 deletions(-) diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index d37343eee9..d7a7260c57 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -915,10 +915,22 @@ fn check_above(path: &Utf8Path, policy: &Policy) -> Result<()> { if !same_name(&resolved, path.as_str()) { return Err(Error::refused(path, Refusal::Replaceable)); } + // A folder on which a volume without a drive letter is mounted resolves to its own path, but opens the mounted + // volume's root, so the folder's own DACL can't be read through it: it is refused too. + let in_volume = nt::path_in_volume(handle.as_handle()).map_err(unreadable)?; + if is_mount_folder(&in_volume, path) { + return Err(Error::refused(path, Refusal::Replaceable)); + } let security = OwnerAndDacl::of_handle(handle.as_handle()).map_err(unreadable)?; policy.check_ancestor(&security, path, path.parent().is_none(), &administrators_member) } +/// Reports whether a directory whose path within its volume is `path_in_volume` is a volume's root reached through a +/// folder at `path`, rather than the root of a path. +fn is_mount_folder(path_in_volume: &str, path: &Utf8Path) -> bool { + path_in_volume == "\\" && path.parent().is_some() +} + /// Opens a directory by path without following a reparse point. fn open_path(path: &Utf8Path, access: FILE_ACCESS_RIGHTS) -> io::Result { let file = OpenOptions::new() @@ -933,44 +945,13 @@ fn open_path(path: &Utf8Path, access: FILE_ACCESS_RIGHTS) -> io::Result Self { - let temp = Utf8PathBuf::from_path_buf(std::env::temp_dir()).expect("the temporary directory path is UTF-8"); - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("the clock is after 1970") - .as_nanos(); - Self(temp.join(format!("pfs-unit-{}-{unique}", std::process::id()))) - } - } - - impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } // oracle: intent crates/private-fs/INTENT.md §paths #[test] - fn repair_file_brings_a_file_into_line_with_its_rule() -> Result<()> { - // Catches a file repair that leaves an entry the rule doesn't name. - let test = TestDir::new(); - let user_full = Rule::new() - .owner(Account::CurrentUser) - .grant(Account::CurrentUser, Access::Full); - let base = crate::Dir::create_base(&test.0, &user_full)?; - let wider = user_full.clone().grant(Account::System, Access::Full); - let file = base.create_file("file", &wider, FileOptions::new())?; - let exact = user_full.exact(); - let path = test.0.join("file"); - assert!(check_file(&file, &path, &exact).is_err()); - repair_file(&file, &path, &exact)?; - check_file(&file, &path, &exact) + fn only_a_volume_root_below_a_path_root_is_a_mount_folder() { + assert!(is_mount_folder("\\", Utf8Path::new(r"\\?\C:\mnt\volume"))); + assert!(!is_mount_folder("\\", Utf8Path::new(r"\\?\C:\"))); + assert!(!is_mount_folder(r"\mnt\volume", Utf8Path::new(r"\\?\C:\mnt\volume"))); } /// Reparse tags, from [MS-FSCC] "Reparse Tags". diff --git a/crates/private-fs/src/windows/nt.rs b/crates/private-fs/src/windows/nt.rs index 543aa31871..627d11c88a 100644 --- a/crates/private-fs/src/windows/nt.rs +++ b/crates/private-fs/src/windows/nt.rs @@ -25,7 +25,7 @@ use windows::Win32::Storage::FileSystem::{ FILE_ID_BOTH_DIR_INFO, FILE_ID_INFO, FILE_INFO_BY_HANDLE_CLASS, FILE_NAME_NORMALIZED, FILE_SHARE_MODE, FILE_STANDARD_INFO, FileAttributeTagInfo, FileIdBothDirectoryInfo, FileIdBothDirectoryRestartInfo, FileIdInfo, FileStandardInfo, FlushFileBuffers, GETFINALPATHNAMEBYHANDLE_FLAGS, GetFileInformationByHandleEx, - GetFinalPathNameByHandleW, GetVolumeInformationByHandleW, VOLUME_NAME_DOS, + GetFinalPathNameByHandleW, GetVolumeInformationByHandleW, VOLUME_NAME_DOS, VOLUME_NAME_NONE, }; use windows::Win32::System::IO::IO_STATUS_BLOCK; use windows::Win32::System::SystemServices::FILE_PERSISTENT_ACLS; @@ -189,6 +189,8 @@ pub(super) fn persistent_acls(handle: BorrowedHandle<'_>) -> io::Result { /// volume check. pub(super) fn is_remote(handle: BorrowedHandle<'_>) -> io::Result { // FILE_FS_DEVICE_INFORMATION: DeviceType, then Characteristics. + // The windows crate's FILE_FS_DEVICE_INFORMATION and FILE_REMOTE_DEVICE need its Wdk_System_SystemServices feature, + // which nothing else here needs, so the two fields are read as they are laid out. let mut information = [0u32; 2]; let mut status_block = IO_STATUS_BLOCK::default(); // SAFETY: The handle is open, and the information and status block outputs are writable for their sizes. @@ -210,6 +212,15 @@ pub(super) fn is_remote(handle: BorrowedHandle<'_>) -> io::Result { /// Returns the normalized path of `handle` with its DOS volume name, such as `\\?\C:\Users`. pub(super) fn final_path(handle: BorrowedHandle<'_>) -> io::Result { + final_path_with(handle, VOLUME_NAME_DOS) +} + +/// Returns the normalized path of `handle` within its volume, such as `\Users`, or `\` for the volume's root. +pub(super) fn path_in_volume(handle: BorrowedHandle<'_>) -> io::Result { + final_path_with(handle, VOLUME_NAME_NONE) +} + +fn final_path_with(handle: BorrowedHandle<'_>, volume: GETFINALPATHNAMEBYHANDLE_FLAGS) -> io::Result { let mut buffer = vec![0u16; 512]; loop { // SAFETY: The handle is open and the buffer is writable for its length. @@ -217,7 +228,7 @@ pub(super) fn final_path(handle: BorrowedHandle<'_>) -> io::Result { GetFinalPathNameByHandleW( raw(handle), &mut buffer, - GETFINALPATHNAMEBYHANDLE_FLAGS(FILE_NAME_NORMALIZED.0 | VOLUME_NAME_DOS.0), + GETFINALPATHNAMEBYHANDLE_FLAGS(FILE_NAME_NORMALIZED.0 | volume.0), ) }; if length == 0 { diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index d1c5f6b268..0304212427 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -689,22 +689,48 @@ fn a_directory_above_that_lets_the_user_only_read_its_access_is_accepted() -> an &[(user(), FULL, INHERITED_BY_CHILDREN)], InheritableAclKind::Protected, )?; - set_dacl( - &above, - &[(user(), READ_CONTROL | TRAVERSE, 0)], - InheritableAclKind::Protected, - )?; + // The directory above `above` grants no FILE_LIST_DIRECTORY either, through which Windows would grant + // FILE_READ_ATTRIBUTES on `above` whatever its own DACL says. + for directory in [&above, test.path()] { + set_dacl( + directory, + &[(user(), READ_CONTROL | TRAVERSE, 0)], + InheritableAclKind::Protected, + )?; + } let created = Dir::create_base(&existing.join("base"), &user_rule()); // The owner keeps WRITE_DAC, so the test can give itself access again to clean up. - set_dacl( - &above, - &[(user(), FULL, INHERITED_BY_CHILDREN)], - InheritableAclKind::Protected, - )?; + for directory in [test.path(), &above] { + set_dacl( + directory, + &[(user(), FULL, INHERITED_BY_CHILDREN)], + InheritableAclKind::Protected, + )?; + } created?; Ok(()) } +// oracle: intent crates/private-fs/INTENT.md §paths +#[test] +fn repair_file_brings_a_file_into_line_with_its_rule() -> anyhow::Result<()> { + // Catches a file repair that leaves an entry the rule doesn't name. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + let wider = user_rule().grant(Account::System, Access::Full); + let file = base.create_file("file", &wider, FileOptions::new())?; + let path = test.path().join("file"); + let exact = user_rule().exact(); + let error = refusal(private_fs::check_file(&file, &path, &exact)); + assert!( + is_refused(&error, |refusal| matches!(refusal, Refusal::Access)), + "{error:?}" + ); + private_fs::repair_file(&file, &path, &exact)?; + assert_eq!(security_of(&file)?, (user(), vec![(user(), FULL, 0)])); + Ok(()) +} + // oracle: intent crates/private-fs/INTENT.md §paths #[test] fn open_base_returns_none_for_a_missing_directory() -> anyhow::Result<()> { From 503d1f4c854cc8fbb5a36a24460e20c505971913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 20:17:29 +0900 Subject: [PATCH 39/60] fix(agent): fail a device-key operation on a leftover it can't remove A leftover temporary or tombstone follows the crate's naming scheme, so it counts as the crate's own key material, and no leftover copy of a private key may survive a later operation. An operation that can't remove one, as while another process holds it open, again fails at once, listing included. Listing still skips a lock file that breaks its rule, which it doesn't need. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/device-key/src/file.rs | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/crates/device-key/src/file.rs b/crates/device-key/src/file.rs index 5e8a50f4d7..b632e3a3a6 100644 --- a/crates/device-key/src/file.rs +++ b/crates/device-key/src/file.rs @@ -242,20 +242,15 @@ fn cleanup_abandoned(dir: &Dir) -> anyhow::Result<()> { let Some(name) = abandoned_name(file_name) else { continue; }; - // `crates/device-key/INTENT.md` ("Operations"): a leftover that can't be removed, as while another process - // holds it open, is an unexpected entry, which an operation skips; a later operation removes it. - // UNTESTED: on Linux and macOS, a leftover that can't be removed is skipped too. - // Only root can make an entry of the caller's own key directory unremovable, for example with an immutable - // attribute; when changing this, keep a failed removal from failing the operation on every platform. + // `crates/device-key/INTENT.md` ("Operations"): a leftover's name follows the crate's scheme, so it counts as the + // crate's own, and no leftover copy of a private key survives a later operation: one that can't be removed, as + // while another process holds it open, fails the operation, listing included. let file_name = if file_name.ends_with(".tmp") { - match retire(dir, file_name, name) { - Ok(file_name) => file_name, - Err(_) => continue, - } + retire(dir, file_name, name).context("retire abandoned key file")? } else { file_name.to_owned() }; - let _ = dir.remove_file(&file_name); + dir.remove_file(&file_name).context("remove abandoned key file")?; } Ok(()) } @@ -810,7 +805,7 @@ mod tests { // oracle: intent crates/device-key/INTENT.md §Operations #[cfg(windows)] #[test] - fn a_leftover_another_process_holds_open_is_skipped_by_listing() -> anyhow::Result<()> { + fn a_leftover_another_process_holds_open_fails_listing_until_it_is_released() -> anyhow::Result<()> { use std::os::windows::fs::OpenOptionsExt as _; let directory = KeyDirectory::new()?; @@ -818,15 +813,19 @@ mod tests { let name = new_key_name(TEST_PREFIX); generate(&backend, &name)?; let leftover = create_leftover(&directory)?; - // A handle that shares nothing makes the cleanup's rename of the leftover fail. + // A handle that shares nothing makes the cleanup's rename of the leftover fail at once rather than wait. let held = fs::OpenOptions::new().read(true).share_mode(0).open(&leftover)?; + let started = Instant::now(); let listed = list(&backend, TEST_PREFIX); + let elapsed = started.elapsed(); drop(held); - assert_eq!(listed?, [name]); + assert!(listed.is_err(), "{listed:?}"); + // Failing promptly means not waiting out a lock's time-out either. + assert!(elapsed < LOCK_TIMEOUT, "{elapsed:?}"); assert!(leftover.exists()); - assert!(list(&backend, TEST_PREFIX).is_ok()); + assert_eq!(list(&backend, TEST_PREFIX)?, [name]); assert!(!leftover.exists()); Ok(()) } From ea11048b4155aab6daebe7a3db042ba6c5073152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 20:24:12 +0900 Subject: [PATCH 40/60] test(private-fs): report skipped Windows tests through one helper Every skipped Windows test reports its name and reason on standard error through one helper, as the Unix tests do, and the test of a rule that doesn't let the current user own skips when the process runs as SYSTEM, which such a rule lets own. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- testsuite/tests/private_fs/windows.rs | 39 ++++++++++++++++++--------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index 0304212427..e772093c0a 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -81,6 +81,12 @@ impl Drop for TestDir { } } +/// Reports a skipped test on standard error, where a CI log shows it. +fn skip(test: &str, reason: &str) { + // The test harness captures `eprintln!` output from a passing test, but not direct writes to stderr. + let _ = writeln!(std::io::stderr(), "SKIP: {test} {reason}"); +} + fn user() -> Sid { Token::current_process_token() .sid_and_attributes() @@ -310,9 +316,9 @@ fn the_backup_privilege_opens_a_file_the_token_cannot() -> anyhow::Result<()> { let backup = match BackupPrivilege::enable() { Ok(backup) => backup, Err(error) => { - let _ = writeln!( - std::io::stderr(), - "SKIP: the process token doesn't hold the backup privilege: {error}" + skip( + "the_backup_privilege_opens_a_file_the_token_cannot", + &format!("the process token doesn't hold the backup privilege: {error}"), ); return Ok(()); } @@ -332,7 +338,14 @@ fn the_backup_privilege_opens_a_file_the_token_cannot() -> anyhow::Result<()> { #[test] fn a_rule_that_does_not_let_the_current_user_own_cannot_create() -> anyhow::Result<()> { // Catches a creation that makes the current user the owner although the rule doesn't allow it, or that reports it - // otherwise than the Unix backend does. + // as anything but an owner refusal. + if user() == system() { + skip( + "a_rule_that_does_not_let_the_current_user_own_cannot_create", + "the process runs as SYSTEM, which the rule lets own", + ); + return Ok(()); + } let test = TestDir::new(); let base = Dir::create_base(test.path(), &user_rule())?; let rule = Rule::new() @@ -511,9 +524,9 @@ fn a_repair_takes_ownership_from_an_owner_the_rule_does_not_allow() -> anyhow::R drop(base.create_dir("child", &user_rule(), Existing::Check)?); let child = test.path().join("child"); if !set_owner(&child, &well_known(Security::WinLocalServiceSid))? { - let _ = writeln!( - std::io::stderr(), - "SKIP: the process token doesn't hold the restore privilege" + skip( + "a_repair_takes_ownership_from_an_owner_the_rule_does_not_allow", + "the process token doesn't hold the restore privilege", ); return Ok(()); } @@ -533,9 +546,9 @@ fn administrators_as_an_owner_allow_a_member_of_the_group() -> anyhow::Result<() // Catches an owner check that compares with the BUILTIN\Administrators SID only. let token = Process::current_process().token(TOKEN_QUERY | TOKEN_DUPLICATE)?; if !token.is_member(&administrators())? { - let _ = writeln!( - std::io::stderr(), - "SKIP: the current user is not a member of Administrators" + skip( + "administrators_as_an_owner_allow_a_member_of_the_group", + "the current user is not a member of Administrators", ); return Ok(()); } @@ -999,9 +1012,9 @@ fn a_thread_holds_one_backup_privilege_at_a_time() -> anyhow::Result<()> { let first = match BackupPrivilege::enable() { Ok(first) => first, Err(error) => { - let _ = writeln!( - std::io::stderr(), - "SKIP: the process token doesn't hold the backup privilege: {error}" + skip( + "a_thread_holds_one_backup_privilege_at_a_time", + &format!("the process token doesn't hold the backup privilege: {error}"), ); return Ok(()); } From 2f4d5273a0ddbb7625eb469e27ac6085e4e8bdd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 20:25:06 +0900 Subject: [PATCH 41/60] fix(private-fs): restore a new directory's owner access on Unix A directory created under a umask that removes the owner's read access gets mode 0700 back before it is opened and checked, through the same path that lets a repair open a directory its owner was shut out of. That path now refuses an owner the rule doesn't allow, and a rule it can't enforce, before changing the mode. A rule whose owners include any account that is the effective user can create, so a rule naming System creates when running as root, as on Windows. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/unix.rs | 39 +++++++++++++++++++++--------- testsuite/tests/private_fs/unix.rs | 9 ++++++- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/crates/private-fs/src/unix.rs b/crates/private-fs/src/unix.rs index 27249fe367..48e0d57b02 100644 --- a/crates/private-fs/src/unix.rs +++ b/crates/private-fs/src/unix.rs @@ -106,18 +106,20 @@ pub(crate) fn open_base(path: &Utf8Path, rule: &Rule) -> Result> { pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, existing: Existing) -> Result { check_creatable(path, rule, Kind::Directory)?; let c_name = c_name(path, name)?; - let open = || { + // A directory the current user owns but can't open is given back its owner's access when it is to be repaired, or + // when it was just created under a umask that removed the owner's read access. + let open = |restore: bool| { let open = || open_at(parent, path, name, libc::O_RDONLY | libc::O_DIRECTORY, Kind::Directory); match open() { - Err(error) if existing == Existing::Repair && is_permission_denied(&error) => { - restore_owner_access(parent, &c_name, error)?; + Err(error) if restore && is_permission_denied(&error) => { + restore_owner_access(parent, &c_name, rule, error)?; open() } opened => opened, } }; // A directory that exists is checked or repaired, so only one that must be created can inherit the parent's ACL. - let (file, created) = match open()? { + let (file, created) = match open(existing == Existing::Repair)? { Some(file) => (file, false), None => { os::refuse_inheritable_acl(&parent.file, parent_path(path))?; @@ -129,7 +131,8 @@ pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, return Err(Error::io(path, "create directory", error)); } } - let file = open()?.ok_or_else(|| Error::io(path, "open directory", io::ErrorKind::NotFound.into()))?; + let file = open(created || existing == Existing::Repair)? + .ok_or_else(|| Error::io(path, "open directory", io::ErrorKind::NotFound.into()))?; (file, created) } }; @@ -160,14 +163,27 @@ pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, } /// Gives the current user back access to the directory `name` of `parent`, which it owns but couldn't open, so that it -/// can be repaired; fails with `denied` when `name` is not such a directory. +/// can be repaired or checked; fails with `denied` when `name` is not such a directory. /// -/// The parent is a checked directory, in which no other account can replace an entry between the look and the change. -fn restore_owner_access(parent: &Dir, name: &CStr, denied: Error) -> Result<()> { +/// The change happens before the volume check that the repair or check makes once the directory is open. +/// It is harmless there: mode 0700 gives no account but the owner any access, and the owner could change the mode +/// itself. +fn restore_owner_access(parent: &Dir, name: &CStr, rule: &Rule, denied: Error) -> Result<()> { + let euid = effective_uid(); match stat_at(&parent.file, name) { - Ok(stat) if entry_kind(stat.st_mode) == EntryKind::Directory && stat.st_uid == effective_uid() => {} + Ok(stat) + if entry_kind(stat.st_mode) == EntryKind::Directory + && stat.st_uid == euid + && owner_allowed(rule, euid, euid) => {} _ => return Err(denied), } + check_rule(denied.path(), rule, euid, euid, Kind::Directory)?; + // UNTESTED: no other account can replace the entry between the `fstatat` above and the `fchmodat` below. + // The parent is a directory checked against its rule, or a base directory that no other account can replace entries + // in, and the entry is a directory the effective user owns, which the sticky bit protects from other accounts; a + // test can't run the race. + // When changing this, keep the type and owner check before the mode change, that invariant of the parent, and the + // open with `O_NOFOLLOW` that follows, then the repair or check. // SAFETY: The parent descriptor is open and the name is NUL-terminated. if unsafe { libc::fchmodat(parent.file.as_raw_fd(), name.as_ptr(), MKDIR_MODE, 0) } != 0 { return Err(Error::io(denied.path(), "change mode", io::Error::last_os_error())); @@ -636,10 +652,11 @@ fn check_item(file: &File, path: &Utf8Path, rule: &Rule, kind: Kind) -> Result<( /// Fails when `rule` can't create an item: private-fs creates items owned by the current user. fn check_creatable(path: &Utf8Path, rule: &Rule, kind: Kind) -> Result<()> { - if !rule.owners.contains(&Account::CurrentUser) { + let euid = effective_uid(); + // Any account of the rule that is the effective user counts, as System does when running as root. + if !owner_allowed(rule, euid, euid) { return Err(Error::refused(path, Refusal::Owner)); } - let euid = effective_uid(); check_rule(path, rule, euid, euid, kind) } diff --git a/testsuite/tests/private_fs/unix.rs b/testsuite/tests/private_fs/unix.rs index 2f3eee2cf2..b9e9417005 100644 --- a/testsuite/tests/private_fs/unix.rs +++ b/testsuite/tests/private_fs/unix.rs @@ -234,7 +234,7 @@ fn an_existing_directory_open_to_others_is_refused_until_repaired() { assert!(base.open_dir("shared", &rule(), Share::Shared).unwrap().is_some()); } -// A repair that needed to open the directory first could never fix one whose mode shuts out its own owner. +// Catches a repair that opens the directory first, which could never fix one whose mode shuts out its own owner. // oracle: intent crates/private-fs/INTENT.md §paths #[test] fn a_directory_its_owner_cannot_open_is_repaired() { @@ -690,6 +690,13 @@ fn add_only_access_to_a_file_fails_without_a_fallback() { #[test] fn a_rule_that_does_not_let_the_current_user_own_cannot_create() { let scratch = Scratch::new(); + if scratch.as_root() { + skip( + "a_rule_that_does_not_let_the_current_user_own_cannot_create", + "needs an account other than root, which System names", + ); + return; + } let system_only = Rule::new().owner(Account::System).grant(Account::System, Access::Full); let error = scratch .base() From 0d6e7156b5828634efc0f3a445d14a2aa82c5878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 20:26:20 +0900 Subject: [PATCH 42/60] fix(agent): create a missing data directory's parents with the identity rule Provisioning again creates every missing directory of the data directory's path by handle with the identity rule, refusing links, as before the parents were left to the platform's defaults: a parent created that way could be reached through a planted link, or fail the check of the directories above with a umask or volume-root default. The installer and the configuration create the data directory in production, so this applies only where they didn't. Device-key's execute-only parent test now asserts only that no key directory is created, and the two execute-only tests share one guard. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/agent-identity/src/private_file.rs | 30 +++++----------- crates/device-key/src/file.rs | 44 +++++++++-------------- 2 files changed, 26 insertions(+), 48 deletions(-) diff --git a/crates/agent-identity/src/private_file.rs b/crates/agent-identity/src/private_file.rs index 0f9aa8b76a..a3b0f41ba8 100644 --- a/crates/agent-identity/src/private_file.rs +++ b/crates/agent-identity/src/private_file.rs @@ -87,8 +87,8 @@ fn granted(rule: Rule, grant_current_user: bool) -> Rule { } /// The rule of `identity`, `authorities`, `keys`, `rejected`, every authority directory and every `identity.json`, and -/// of `` when the agent creates it; an existing `` gets only the check of the directories above, and -/// the directories above it that the agent creates keep the access they inherit. +/// of `` and its missing parents when the agent creates them; an existing `` gets only the check of +/// the directories above. /// /// [`crates/agent-identity/INTENT.md`] ("Local state"): they are owned by the agent's account, which in production is /// SYSTEM or root. @@ -173,8 +173,9 @@ fn open_parent(path: &Utf8Path) -> anyhow::Result<(Dir, &str)> { /// Provisions the identity directories, and returns ``. /// -/// A missing `` is created with the identity rule, and its missing parents as the platform creates any -/// directory, so that they keep the access they inherit: they are not the agent's identity directories. +/// A missing `` and its missing parents are created with the identity rule, by handle and refusing links, so +/// that no directory created on the way can be replaced or redirected by another account. +/// The installer and the configuration create `` in production, so this applies only where they didn't. /// /// [`crates/agent-identity/INTENT.md`] ("Local state"): an identity directory with a wrong owner or wrong permissions is /// reset, without changing anything below it. @@ -186,9 +187,6 @@ pub(crate) fn provision_directories(data_dir: &Utf8Path, grant_current_user: boo grant_current_user || crate::windows::is_system()?, "identity directory must be created by SYSTEM" ); - if let Some(parent) = data_dir.parent().filter(|parent| !parent.as_str().is_empty()) { - std::fs::create_dir_all(parent).context("create the parent of the identity data directory")?; - } let rule = tree_rule(grant_current_user); let data = Dir::create_base(data_dir, &rule).context("open identity data directory")?; let identity = provision(&data, "identity", &rule)?; @@ -785,20 +783,12 @@ mod unix_tests { // oracle: intent crates/agent-identity/INTENT.md §Local state #[test] - fn provisioning_under_a_missing_parent_leaves_the_parent_its_default_mode() -> anyhow::Result<()> { + fn provisioning_gives_a_missing_data_directory_the_identity_rule() -> anyhow::Result<()> { let directory = TestDir::new()?; - let parent = directory.0.join("parent"); - let data_dir = parent.join("data"); - // A directory the platform creates by default gets the mode the process umask leaves. - let default = directory.0.join("default"); - fs::create_dir(&default)?; + let data_dir = directory.0.join("parent").join("data"); prepare_directories(&data_dir, true)?; - assert_eq!( - fs::metadata(&parent)?.permissions().mode(), - fs::metadata(&default)?.permissions().mode() - ); assert_eq!(fs::metadata(&data_dir)?.permissions().mode() & 0o777, 0o700); Ok(()) } @@ -971,14 +961,12 @@ mod windows_tests { // oracle: intent crates/agent-identity/INTENT.md §Local state #[test] - fn provisioning_under_a_missing_parent_leaves_the_parent_its_inherited_access() -> anyhow::Result<()> { + fn provisioning_gives_a_missing_data_directory_the_identity_rule() -> anyhow::Result<()> { let directory = TestDir::new()?; - let parent = directory.0.join("parent"); - let data_dir = parent.join("data"); + let data_dir = directory.0.join("parent").join("data"); prepare_directories(&data_dir, true)?; - assert_eq!(acl::protected_dacl_of(&parent)?, None); assert_eq!( acl::protected_dacl_of(&data_dir)?, Some(acl::sorted(&[ diff --git a/crates/device-key/src/file.rs b/crates/device-key/src/file.rs index b632e3a3a6..24d6237287 100644 --- a/crates/device-key/src/file.rs +++ b/crates/device-key/src/file.rs @@ -927,20 +927,26 @@ mod tests { Ok(()) } + /// Gives a test directory whose mode it narrowed its owner's full access back when dropped, so that it can be + /// removed. + #[cfg(unix)] + struct RestorePermissions(Utf8PathBuf); + + #[cfg(unix)] + impl Drop for RestorePermissions { + fn drop(&mut self) { + use std::os::unix::fs::PermissionsExt as _; + + let _ = fs::set_permissions(&self.0, fs::Permissions::from_mode(0o700)); + } + } + // oracle: intent crates/device-key/INTENT.md §File backend #[cfg(unix)] #[test] fn existing_keys_work_under_execute_only_ancestor() -> anyhow::Result<()> { use std::os::unix::fs::PermissionsExt as _; - struct RestorePermissions(Utf8PathBuf); - - impl Drop for RestorePermissions { - fn drop(&mut self) { - let _ = fs::set_permissions(&self.0, fs::Permissions::from_mode(0o700)); - } - } - let root = KeyDirectory::new()?; let ancestor = root.0.join("execute-only"); let keys = ancestor.join("keys"); @@ -963,13 +969,6 @@ mod tests { // `crates/device-key/INTENT.md` ("File backend") asks only two rights on the ancestors for keeping and using keys // once the key directory exists; creating it needs its parent open, to sync the new entry so that it survives a // power cut. - struct RestorePermissions(Utf8PathBuf); - - impl Drop for RestorePermissions { - fn drop(&mut self) { - let _ = fs::set_permissions(&self.0, fs::Permissions::from_mode(0o700)); - } - } // SAFETY: Reading the process's effective UID has no preconditions. if unsafe { libc::geteuid() } == 0 { @@ -988,21 +987,12 @@ mod tests { dir: parent.join("keys"), }; - let error = generate(&backend, &new_key_name(TEST_PREFIX)) - .err() - .context("a key directory was created under an execute-only parent")?; + let generated = generate(&backend, &new_key_name(TEST_PREFIX)); drop(restore); - let refusal = error - .chain() - .find_map(|cause| cause.downcast_ref::()) - .context("the failure is not from private-fs")?; assert!( - matches!( - refusal.kind(), - private_fs::ErrorKind::Refused(private_fs::Refusal::Unreadable(_)) - ), - "{error:#}" + generated.is_err(), + "a key directory was created under an execute-only parent" ); assert!(!parent.join("keys").exists()); Ok(()) From 45cd649aa4f6133bd2e504a02922810e70700fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 20:53:44 +0900 Subject: [PATCH 43/60] test(agent): keep the conformance work directory under ProgramData on Windows The SYSTEM fixture's agent runs as SYSTEM, which refuses a data directory under a user's profile, since that user can rename or replace the directories above it. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- testsuite/tests/agent_identity/mod.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/testsuite/tests/agent_identity/mod.rs b/testsuite/tests/agent_identity/mod.rs index c77e140b47..68f7687a26 100644 --- a/testsuite/tests/agent_identity/mod.rs +++ b/testsuite/tests/agent_identity/mod.rs @@ -312,11 +312,19 @@ async fn conformance() -> anyhow::Result<()> { let agent_version = std::fs::read_to_string(project_dir.join("VERSION")).context("read workspace VERSION")?; let agent_version = agent_version.trim(); ensure!(!agent_version.is_empty(), "workspace VERSION is empty"); - // The agent refuses a data directory with a directory above it that another account can rename, as a workspace - // `target` directory's can be, and the system temporary directory's are not. + // The agent refuses a data directory with a directory above it that an account other than the system account, + // administrators and its own can rename or replace, as a workspace `target` directory's can be. On Windows, the + // SYSTEM fixture's agent runs as SYSTEM, so even the user's temporary directory fails; `%ProgramData%` passes, and a + // directory an elevated administrator creates there is owned by Administrators. + #[cfg(windows)] + let work_parent = std::env::var_os("ProgramData") + .map(PathBuf::from) + .context("ProgramData is not set")?; + #[cfg(not(windows))] + let work_parent = std::env::temp_dir(); let mut work_dir = tempfile::Builder::new() .prefix("agent-identity-integration-") - .tempdir()?; + .tempdir_in(work_parent)?; let mut first_dir = tempfile::Builder::new().prefix("mock-1-").tempdir_in(work_dir.path())?; let second_dir = tempfile::Builder::new().prefix("mock-2-").tempdir_in(work_dir.path())?; let first = start_mock(first_dir.path()).await?; From 1ec5da2586c6caa13d2af2b8d62824f6fd80c8f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 21:18:14 +0900 Subject: [PATCH 44/60] test(private-fs): cite each test's deciding source instead of the crate's own docs A test whose expected value comes from the crate's own rustdoc is self-derived. Tests that an intent or spec sentence decides now cite it. Tests that alone catch a fault no source decides keep a mutant marker naming it. Tests that catch nothing the rest of the suite misses are removed. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/unix.rs | 22 ++----- crates/private-fs/src/windows/mod.rs | 5 +- crates/private-fs/src/windows/security.rs | 2 +- testsuite/tests/private_fs/unix.rs | 79 +++-------------------- testsuite/tests/private_fs/windows.rs | 24 ++----- 5 files changed, 20 insertions(+), 112 deletions(-) diff --git a/crates/private-fs/src/unix.rs b/crates/private-fs/src/unix.rs index 48e0d57b02..397d712149 100644 --- a/crates/private-fs/src/unix.rs +++ b/crates/private-fs/src/unix.rs @@ -913,7 +913,7 @@ mod tests { // A backend that let the owner keep less than full access, or chose a broader access than the rule, would leave // the owner, who can always change the mode, with access the rule withholds. - // oracle: reference crates/private-fs/src/rule.rs Access + // oracle: intent crates/private-fs/INTENT.md §rules #[test] fn a_grant_this_platform_cannot_enforce_needs_a_fallback() { let rule = Rule::new() @@ -943,7 +943,7 @@ mod tests { ); } - // oracle: reference crates/private-fs/src/rule.rs Rule::owner_fallback + // oracle: intent crates/private-fs/INTENT.md §rules #[test] fn an_owner_without_full_access_needs_an_owner_fallback() { let rule = Rule::new().owner(Account::CurrentUser).grant_or( @@ -972,7 +972,7 @@ mod tests { } // Root has every access whatever the mode says, so a rule may give root, or a root owner, any access. - // oracle: reference crates/private-fs/src/rule.rs Account + // oracle: intent crates/private-fs/INTENT.md §mechanisms #[test] fn grants_to_root_need_nothing_on_disk() { let rule = Rule::new() @@ -1009,21 +1009,7 @@ mod tests { assert!(check(&rule, 0, USER, Kind::Directory).is_ok()); } - // oracle: reference crates/private-fs/src/rule.rs Access::AddOnly - #[test] - fn add_only_access_to_a_file_is_unsupported_even_for_root() { - let rule = Rule::new() - .owner(Account::CurrentUser) - .grant(Account::CurrentUser, Access::Full) - .grant(Account::System, Access::AddOnly); - assert_eq!( - unsupported(check(&rule, USER, USER, Kind::File)), - Some((Account::System, Access::AddOnly)) - ); - assert!(check(&rule, USER, USER, Kind::Directory).is_ok()); - } - - // oracle: reference crates/private-fs/src/rule.rs Rule::owner + // oracle: intent crates/private-fs/INTENT.md §vocabulary #[test] fn only_an_account_the_rule_names_may_own_an_item() { let current_user_only = Rule::new().owner(Account::CurrentUser); diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index d7a7260c57..bbbb8cfd0d 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -976,7 +976,7 @@ mod tests { } // oracle: spec MS-FSCC §2.1.2.1 Reparse Tags - // oracle: reference crates/private-fs/src/error.rs Refusal + // oracle: intent crates/private-fs/INTENT.md §paths #[test] fn a_name_surrogate_item_is_a_link() { for tag in [SYMLINK, MOUNT_POINT] { @@ -988,7 +988,7 @@ mod tests { } // oracle: spec MS-FSCC §2.1.2.1 Reparse Tags - // oracle: reference crates/private-fs/src/error.rs Refusal + // oracle: intent crates/private-fs/INTENT.md §mechanisms #[test] fn any_other_reparse_point_item_is_not_a_file() { // Opened without following the reparse point, a stub's content is not the file's, and a FIFO is not a file. @@ -1001,7 +1001,6 @@ mod tests { } // oracle: spec MS-FSCC §2.1.2.1 Reparse Tags - // oracle: reference crates/private-fs/src/lib.rs EntryKind #[test] fn a_listed_reparse_point_is_a_link_only_when_it_stands_for_another_name() { assert_eq!(entry_kind(REPARSE_POINT, SYMLINK), EntryKind::Link); diff --git a/crates/private-fs/src/windows/security.rs b/crates/private-fs/src/windows/security.rs index e6f8fa65aa..afd15f1ab6 100644 --- a/crates/private-fs/src/windows/security.rs +++ b/crates/private-fs/src/windows/security.rs @@ -775,7 +775,7 @@ mod tests { assert_eq!(refusal(checked), "Access"); } - // oracle: reference crates/private-fs/src/rule.rs Rule + // oracle: mutant a check that skips a deny entry instead of refusing it #[test] fn a_check_refuses_a_deny_entry() { // A rule names only accounts with access, so its DACL is allow entries only, and a deny entry is not one. diff --git a/testsuite/tests/private_fs/unix.rs b/testsuite/tests/private_fs/unix.rs index b9e9417005..5ed636304d 100644 --- a/testsuite/tests/private_fs/unix.rs +++ b/testsuite/tests/private_fs/unix.rs @@ -3,7 +3,7 @@ //! Each test works in its own directory under the system temporary directory, whose ancestors are root's. use std::fs; -use std::io::{Read as _, Write as _}; +use std::io::Write as _; use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _, symlink}; use std::os::unix::net::UnixListener; use std::process::Command; @@ -203,18 +203,6 @@ fn create_base_creates_each_missing_directory_owner_only() { assert!(Dir::open_base(&path, &rule()).unwrap().is_some()); } -// oracle: reference crates/private-fs/src/lib.rs Dir::open_base -#[test] -fn open_base_returns_none_for_a_missing_directory() { - let scratch = Scratch::new(); - assert!( - Dir::open_base(&scratch.path.join("missing"), &rule()) - .unwrap() - .is_none() - ); - assert!(!scratch.path.join("missing").exists()); -} - // A directory another process created first, open to other accounts, must not count as created. // oracle: intent crates/private-fs/INTENT.md §paths #[test] @@ -295,7 +283,7 @@ fn an_entry_that_is_a_symbolic_link_is_refused() { } // Another hard link lets whoever can reach it in another directory open the file. -// oracle: reference crates/private-fs/src/lib.rs Dir::open_file +// oracle: intent crates/private-fs/INTENT.md §mechanisms #[test] fn a_file_with_another_hard_link_is_refused() { let scratch = Scratch::new(); @@ -328,7 +316,7 @@ enum Special { } // Opening a FIFO for reading blocks until a writer comes, unless the open doesn't wait. -// oracle: reference crates/private-fs/src/error.rs Refusal::NotAFile +// oracle: mutant a check that takes a FIFO for a regular file #[rstest] #[case::fifo(Special::Fifo)] #[case::socket(Special::Socket)] @@ -646,7 +634,7 @@ fn root_only_an_item_owned_by_another_account_is_refused_until_repaired() { } // private-fs never chooses a broader access than the rule: an owner other than root can always change the mode. -// oracle: reference crates/private-fs/src/rule.rs Access +// oracle: intent crates/private-fs/INTENT.md §rules #[test] fn a_grant_the_platform_cannot_enforce_fails_without_a_fallback() { let scratch = Scratch::new(); @@ -672,7 +660,7 @@ fn a_grant_the_platform_cannot_enforce_fails_without_a_fallback() { Dir::create_base(&scratch.path.join("dir"), &with_fallback).unwrap(); } -// oracle: reference crates/private-fs/src/rule.rs Access::AddOnly +// oracle: mutant an add-only grant to root on a file accepted instead of unsupported #[test] fn add_only_access_to_a_file_fails_without_a_fallback() { let scratch = Scratch::new(); @@ -686,7 +674,7 @@ fn add_only_access_to_a_file_fails_without_a_fallback() { } // private-fs creates what it creates owned by the current user, which the rule must allow. -// oracle: reference crates/private-fs/src/rule.rs Rule::owner +// oracle: intent crates/private-fs/INTENT.md §paths #[test] fn a_rule_that_does_not_let_the_current_user_own_cannot_create() { let scratch = Scratch::new(); @@ -706,55 +694,6 @@ fn a_rule_that_does_not_let_the_current_user_own_cannot_create() { assert!(!scratch.path.join("dir").exists()); } -// oracle: reference crates/private-fs/src/lib.rs Dir::persist -#[test] -fn persist_installs_the_written_file_under_its_name() { - let scratch = Scratch::new(); - let base = scratch.base(); - let mut file = base.create_file("key.tmp", &rule(), FileOptions::new()).unwrap(); - file.write_all(b"secret").unwrap(); - base.persist(&file, "key.tmp", "key", Replace::Refuse).unwrap(); - - let mut content = String::new(); - base.open_file("key", &rule(), FileOptions::new().read(true)) - .unwrap() - .expect("persisted file") - .read_to_string(&mut content) - .unwrap(); - assert_eq!(content, "secret"); - assert!( - base.open_file("key.tmp", &rule(), FileOptions::new()) - .unwrap() - .is_none() - ); -} - -// oracle: reference crates/private-fs/src/lib.rs Replace -#[test] -fn a_rename_that_refuses_replacement_keeps_the_existing_entry() { - let scratch = Scratch::new(); - let base = scratch.base(); - base.create_file("old", &rule(), FileOptions::new()) - .unwrap() - .write_all(b"old") - .unwrap(); - base.create_file("new", &rule(), FileOptions::new()) - .unwrap() - .write_all(b"new") - .unwrap(); - - let error = base.rename("new", "old", Replace::Refuse).unwrap_err(); - assert!( - matches!(error.kind(), ErrorKind::Io { source, .. } if source.kind() == std::io::ErrorKind::AlreadyExists), - "{error:?}" - ); - assert_eq!(fs::read(scratch.path.join("old")).unwrap(), b"old"); - - base.rename("new", "old", Replace::Allow).unwrap(); - assert_eq!(fs::read(scratch.path.join("old")).unwrap(), b"new"); - assert!(!scratch.path.join("new").exists()); -} - /// Writes the file `name` in `scratch` with `mode`, outside private-fs, and opens it. fn plain_file(scratch: &Scratch, name: &str, mode: u32) -> (Utf8PathBuf, fs::File) { let path = scratch.path.join(name); @@ -808,7 +747,7 @@ fn repair_leaves_a_file_with_another_hard_link_unchanged() { assert_eq!(mode(&path), 0o644); } -// oracle: reference crates/private-fs/src/lib.rs Dir::remove_file +// oracle: mutant a removal that reports the inverse of whether the file existed #[test] fn remove_file_reports_whether_the_file_existed() { let scratch = Scratch::new(); @@ -819,7 +758,7 @@ fn remove_file_reports_whether_the_file_existed() { } // A discard that removed by name alone would remove whatever was installed under the name since. -// oracle: reference crates/private-fs/src/lib.rs Dir::discard +// oracle: mutant a discard that removes the name although it now holds another file #[test] fn discard_removes_only_the_file_it_was_given() { let scratch = Scratch::new(); @@ -836,7 +775,7 @@ fn discard_removes_only_the_file_it_was_given() { } // A listing that followed links would report a link to a directory as a directory. -// oracle: reference crates/private-fs/src/lib.rs EntryKind +// oracle: mutant a listing that reports a symbolic link as something other than a link #[test] fn entries_report_kinds_without_following_links() { let scratch = Scratch::new(); diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index e772093c0a..c11ff13896 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -334,7 +334,7 @@ fn the_backup_privilege_opens_a_file_the_token_cannot() -> anyhow::Result<()> { Ok(()) } -// oracle: reference crates/private-fs/src/rule.rs Rule::owner +// oracle: intent crates/private-fs/INTENT.md §paths #[test] fn a_rule_that_does_not_let_the_current_user_own_cannot_create() -> anyhow::Result<()> { // Catches a creation that makes the current user the owner although the rule doesn't allow it, or that reports it @@ -943,7 +943,7 @@ fn a_created_directory_opens_elsewhere_without_sharing_deletion() -> anyhow::Res Ok(()) } -// oracle: reference crates/private-fs/src/lib.rs Dir::discard +// oracle: mutant a discard that removes the file after a persist renamed it #[test] fn discard_keeps_a_file_persisted_under_another_name() -> anyhow::Result<()> { // Catches a discard after a persist whose flush failed, which would delete the file just installed while the file @@ -959,23 +959,7 @@ fn discard_keeps_a_file_persisted_under_another_name() -> anyhow::Result<()> { Ok(()) } -// oracle: reference crates/private-fs/src/lib.rs Dir::discard -#[test] -fn discard_keeps_a_file_renamed_to_a_name_only_full_case_mapping_matches() -> anyhow::Result<()> { - // Catches names compared with Unicode's full case mapping, under which `xß` and `xSS` match although NTFS keeps - // them apart, so that a discard deletes the file installed under the other name. - let test = TestDir::new(); - let base = Dir::create_base(test.path(), &user_rule())?; - let mut file = base.create_file("x\u{df}", &user_rule(), FileOptions::new())?; - file.write_all(b"new")?; - base.persist(&file, "x\u{df}", "xSS", Replace::Allow)?; - base.discard(&file, "x\u{df}")?; - drop(file); - assert_eq!(std::fs::read(test.path().join("xSS"))?, b"new"); - Ok(()) -} - -// oracle: reference crates/private-fs/src/lib.rs Dir::discard +// oracle: mutant a discard that compares names case-sensitively, so it keeps a file given in another case #[test] fn discard_removes_a_file_still_under_its_name() -> anyhow::Result<()> { // Catches a discard that never removes anything, given a name of another case than the one created. @@ -988,7 +972,7 @@ fn discard_removes_a_file_still_under_its_name() -> anyhow::Result<()> { Ok(()) } -// oracle: reference crates/private-fs/src/lib.rs Dir::entries +// oracle: mutant a listing through a second handle, which an exclusive open refuses #[test] fn a_directory_opened_exclusively_lists_its_entries() -> anyhow::Result<()> { // Catches a listing through a second handle, which the exclusive open refuses. From 153315313f5171ef38b533d870e7389fa4f0717d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 21:22:17 +0900 Subject: [PATCH 45/60] test(private-fs): prune the Windows twins of removed and remarked tests Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- testsuite/tests/private_fs/windows.rs | 31 +-------------------------- 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index c11ff13896..a3a5658a23 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -744,35 +744,6 @@ fn repair_file_brings_a_file_into_line_with_its_rule() -> anyhow::Result<()> { Ok(()) } -// oracle: intent crates/private-fs/INTENT.md §paths -#[test] -fn open_base_returns_none_for_a_missing_directory() -> anyhow::Result<()> { - // Catches an open that creates what it should only open. - let test = TestDir::new(); - assert!(Dir::open_base(&test.path().join("missing"), &user_rule())?.is_none()); - assert!(!test.path().exists()); - Ok(()) -} - -// oracle: intent crates/private-fs/INTENT.md §paths -#[test] -fn persist_refusing_to_replace_keeps_the_existing_file() -> anyhow::Result<()> { - // Catches a rename without replacement that overwrites the destination. - let test = TestDir::new(); - let base = Dir::create_base(test.path(), &user_rule())?; - base.create_file("old", &user_rule(), FileOptions::new())? - .write_all(b"old")?; - let mut new = base.create_file("new", &user_rule(), FileOptions::new())?; - new.write_all(b"new")?; - let error = refusal(base.persist(&new, "new", "old", Replace::Refuse)); - assert!( - matches!(error.kind(), ErrorKind::Io { source, .. } if source.kind() == std::io::ErrorKind::AlreadyExists), - "{error:?}" - ); - assert_eq!(std::fs::read(test.path().join("old"))?, b"old"); - Ok(()) -} - // oracle: intent crates/private-fs/INTENT.md §paths #[test] fn persist_replacing_moves_the_content_to_the_destination() -> anyhow::Result<()> { @@ -842,7 +813,7 @@ fn entries_lists_each_entry_with_its_kind() -> anyhow::Result<()> { Ok(()) } -// oracle: intent crates/private-fs/INTENT.md §paths +// oracle: mutant remove_file reporting the inverse of whether the file existed #[test] fn remove_file_reports_whether_the_file_existed() -> anyhow::Result<()> { // Catches a removal that fails on a missing file or leaves the file. From 9e7456d32bbcc657688901bf6462f40240b988ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 21:45:16 +0900 Subject: [PATCH 46/60] fix(private-fs): require the rule's mode under an exact Unix rule On Linux and macOS an exact check also refuses owner bits other than the rule's, 0700 for a directory and 0600 for a file, so a repair resets a narrowed mode such as 0500 instead of keeping it. The setuid, setgid and sticky bits stay ignored, and a narrower check is unchanged. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/unix.rs | 10 ++++++++++ testsuite/tests/private_fs/unix.rs | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/crates/private-fs/src/unix.rs b/crates/private-fs/src/unix.rs index 397d712149..f5dec3ce55 100644 --- a/crates/private-fs/src/unix.rs +++ b/crates/private-fs/src/unix.rs @@ -3,6 +3,8 @@ //! The owner of a file or directory can always change its mode, so it always has full access, and root can access //! everything whatever the mode says. //! A rule therefore reduces to an owner, mode `0700` for a directory or `0600` for a file, and no extended ACL. +//! A check refuses group and other bits, and under an exact rule also owner bits other than the rule's, which leave the +//! owner less than its full access until it changes the mode back. use std::ffi::{CStr, CString, OsString}; use std::fs::{File, Metadata, OpenOptions, Permissions}; @@ -647,6 +649,14 @@ fn check_item(file: &File, path: &Utf8Path, rule: &Rule, kind: Kind) -> Result<( if metadata.mode() & GROUP_OTHER_BITS != 0 { return Err(Error::refused(path, Refusal::Access)); } + // The setuid, setgid and sticky bits give no account access, so even an exact rule ignores them. + let expected = match kind { + Kind::Directory => DIR_MODE, + Kind::File => FILE_MODE, + }; + if rule.exact && metadata.mode() & 0o777 != expected { + return Err(Error::refused(path, Refusal::Access)); + } os::check_no_extended_acl(file, path, kind == Kind::Directory) } diff --git a/testsuite/tests/private_fs/unix.rs b/testsuite/tests/private_fs/unix.rs index 5ed636304d..edff4d5a33 100644 --- a/testsuite/tests/private_fs/unix.rs +++ b/testsuite/tests/private_fs/unix.rs @@ -222,6 +222,24 @@ fn an_existing_directory_open_to_others_is_refused_until_repaired() { assert!(base.open_dir("shared", &rule(), Share::Shared).unwrap().is_some()); } +// Catches an exact check that accepted a mode taking access away from the owner, which a repair then never resets. +// oracle: intent crates/private-fs/INTENT.md §vocabulary +#[test] +fn an_exact_rule_requires_the_rule_s_mode_and_a_repair_restores_it() { + let scratch = Scratch::new(); + let base = scratch.base(); + drop(base.create_dir("dir", &rule(), Existing::Check).unwrap()); + let dir = scratch.path.join("dir"); + set_mode(&dir, 0o500); + + assert!(base.open_dir("dir", &rule(), Share::Shared).unwrap().is_some()); + let error = base.open_dir("dir", &rule().exact(), Share::Shared).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); + + base.create_dir("dir", &rule().exact(), Existing::Repair).unwrap(); + assert_eq!(mode(&dir), 0o700); +} + // Catches a repair that opens the directory first, which could never fix one whose mode shuts out its own owner. // oracle: intent crates/private-fs/INTENT.md §paths #[test] From 1f14f2915ddf4132b06f4552d8a94fc509377840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 21:45:40 +0900 Subject: [PATCH 47/60] docs(private-fs): say what an exact rule requires on Linux and macOS Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/rule.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/private-fs/src/rule.rs b/crates/private-fs/src/rule.rs index 535afdf7fc..5a1e70fe53 100644 --- a/crates/private-fs/src/rule.rs +++ b/crates/private-fs/src/rule.rs @@ -109,6 +109,8 @@ impl Rule { /// Makes a check also fail when a file or directory gives an account less than the rule does. /// /// By default a check fails only when a file or directory gives an account more than the rule does. + /// On Linux and macOS an exact check also requires the mode the rule maps to, `0700` for a directory and `0600` for a + /// file, ignoring the setuid, setgid and sticky bits, so a repair resets an owner's narrowed mode. #[must_use] pub fn exact(mut self) -> Self { self.exact = true; From a5ccdedd6b7bea6a38f869eee3cb557044beff91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 21:45:48 +0900 Subject: [PATCH 48/60] fix(agent): never list a hard-linked device key, and fail on a bad lock file Listing device keys now skips a key file with another hard link, which makes it a link rather than a key, and still returns a key that breaks the access rule, so that a caller can clean it up. The lock file is device-key's own entry, so listing again fails at once, as every other operation does, when the lock file breaks its rule, naming the file and why; leftovers are then never skipped. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/device-key/src/file.rs | 101 +++++++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 26 deletions(-) diff --git a/crates/device-key/src/file.rs b/crates/device-key/src/file.rs index 24d6237287..c1ba317bd5 100644 --- a/crates/device-key/src/file.rs +++ b/crates/device-key/src/file.rs @@ -163,39 +163,33 @@ pub(super) fn list(dir: &Utf8Path, prefix: &str) -> anyhow::Result> let Some(dir) = open_key_directory(dir)? else { return Ok(Vec::new()); }; - // `crates/device-key/INTENT.md` ("Operations"): listing skips an unexpected entry, a lock file that fails its rule - // included. - // Listing needs the lock only to remove leftovers first, and their names never end in `.p8`, so without the lock it - // lists the same keys and leaves the leftovers to a later operation. - let _lock = match lock_directory(&dir) { - Ok(lock) => { - cleanup_abandoned(&dir)?; - Some(lock) - } - Err(error) - if error - .chain() - .filter_map(|cause| cause.downcast_ref::()) - .any(private_fs::Error::is_refused) => - { - None - } - Err(error) => return Err(error), - }; + let _lock = lock_and_reconcile(&dir)?; let mut names = dir .entries() .context("list key directory")? .into_iter() .filter(|entry| entry.kind == EntryKind::File) .filter_map(|entry| { - let name = entry.name.to_str()?.strip_suffix(KEY_FILE_SUFFIX)?; - name_matches_prefix(name, prefix).then(|| name.to_owned()) + let file_name = entry.name.to_str()?; + let name = file_name.strip_suffix(KEY_FILE_SUFFIX)?; + (name_matches_prefix(name, prefix) && !is_hard_linked(&dir, file_name)).then(|| name.to_owned()) }) .collect::>(); names.sort_unstable(); Ok(names) } +/// Reports whether the key file `file_name` has another hard link, which makes it a link rather than a key. +/// +/// `crates/device-key/INTENT.md` ("Operations"): listing never returns a link, but returns a key that breaks the access +/// rule, so only a refusal for a link excludes a key file, and any other failure to check it leaves it listed. +fn is_hard_linked(dir: &Dir, file_name: &str) -> bool { + matches!( + dir.open_file(file_name, &key_file_rule(), FileOptions::new()), + Err(error) if matches!(error.kind(), private_fs::ErrorKind::Refused(private_fs::Refusal::Link)) + ) +} + /// Opens the key directory, or returns `None` when it doesn't exist. fn open_key_directory(dir: &Utf8Path) -> anyhow::Result> { // An empty path would name the current directory, which holds no keys. @@ -777,8 +771,10 @@ mod tests { } // oracle: intent crates/device-key/INTENT.md §Operations + // The lock file is the crate's own entry, so it is not an unexpected entry that listing skips; failing promptly on one + // that breaks its rule keeps both "no leftover survives a later operation" and "fails promptly" true. #[test] - fn a_lock_file_that_fails_its_rule_stops_every_operation_but_listing() -> anyhow::Result<()> { + fn a_lock_file_that_fails_its_rule_stops_every_operation_promptly() -> anyhow::Result<()> { let directory = KeyDirectory::new()?; let backend = directory.backend(); let name = new_key_name(TEST_PREFIX); @@ -795,10 +791,28 @@ mod tests { fs::set_permissions(&lock, fs::Permissions::from_mode(0o644))?; } - assert_eq!(list(&backend, TEST_PREFIX)?, std::slice::from_ref(&name)); - assert!(open(&backend, &name).is_err()); - assert!(generate(&backend, &new_key_name(TEST_PREFIX)).is_err()); - assert!(delete(&backend, &name).is_err()); + let operations: [(&str, &dyn Fn() -> anyhow::Result<()>); 4] = [ + ("list", &|| list(&backend, TEST_PREFIX).map(drop)), + ("open", &|| open(&backend, &name).map(drop)), + ("generate", &|| generate(&backend, &new_key_name(TEST_PREFIX)).map(drop)), + ("delete", &|| delete(&backend, &name)), + ]; + for (operation, run) in operations { + let started = Instant::now(); + let error = run() + .err() + .with_context(|| format!("{operation} ignored a lock file that fails its rule"))?; + let elapsed = started.elapsed(); + + assert!(elapsed < LOCK_TIMEOUT, "{operation}: {elapsed:?}"); + // The error names the lock file and why it fails, so that an operator can fix it. + let refusal = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .with_context(|| format!("{operation}: the failure is not a refusal: {error:#}"))?; + assert!(refusal.is_refused(), "{operation}: {error:#}"); + assert!(format!("{error:#}").contains(LOCK_NAME), "{operation}: {error:#}"); + } Ok(()) } @@ -830,6 +844,41 @@ mod tests { Ok(()) } + // oracle: intent crates/device-key/INTENT.md §Operations + #[test] + fn a_hard_linked_key_file_is_not_listed() -> anyhow::Result<()> { + let directory = KeyDirectory::new()?; + let backend = directory.backend(); + let linked = new_key_name(TEST_PREFIX); + let kept = new_key_name(TEST_PREFIX); + generate(&backend, &linked)?; + generate(&backend, &kept)?; + fs::hard_link(directory.key_path(&linked), directory.0.join("elsewhere"))?; + + assert_eq!(list(&backend, TEST_PREFIX)?, [kept]); + Ok(()) + } + + // oracle: intent crates/device-key/INTENT.md §Operations + #[test] + fn a_key_file_readable_by_other_accounts_is_still_listed() -> anyhow::Result<()> { + let directory = KeyDirectory::new()?; + let backend = directory.backend(); + let name = new_key_name(TEST_PREFIX); + // Written outside the crate, the file is readable by other accounts: on Unix through its mode, and on Windows + // through the entries it inherits from the key directory. + fs::write(directory.key_path(&name), b"key material")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(directory.key_path(&name), fs::Permissions::from_mode(0o644))?; + } + + assert_eq!(list(&backend, TEST_PREFIX)?, [name]); + Ok(()) + } + // oracle: intent crates/device-key/INTENT.md §Operations #[test] fn lock_wait_on_an_existing_lock_file_expires() -> anyhow::Result<()> { From 81a24178b47fe1e59aa1754e5de1a22d4c03f987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 21:58:35 +0900 Subject: [PATCH 49/60] fix(private-fs): report a mount folder above a base as unreadable Its owner and DACL can't be read through the mounted volume's root, so the check of the directories above fails for that reason, not because another account could replace it. Tests now guard the private parents of a new data directory and the refusal of a key directory under an execute-only parent, and markers say exactly what decides them. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/agent-identity/src/private_file.rs | 9 +++++++++ crates/device-key/src/file.rs | 9 +++++++-- crates/private-fs/src/unix.rs | 4 ++++ crates/private-fs/src/windows/mod.rs | 7 +++++-- testsuite/tests/private_fs/unix.rs | 3 ++- testsuite/tests/private_fs/windows.rs | 2 +- 6 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/agent-identity/src/private_file.rs b/crates/agent-identity/src/private_file.rs index a3b0f41ba8..4f7c50ff82 100644 --- a/crates/agent-identity/src/private_file.rs +++ b/crates/agent-identity/src/private_file.rs @@ -790,6 +790,12 @@ mod unix_tests { prepare_directories(&data_dir, true)?; assert_eq!(fs::metadata(&data_dir)?.permissions().mode() & 0o777, 0o700); + // oracle: mutant missing parents of the data directory created with the default mode, which loses the refusal of + // a link among them + assert_eq!( + fs::metadata(directory.0.join("parent"))?.permissions().mode() & 0o777, + 0o700 + ); Ok(()) } @@ -974,6 +980,9 @@ mod windows_tests { full(&acl::user_sid()?, INHERITED) ])) ); + // oracle: mutant missing parents of the data directory created with inherited access, which loses the refusal of + // a link among them + assert!(acl::protected_dacl_of(&directory.0.join("parent"))?.is_some()); Ok(()) } diff --git a/crates/device-key/src/file.rs b/crates/device-key/src/file.rs index c1ba317bd5..71607b44e8 100644 --- a/crates/device-key/src/file.rs +++ b/crates/device-key/src/file.rs @@ -1039,9 +1039,14 @@ mod tests { let generated = generate(&backend, &new_key_name(TEST_PREFIX)); drop(restore); + let error = generated + .err() + .context("a key directory was created under an execute-only parent")?; assert!( - generated.is_err(), - "a key directory was created under an execute-only parent" + error.chain().any(|cause| cause + .downcast_ref::() + .is_some_and(private_fs::Error::is_refused)), + "{error:#}" ); assert!(!parent.join("keys").exists()); Ok(()) diff --git a/crates/private-fs/src/unix.rs b/crates/private-fs/src/unix.rs index f5dec3ce55..bf235a38c0 100644 --- a/crates/private-fs/src/unix.rs +++ b/crates/private-fs/src/unix.rs @@ -133,6 +133,10 @@ pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, return Err(Error::io(path, "create directory", error)); } } + // UNTESTED: a directory this call just created opens even under a umask that removes the owner's read or + // search bit, because the open restores the owner's access first. + // The umask is process-wide, so a test can't set one without racing every other test. + // When changing this, keep restoring the access of a directory created here before giving up on its open. let file = open(created || existing == Existing::Repair)? .ok_or_else(|| Error::io(path, "open directory", io::ErrorKind::NotFound.into()))?; (file, created) diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index bbbb8cfd0d..72031b8206 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -916,10 +916,13 @@ fn check_above(path: &Utf8Path, policy: &Policy) -> Result<()> { return Err(Error::refused(path, Refusal::Replaceable)); } // A folder on which a volume without a drive letter is mounted resolves to its own path, but opens the mounted - // volume's root, so the folder's own DACL can't be read through it: it is refused too. + // volume's root, so the folder's own owner and DACL can't be read through it, which fails the check. let in_volume = nt::path_in_volume(handle.as_handle()).map_err(unreadable)?; if is_mount_folder(&in_volume, path) { - return Err(Error::refused(path, Refusal::Replaceable)); + return Err(unreadable(io::Error::new( + io::ErrorKind::Unsupported, + "a volume is mounted on this folder", + ))); } let security = OwnerAndDacl::of_handle(handle.as_handle()).map_err(unreadable)?; policy.check_ancestor(&security, path, path.parent().is_none(), &administrators_member) diff --git a/testsuite/tests/private_fs/unix.rs b/testsuite/tests/private_fs/unix.rs index edff4d5a33..5e067d037e 100644 --- a/testsuite/tests/private_fs/unix.rs +++ b/testsuite/tests/private_fs/unix.rs @@ -334,7 +334,7 @@ enum Special { } // Opening a FIFO for reading blocks until a writer comes, unless the open doesn't wait. -// oracle: mutant a check that takes a FIFO for a regular file +// oracle: mutant a check that takes a FIFO or a socket for a regular file #[rstest] #[case::fifo(Special::Fifo)] #[case::socket(Special::Socket)] @@ -730,6 +730,7 @@ fn repair_brings_a_file_open_to_others_into_line() { assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); private_fs::repair_file(&file, &path, &rule()).unwrap(); + // oracle: mutant a repair that leaves a file mode other than 0600 that a narrower check still accepts assert_eq!(mode(&path), 0o600); private_fs::check_file(&file, &path, &rule()).unwrap(); } diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index a3a5658a23..1940829eb3 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -816,7 +816,7 @@ fn entries_lists_each_entry_with_its_kind() -> anyhow::Result<()> { // oracle: mutant remove_file reporting the inverse of whether the file existed #[test] fn remove_file_reports_whether_the_file_existed() -> anyhow::Result<()> { - // Catches a removal that fails on a missing file or leaves the file. + // Catches a removal that reports the inverse of whether the file existed. let test = TestDir::new(); let base = Dir::create_base(test.path(), &user_rule())?; drop(base.create_file("file", &user_rule(), FileOptions::new())?); From 0623c075ffeb6216d40650dfd229442745548808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 22:10:22 +0900 Subject: [PATCH 50/60] docs(private-fs): describe an access refusal as access other than the rule's An exact rule on Linux and macOS goes back to ignoring the owner's own bits; a repair resets them instead. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/error.rs | 2 +- crates/private-fs/src/rule.rs | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/private-fs/src/error.rs b/crates/private-fs/src/error.rs index fe6090bca8..ce506cdc9b 100644 --- a/crates/private-fs/src/error.rs +++ b/crates/private-fs/src/error.rs @@ -96,7 +96,7 @@ impl fmt::Display for Error { Refusal::NotADirectory => "is not a directory", Refusal::NotAFile => "is not a regular file", Refusal::Owner => "is owned by an account the rule doesn't allow", - Refusal::Access => "gives access the rule doesn't", + Refusal::Access => "gives access other than the rule's", Refusal::Volume => "is on a volume that doesn't enforce ownership and access", Refusal::Replaceable => "can be renamed or replaced by an account the rule doesn't allow", Refusal::Unreadable(_) => "has an owner or access that can't be read", diff --git a/crates/private-fs/src/rule.rs b/crates/private-fs/src/rule.rs index 5a1e70fe53..535afdf7fc 100644 --- a/crates/private-fs/src/rule.rs +++ b/crates/private-fs/src/rule.rs @@ -109,8 +109,6 @@ impl Rule { /// Makes a check also fail when a file or directory gives an account less than the rule does. /// /// By default a check fails only when a file or directory gives an account more than the rule does. - /// On Linux and macOS an exact check also requires the mode the rule maps to, `0700` for a directory and `0600` for a - /// file, ignoring the setuid, setgid and sticky bits, so a repair resets an owner's narrowed mode. #[must_use] pub fn exact(mut self) -> Self { self.exact = true; From f8380d167316b55120bc9946f45f0af400c80cb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 22:18:56 +0900 Subject: [PATCH 51/60] feat(private-fs): report each listed entry's hard-link count Entry gains links, the entry's number of hard links, read without opening the entry for its content or its owner and access, so a caller can leave out a file with another hard link even when it can't open the file to check it. On Linux and macOS it comes from fstatat without following a link, which needs only search access on the directory; on Windows from the entry opened with FILE_READ_ATTRIBUTES only, which the directory's list access grants. An entry removed meanwhile is left out. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/lib.rs | 3 ++ crates/private-fs/src/unix.rs | 25 ++++++++------- crates/private-fs/src/windows/mod.rs | 45 ++++++++++++++++++++++++--- testsuite/tests/private_fs/unix.rs | 30 ++++++++++++++++++ testsuite/tests/private_fs/windows.rs | 34 ++++++++++++++++++++ 5 files changed, 121 insertions(+), 16 deletions(-) diff --git a/crates/private-fs/src/lib.rs b/crates/private-fs/src/lib.rs index 1f51d5c283..d4cbae2bb1 100644 --- a/crates/private-fs/src/lib.rs +++ b/crates/private-fs/src/lib.rs @@ -83,6 +83,9 @@ pub struct FileOptions { pub struct Entry { pub name: OsString, pub kind: EntryKind, + /// The number of hard links of the entry, read without opening it for its content or its owner and access, so a + /// caller can leave out a file with another hard link even when it can't open the file to check it. + pub links: u64, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/private-fs/src/unix.rs b/crates/private-fs/src/unix.rs index bf235a38c0..af0d8507d5 100644 --- a/crates/private-fs/src/unix.rs +++ b/crates/private-fs/src/unix.rs @@ -373,20 +373,17 @@ pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { if name == c"." || name == c".." { continue; } - let kind = match entry.d_type { - libc::DT_REG => EntryKind::File, - libc::DT_DIR => EntryKind::Directory, - libc::DT_LNK => EntryKind::Link, - libc::DT_UNKNOWN => match stat_at(&dir.file, name) { - Ok(stat) => entry_kind(stat.st_mode), - Err(error) if error.kind() == io::ErrorKind::NotFound => continue, - Err(error) => return Err(list_error(error)), - }, - _ => EntryKind::Other, + // fstatat without following a link needs only search access on the directory, not access to the entry. + let stat = match stat_at(&dir.file, name) { + Ok(stat) => stat, + // The entry was removed since it was read. + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => return Err(list_error(error)), }; entries.push(Entry { name: OsString::from_vec(name.to_bytes().to_vec()), - kind, + kind: entry_kind(stat.st_mode), + links: link_count(stat.st_nlink), }); } } @@ -875,6 +872,12 @@ fn same_file(left: &libc::stat, right: &libc::stat) -> bool { left.st_dev == right.st_dev && left.st_ino == right.st_ino } +/// Widens a link count, whose type `nlink_t` is 16 bits on macOS, 32 bits on 64-bit ARM Linux and 64 bits on x86-64 +/// Linux. +fn link_count(links: impl Into) -> u64 { + links.into() +} + fn entry_kind(mode: libc::mode_t) -> EntryKind { match mode & libc::S_IFMT { libc::S_IFREG => EntryKind::File, diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index 72031b8206..29394d2b52 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -8,6 +8,7 @@ mod nt; mod security; use std::cell::Cell; +use std::ffi::OsStr; use std::fs::{File, OpenOptions}; use std::io; use std::marker::PhantomData; @@ -372,13 +373,47 @@ pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { let _listing = dir.listing.lock().unwrap_or_else(PoisonError::into_inner); nt::list(dir.handle()).map_err(|error| Error::io(path, "list directory", error))? }; - Ok(listed - .into_iter() - .map(|(name, attributes, tag)| Entry { + let mut entries = Vec::with_capacity(listed.len()); + for (name, attributes, tag) in listed { + let Some(links) = entry_links(dir.handle(), path, &name)? else { + // The entry was removed since it was listed. + continue; + }; + entries.push(Entry { name, kind: entry_kind(attributes, tag), - }) - .collect()) + links, + }); + } + Ok(entries) +} + +/// Returns the number of hard links of the entry `name` of `dir`, or `None` when it doesn't exist any more. +/// +/// The entry is opened with FILE_READ_ATTRIBUTES only, which the directory's FILE_LIST_DIRECTORY grants whatever the +/// entry's own DACL says, without following a reparse point. +fn entry_links(dir: BorrowedHandle<'_>, path: &Utf8Path, name: &OsStr) -> Result> { + let open_error = |error| Error::io(path, "open entry", error); + let name = name + .to_str() + .ok_or_else(|| open_error(io::Error::from(io::ErrorKind::InvalidData)))?; + let request = Request { + // Not SYNCHRONIZE, which the entry's DACL may not grant, so the handle is asynchronous, which reading + // information doesn't mind. + access: FILE_READ_ATTRIBUTES, + share: SHARE_ALL, + disposition: FILE_OPEN, + options: NTCREATEFILE_CREATE_OPTIONS(FILE_OPEN_REPARSE_POINT.0 | FILE_OPEN_FOR_BACKUP_INTENT.0), + attributes: FILE_FLAGS_AND_ATTRIBUTES(0), + descriptor: None, + }; + let handle = match nt::create(dir, name, &request) { + Ok(handle) => handle, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(open_error(error)), + }; + let links = nt::link_count(handle.as_handle()).map_err(|error| Error::io(path, "read link count", error))?; + Ok(Some(u64::from(links))) } /// Returns the kind of a listed entry: a reparse point that stands for another name is a link, and any other reparse diff --git a/testsuite/tests/private_fs/unix.rs b/testsuite/tests/private_fs/unix.rs index 5e067d037e..3e259da69e 100644 --- a/testsuite/tests/private_fs/unix.rs +++ b/testsuite/tests/private_fs/unix.rs @@ -793,6 +793,36 @@ fn discard_removes_only_the_file_it_was_given() { assert!(scratch.path.join("key.tmp").exists()); } +// A listing that couldn't tell a file with another hard link would let a caller list it as its own, when the caller can't +// open the file to check it. +// oracle: intent crates/private-fs/INTENT.md §mechanisms +#[test] +fn entries_report_each_file_s_hard_links() { + let scratch = Scratch::new(); + let base = scratch.base(); + drop(base.create_file("linked", &rule(), FileOptions::new()).unwrap()); + drop(base.create_file("single", &rule(), FileOptions::new()).unwrap()); + fs::hard_link(scratch.path.join("linked"), scratch.path.join("other")).unwrap(); + // The count is read without opening the file, so a file nobody may open is counted too. + set_mode(&scratch.path.join("linked"), 0o000); + + let mut entries: Vec<_> = base + .entries() + .unwrap() + .into_iter() + .map(|entry| (entry.name.into_string().unwrap(), entry.links)) + .collect(); + entries.sort(); + assert_eq!( + entries, + [ + ("linked".to_owned(), 2), + ("other".to_owned(), 2), + ("single".to_owned(), 1), + ] + ); +} + // A listing that followed links would report a link to a directory as a directory. // oracle: mutant a listing that reports a symbolic link as something other than a link #[test] diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index 1940829eb3..b7f537b3d6 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -813,6 +813,40 @@ fn entries_lists_each_entry_with_its_kind() -> anyhow::Result<()> { Ok(()) } +// oracle: intent crates/private-fs/INTENT.md §mechanisms +#[test] +fn entries_report_each_file_s_hard_links() -> anyhow::Result<()> { + // Catches a listing that can't tell a file with another hard link, so that a caller lists it as its own when it + // can't open the file to check it. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + drop(base.create_file("linked", &user_rule(), FileOptions::new())?); + drop(base.create_file("single", &user_rule(), FileOptions::new())?); + std::fs::hard_link(test.path().join("linked"), test.path().join("other"))?; + // The count is read without opening the file for its content or its DACL, so a file whose DACL grants the user + // nothing is counted too. + set_dacl( + &test.path().join("linked"), + &[(users(), 0x40, 0)], + InheritableAclKind::Protected, + )?; + let mut entries: Vec<_> = base + .entries()? + .into_iter() + .map(|entry| (entry.name.into_string().expect("UTF-16 name"), entry.links)) + .collect(); + entries.sort(); + assert_eq!( + entries, + [ + ("linked".to_owned(), 2), + ("other".to_owned(), 2), + ("single".to_owned(), 1) + ] + ); + Ok(()) +} + // oracle: mutant remove_file reporting the inverse of whether the file existed #[test] fn remove_file_reports_whether_the_file_existed() -> anyhow::Result<()> { From 2c2fd2778364d6bd8359f32b5ab3c3116186337a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 22:15:33 +0900 Subject: [PATCH 52/60] fix(private-fs): reset a narrowed Unix mode in repair, not in checks An exact check on Linux and macOS ignores the owner's own bits again, since an owner can always change them back, so a narrowed mode such as 0500 still counts as private. A repair resets it to the rule's mode instead, and writes nothing to a file or directory that already has the rule's owner and mode and no extended ACL, so repairing at every start doesn't touch a correct one. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/unix.rs | 65 ++++++++++++++++++------------ testsuite/tests/private_fs/unix.rs | 30 +++++++++++--- 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/crates/private-fs/src/unix.rs b/crates/private-fs/src/unix.rs index af0d8507d5..999fed8ff6 100644 --- a/crates/private-fs/src/unix.rs +++ b/crates/private-fs/src/unix.rs @@ -3,8 +3,6 @@ //! The owner of a file or directory can always change its mode, so it always has full access, and root can access //! everything whatever the mode says. //! A rule therefore reduces to an owner, mode `0700` for a directory or `0600` for a file, and no extended ACL. -//! A check refuses group and other bits, and under an exact rule also owner bits other than the rule's, which leave the -//! owner less than its full access until it changes the mode back. use std::ffi::{CStr, CString, OsString}; use std::fs::{File, Metadata, OpenOptions, Permissions}; @@ -49,6 +47,23 @@ enum Kind { File, } +impl Kind { + /// The permission bits a rule maps to: owner-only, `0700` for a directory and `0600` for a file. + fn mode(self) -> u32 { + match self { + Kind::Directory => DIR_MODE, + Kind::File => FILE_MODE, + } + } + + fn inspect_step(self) -> &'static str { + match self { + Kind::Directory => "inspect directory", + Kind::File => "inspect file", + } + } +} + /// The accounts that may own a directory above a base directory, or be able to rename or replace it. pub(crate) struct Allowed { /// The effective user ID, when the rule names [`Account::CurrentUser`]. @@ -408,12 +423,10 @@ pub(crate) fn repair_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<( /// Brings the owner, mode and ACLs of the open directory or file `file` into line with `rule`, then checks it. fn repair_item(file: &File, path: &Utf8Path, rule: &Rule, kind: Kind) -> Result<()> { - let (mode, step) = match kind { - Kind::Directory => (DIR_MODE, "inspect directory"), - Kind::File => (FILE_MODE, "inspect file"), - }; let euid = effective_uid(); - let metadata = file.metadata().map_err(|error| Error::io(path, step, error))?; + let metadata = file + .metadata() + .map_err(|error| Error::io(path, kind.inspect_step(), error))?; // A repair changes nothing that the check would refuse whatever the repair did, such as another hard link, whose // mode the change would also give to the other name. match kind { @@ -429,11 +442,21 @@ fn repair_item(file: &File, path: &Utf8Path, rule: &Rule, kind: Kind) -> Result< euid }; check_rule(path, rule, owner, euid, kind)?; + let has_extended_acl = match os::check_no_extended_acl(file, path, kind == Kind::Directory) { + Ok(()) => false, + Err(error) if error.is_refused() => true, + Err(error) => return Err(error), + }; + // An item that already has the rule's owner and mode, and no extended ACL, is left unwritten, so a repair on every + // start doesn't touch its change time; the setuid, setgid and sticky bits give no account access and are ignored. + if owner == metadata.uid() && metadata.mode() & 0o777 == kind.mode() && !has_extended_acl { + return check_item(file, path, rule, kind); + } if owner != metadata.uid() { std::os::unix::fs::fchown(file, Some(owner), None).map_err(|error| Error::io(path, "change owner", error))?; } os::remove_extended_acl(file, path)?; - file.set_permissions(Permissions::from_mode(mode)) + file.set_permissions(Permissions::from_mode(kind.mode())) .map_err(|error| Error::io(path, "change mode", error))?; check_item(file, path, rule, kind) } @@ -614,11 +637,11 @@ fn replaceable(mode: u32, acl: &os::AncestorAcl, allowed: &Allowed) -> bool { /// Gives the directory or file that was just created as `file` its rule's mode, then checks it. fn protect_created(file: &File, path: &Utf8Path, rule: &Rule, kind: Kind) -> Result<()> { - let (expected, step) = match kind { - Kind::Directory => (DIR_MODE, "inspect directory"), - Kind::File => (FILE_MODE, "inspect file"), - }; - let mode = file.metadata().map_err(|error| Error::io(path, step, error))?.mode(); + let expected = kind.mode(); + let mode = file + .metadata() + .map_err(|error| Error::io(path, kind.inspect_step(), error))? + .mode(); // UNTESTED: a umask that removes owner bits doesn't leave the new directory or file without the owner's access. // The umask is process-wide, so a test can't set one without racing every other test. // When changing this, keep the mode change before the check. @@ -630,11 +653,9 @@ fn protect_created(file: &File, path: &Utf8Path, rule: &Rule, kind: Kind) -> Res } fn check_item(file: &File, path: &Utf8Path, rule: &Rule, kind: Kind) -> Result<()> { - let step = match kind { - Kind::Directory => "inspect directory", - Kind::File => "inspect file", - }; - let metadata = file.metadata().map_err(|error| Error::io(path, step, error))?; + let metadata = file + .metadata() + .map_err(|error| Error::io(path, kind.inspect_step(), error))?; match kind { Kind::Directory if !metadata.is_dir() => return Err(Error::refused(path, Refusal::NotADirectory)), Kind::File if !metadata.is_file() => return Err(Error::refused(path, Refusal::NotAFile)), @@ -650,14 +671,6 @@ fn check_item(file: &File, path: &Utf8Path, rule: &Rule, kind: Kind) -> Result<( if metadata.mode() & GROUP_OTHER_BITS != 0 { return Err(Error::refused(path, Refusal::Access)); } - // The setuid, setgid and sticky bits give no account access, so even an exact rule ignores them. - let expected = match kind { - Kind::Directory => DIR_MODE, - Kind::File => FILE_MODE, - }; - if rule.exact && metadata.mode() & 0o777 != expected { - return Err(Error::refused(path, Refusal::Access)); - } os::check_no_extended_acl(file, path, kind == Kind::Directory) } diff --git a/testsuite/tests/private_fs/unix.rs b/testsuite/tests/private_fs/unix.rs index 3e259da69e..2051c78679 100644 --- a/testsuite/tests/private_fs/unix.rs +++ b/testsuite/tests/private_fs/unix.rs @@ -222,24 +222,42 @@ fn an_existing_directory_open_to_others_is_refused_until_repaired() { assert!(base.open_dir("shared", &rule(), Share::Shared).unwrap().is_some()); } -// Catches an exact check that accepted a mode taking access away from the owner, which a repair then never resets. -// oracle: intent crates/private-fs/INTENT.md §vocabulary +// Catches a repair that kept a mode the owner narrowed, which a check accepts since the owner can change it back. +// oracle: intent crates/private-fs/INTENT.md §paths #[test] -fn an_exact_rule_requires_the_rule_s_mode_and_a_repair_restores_it() { +fn a_repair_restores_a_mode_the_owner_narrowed() { let scratch = Scratch::new(); let base = scratch.base(); drop(base.create_dir("dir", &rule(), Existing::Check).unwrap()); let dir = scratch.path.join("dir"); set_mode(&dir, 0o500); - assert!(base.open_dir("dir", &rule(), Share::Shared).unwrap().is_some()); - let error = base.open_dir("dir", &rule().exact(), Share::Shared).unwrap_err(); - assert!(matches!(refusal(&error), Some(Refusal::Access)), "{error:?}"); + assert!(base.open_dir("dir", &rule().exact(), Share::Shared).unwrap().is_some()); base.create_dir("dir", &rule().exact(), Existing::Repair).unwrap(); assert_eq!(mode(&dir), 0o700); } +// A repair at every start that rewrote a correct directory would change it each time for nothing. +// oracle: mutant a repair that changes the mode or ACL of a directory that already has the rule's +#[test] +fn a_repair_writes_nothing_to_a_directory_that_has_the_rule() { + let scratch = Scratch::new(); + let base = scratch.base(); + drop(base.create_dir("dir", &rule(), Existing::Check).unwrap()); + let dir = scratch.path.join("dir"); + let ctime = |path: &Utf8Path| { + let metadata = fs::symlink_metadata(path).unwrap(); + (metadata.ctime(), metadata.ctime_nsec()) + }; + let before = ctime(&dir); + // Lets the clock pass a coarse kernel timestamp's tick, so that a write would show in the change time. + std::thread::sleep(std::time::Duration::from_millis(50)); + + base.create_dir("dir", &rule().exact(), Existing::Repair).unwrap(); + assert_eq!(ctime(&dir), before); +} + // Catches a repair that opens the directory first, which could never fix one whose mode shuts out its own owner. // oracle: intent crates/private-fs/INTENT.md §paths #[test] From 4a49d162ef3f7327bae31a7d876244bf3a9f3186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 22:25:47 +0900 Subject: [PATCH 53/60] fix(private-fs): leave a Windows item already in line with its rule unwritten A repair writes nothing when the item already passes the exact check with an allowed owner, so a repair at every start-up changes nothing on a correct item and needs no WRITE_DAC on it; an existing directory to repair is opened with what reading its owner and DACL needs, and the repair asks for WRITE_DAC only when it writes. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/windows/mod.rs | 14 ++++++++++++-- crates/private-fs/src/windows/security.rs | 22 +++++++++++++++++++++- testsuite/tests/private_fs/windows.rs | 20 ++++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index 29394d2b52..5c3fc2f5ab 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -185,9 +185,10 @@ pub(crate) fn create_dir(parent: &Dir, path: &Utf8Path, name: &str, rule: &Rule, } Existing::Repair => { // A directory whose DACL no longer grants the current user anything is still repairable by its owner, - // so it is opened with the repair's access only, and opened normally once repaired. + // so it is opened with what reading its owner and DACL needs, within the owner's implicit access, and + // opened normally once repaired; the repair itself asks for WRITE_DAC only when it writes. let request = Request { - access: REPAIR_ACCESS, + access: FILE_ACCESS_RIGHTS(READ_CONTROL.0 | FILE_READ_ATTRIBUTES.0), share: SHARE_ALL, disposition: FILE_OPEN, options: REPAIR_OPTIONS, @@ -477,6 +478,11 @@ fn repair(handle: BorrowedHandle<'_>, path: &Utf8Path, kind: Kind, policy: &Poli classify(handle, path, kind)?; let security = OwnerAndDacl::of_handle(handle).map_err(|error| Error::io(path, "read access control list", error))?; + // An item already in line with the rule, owner included, is left as it is: nothing is written, so a repair at every + // start-up changes nothing on a correct item and needs no WRITE_DAC on it. + if policy.check_exactly(&security, path, &administrators_member).is_ok() { + return Ok(()); + } let current_owner = security.owner(); let owner_allowed = match current_owner { Some(owner) => policy.owner_allowed(owner, path, &administrators_member)?, @@ -952,6 +958,10 @@ fn check_above(path: &Utf8Path, policy: &Policy) -> Result<()> { } // A folder on which a volume without a drive letter is mounted resolves to its own path, but opens the mounted // volume's root, so the folder's own owner and DACL can't be read through it, which fails the check. + // UNTESTED: a folder above a base on which a volume without a drive letter is mounted is refused. + // Mounting a volume on a folder needs elevation, which tests can't rely on, and `is_mount_folder` is tested alone; + // when changing this, keep asking for the path within the volume through the same READ_CONTROL-only handle, and + // keep refusing a volume's root reached below the root of a path. let in_volume = nt::path_in_volume(handle.as_handle()).map_err(unreadable)?; if is_mount_folder(&in_volume, path) { return Err(unreadable(io::Error::new( diff --git a/crates/private-fs/src/windows/security.rs b/crates/private-fs/src/windows/security.rs index afd15f1ab6..59693c00db 100644 --- a/crates/private-fs/src/windows/security.rs +++ b/crates/private-fs/src/windows/security.rs @@ -191,6 +191,26 @@ impl Policy { /// Checks the owner and DACL of an item against the rule. pub(super) fn check(&self, security: &OwnerAndDacl, path: &Utf8Path, membership: &Membership<'_>) -> Result<()> { + self.check_with(self.exact, security, path, membership) + } + + /// Checks the owner and DACL of an item exactly against the rule, whether or not the rule is exact. + pub(super) fn check_exactly( + &self, + security: &OwnerAndDacl, + path: &Utf8Path, + membership: &Membership<'_>, + ) -> Result<()> { + self.check_with(true, security, path, membership) + } + + fn check_with( + &self, + exact: bool, + security: &OwnerAndDacl, + path: &Utf8Path, + membership: &Membership<'_>, + ) -> Result<()> { let refused = |refusal| Error::refused(path, refusal); let owner = security.owner.as_ref().ok_or_else(|| refused(Refusal::Owner))?; if !self.owner_allowed(owner, path, membership)? { @@ -237,7 +257,7 @@ impl Policy { if owner_access != Some(Access::Full) && !owner_rights.iter().any(applies_to_item) { return Err(refused(Refusal::Access)); } - if self.exact { + if exact { let mut expected = self.entries(); expected.sort(); others.sort(); diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index b7f537b3d6..b5b323421b 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -499,6 +499,26 @@ fn a_repair_restores_the_rule_without_changing_what_lies_below() -> anyhow::Resu Ok(()) } +// oracle: mutant a repair that writes the owner and DACL of a directory already in line with its rule +#[test] +fn a_repair_of_a_directory_already_in_line_writes_nothing() -> anyhow::Result<()> { + // Catches a repair that writes the DACL every time, which a directory whose rule gives its owner read and write + // but not WRITE_DAC refuses, so a repair at every start-up would fail on a correct directory. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + let rule = Rule::new() + .owner(Account::CurrentUser) + .grant(Account::CurrentUser, Access::ReadWrite) + .exact(); + let child = base.create_dir("child", &rule, Existing::Check)?; + let before = security(&test.path().join("child"))?; + child.repair(&rule)?; + drop(child); + drop(base.create_dir("child", &rule, Existing::Repair)?); + assert_eq!(security(&test.path().join("child"))?, before); + Ok(()) +} + // oracle: intent crates/private-fs/INTENT.md §paths #[test] fn a_repair_restores_a_directory_whose_dacl_grants_its_owner_nothing() -> anyhow::Result<()> { From 7f9f83ea368434f4b91fe82213be44a8a8e15e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 22:29:50 +0900 Subject: [PATCH 54/60] fix(private-fs): count the links of a Windows entry whose name isn't valid UTF-16 Reading an entry's link count converted its listed name to a string, so a name with an unpaired surrogate, which NTFS allows, failed every listing of its directory. The entry is now opened with its name as listed. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/windows/mod.rs | 8 ++++---- crates/private-fs/src/windows/nt.rs | 13 +++++++++---- testsuite/tests/private_fs/windows.rs | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index 5c3fc2f5ab..122a462540 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -12,6 +12,7 @@ use std::ffi::OsStr; use std::fs::{File, OpenOptions}; use std::io; use std::marker::PhantomData; +use std::os::windows::ffi::OsStrExt as _; use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; use std::os::windows::io::{AsHandle as _, BorrowedHandle, FromRawHandle as _, OwnedHandle}; use std::sync::{Mutex, PoisonError}; @@ -395,9 +396,8 @@ pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { /// entry's own DACL says, without following a reparse point. fn entry_links(dir: BorrowedHandle<'_>, path: &Utf8Path, name: &OsStr) -> Result> { let open_error = |error| Error::io(path, "open entry", error); - let name = name - .to_str() - .ok_or_else(|| open_error(io::Error::from(io::ErrorKind::InvalidData)))?; + // A listed name need not be valid UTF-16, so it is opened as listed. + let name: Vec = name.encode_wide().collect(); let request = Request { // Not SYNCHRONIZE, which the entry's DACL may not grant, so the handle is asynchronous, which reading // information doesn't mind. @@ -408,7 +408,7 @@ fn entry_links(dir: BorrowedHandle<'_>, path: &Utf8Path, name: &OsStr) -> Result attributes: FILE_FLAGS_AND_ATTRIBUTES(0), descriptor: None, }; - let handle = match nt::create(dir, name, &request) { + let handle = match nt::create_wide(dir, &name, &request) { Ok(handle) => handle, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(open_error(error)), diff --git a/crates/private-fs/src/windows/nt.rs b/crates/private-fs/src/windows/nt.rs index 627d11c88a..5a54faa064 100644 --- a/crates/private-fs/src/windows/nt.rs +++ b/crates/private-fs/src/windows/nt.rs @@ -70,16 +70,21 @@ pub(super) fn win32_error(error: windows::core::Error) -> io::Error { /// Opens or creates the entry `name` of the directory `root`, without resolving any path but `name`. pub(super) fn create(root: BorrowedHandle<'_>, name: &str, request: &Request<'_>) -> io::Result { - create_in(raw(root), name, request) + create_in(raw(root), name.encode_utf16().collect(), request) } /// Opens or creates the object at the NT path `path`, such as `\??\C:\Users`. pub(super) fn create_absolute(path: &str, request: &Request<'_>) -> io::Result { - create_in(HANDLE::default(), path, request) + create_in(HANDLE::default(), path.encode_utf16().collect(), request) } -fn create_in(root: HANDLE, name: &str, request: &Request<'_>) -> io::Result { - let mut wide: Vec = name.encode_utf16().collect(); +/// Opens or creates the entry of the directory `root` named by the UTF-16 `name`, which need not be valid UTF-16, as +/// NTFS names need not be. +pub(super) fn create_wide(root: BorrowedHandle<'_>, name: &[u16], request: &Request<'_>) -> io::Result { + create_in(raw(root), name.to_vec(), request) +} + +fn create_in(root: HANDLE, mut wide: Vec, request: &Request<'_>) -> io::Result { let length = wide .len() .checked_mul(size_of::()) diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index b5b323421b..49531aaefa 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -867,6 +867,24 @@ fn entries_report_each_file_s_hard_links() -> anyhow::Result<()> { Ok(()) } +// oracle: spec MS-FSCC §2.1.5.2 Filename +#[test] +fn entries_list_a_name_that_is_not_valid_utf16() -> anyhow::Result<()> { + // Catches a listing that fails on a name with an unpaired surrogate, which NTFS allows, so that one stray name + // stops every listing of the directory. + use std::os::windows::ffi::OsStringExt as _; + + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + let name = std::ffi::OsString::from_wide(&[u16::from(b'x'), 0xD800]); + std::fs::File::create(test.path().as_std_path().join(&name))?; + let entries = base.entries()?; + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, name); + assert_eq!(entries[0].links, 1); + Ok(()) +} + // oracle: mutant remove_file reporting the inverse of whether the file existed #[test] fn remove_file_reports_whether_the_file_existed() -> anyhow::Result<()> { From 2d7ef824ab736893749190558167c62be5059daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 22:44:42 +0900 Subject: [PATCH 55/60] fix(agent): always repair the identity directories, and list keys by link count Provisioning now repairs every identity directory rather than only one that fails its check, so that a directory whose mode is narrower than the rule's, such as 0500, is reset too; a repair leaves a directory already in line with its rule unwritten. Listing device keys now leaves out a key file with another hard link from the link count the listing reports, without opening the file, so that a hard-linked key file the caller can't open is left out too. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/agent-identity/src/private_file.rs | 45 ++++++++++++++++----- crates/device-key/src/file.rs | 48 ++++++++++++++++------- 2 files changed, 69 insertions(+), 24 deletions(-) diff --git a/crates/agent-identity/src/private_file.rs b/crates/agent-identity/src/private_file.rs index 4f7c50ff82..3ce62115cb 100644 --- a/crates/agent-identity/src/private_file.rs +++ b/crates/agent-identity/src/private_file.rs @@ -201,14 +201,11 @@ pub(crate) fn provision_directories(data_dir: &Utf8Path, grant_current_user: boo return Ok(data); fn provision(parent: &Dir, name: &str, rule: &Rule) -> anyhow::Result { - // Repairing only a directory that fails its rule leaves a correct one unwritten. - // A directory whose access denies the agent fails to open rather than failing the rule, and is repaired too, - // since repair needs only the access its owner always keeps. - match parent.create_dir(name, rule, Existing::Check) { - Err(error) if fails_trust_check(&error) => parent.create_dir(name, rule, Existing::Repair), - created => created, - } - .context("provision identity directory") + // A repair changes nothing on a directory that already follows its rule, and also covers one whose access + // denies the agent or whose mode is narrower than the rule's. + parent + .create_dir(name, rule, Existing::Repair) + .context("provision identity directory") } } @@ -799,6 +796,30 @@ mod unix_tests { Ok(()) } + // oracle: intent crates/agent-identity/INTENT.md §Local state + #[test] + fn provisioning_resets_an_identity_directory_narrowed_to_read_and_search() -> anyhow::Result<()> { + // SAFETY: Reading the process's effective UID has no preconditions. + if unsafe { libc::geteuid() } == 0 { + writeln!( + io::stderr(), + "SKIP: provisioning_resets_an_identity_directory_narrowed_to_read_and_search needs an account other \ + than root" + )?; + return Ok(()); + } + let directory = TestDir::provisioned()?; + let identity = directory.0.join("identity"); + fs::set_permissions(&identity, fs::Permissions::from_mode(0o500))?; + + let provisioned = prepare_directories(&directory.0, true); + // A failed reset would leave a directory the test can't remove. + let _ = fs::set_permissions(&identity, fs::Permissions::from_mode(0o700)); + + provisioned?; + Ok(()) + } + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files #[test] fn a_pending_file_owned_by_another_account_is_ignored() -> anyhow::Result<()> { @@ -982,7 +1003,13 @@ mod windows_tests { ); // oracle: mutant missing parents of the data directory created with inherited access, which loses the refusal of // a link among them - assert!(acl::protected_dacl_of(&directory.0.join("parent"))?.is_some()); + assert_eq!( + acl::protected_dacl_of(&directory.0.join("parent"))?, + Some(acl::sorted(&[ + full(&acl::system_sid()?, INHERITED), + full(&acl::user_sid()?, INHERITED) + ])) + ); Ok(()) } diff --git a/crates/device-key/src/file.rs b/crates/device-key/src/file.rs index 71607b44e8..958a65c4e8 100644 --- a/crates/device-key/src/file.rs +++ b/crates/device-key/src/file.rs @@ -168,28 +168,18 @@ pub(super) fn list(dir: &Utf8Path, prefix: &str) -> anyhow::Result> .entries() .context("list key directory")? .into_iter() - .filter(|entry| entry.kind == EntryKind::File) + // `crates/device-key/INTENT.md` ("Operations"): listing never returns a link, and a file with another hard link is + // one; any other key that breaks the access rule is still listed, so the listing reads only the link count. + .filter(|entry| entry.kind == EntryKind::File && entry.links <= 1) .filter_map(|entry| { - let file_name = entry.name.to_str()?; - let name = file_name.strip_suffix(KEY_FILE_SUFFIX)?; - (name_matches_prefix(name, prefix) && !is_hard_linked(&dir, file_name)).then(|| name.to_owned()) + let name = entry.name.to_str()?.strip_suffix(KEY_FILE_SUFFIX)?; + name_matches_prefix(name, prefix).then(|| name.to_owned()) }) .collect::>(); names.sort_unstable(); Ok(names) } -/// Reports whether the key file `file_name` has another hard link, which makes it a link rather than a key. -/// -/// `crates/device-key/INTENT.md` ("Operations"): listing never returns a link, but returns a key that breaks the access -/// rule, so only a refusal for a link excludes a key file, and any other failure to check it leaves it listed. -fn is_hard_linked(dir: &Dir, file_name: &str) -> bool { - matches!( - dir.open_file(file_name, &key_file_rule(), FileOptions::new()), - Err(error) if matches!(error.kind(), private_fs::ErrorKind::Refused(private_fs::Refusal::Link)) - ) -} - /// Opens the key directory, or returns `None` when it doesn't exist. fn open_key_directory(dir: &Utf8Path) -> anyhow::Result> { // An empty path would name the current directory, which holds no keys. @@ -811,6 +801,7 @@ mod tests { .find_map(|cause| cause.downcast_ref::()) .with_context(|| format!("{operation}: the failure is not a refusal: {error:#}"))?; assert!(refusal.is_refused(), "{operation}: {error:#}"); + // oracle: mutant an error that doesn't name the lock file, which leaves an operator unable to find it assert!(format!("{error:#}").contains(LOCK_NAME), "{operation}: {error:#}"); } Ok(()) @@ -859,6 +850,33 @@ mod tests { Ok(()) } + // oracle: intent crates/device-key/INTENT.md §Operations + #[cfg(unix)] + #[test] + fn a_hard_linked_key_file_the_caller_cannot_open_is_not_listed() -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt as _; + + // SAFETY: Reading the process's effective UID has no preconditions. + if unsafe { libc::geteuid() } == 0 { + writeln!( + std::io::stderr(), + "SKIP: a_hard_linked_key_file_the_caller_cannot_open_is_not_listed needs an account other than root" + )?; + return Ok(()); + } + let directory = KeyDirectory::new()?; + let backend = directory.backend(); + let linked = new_key_name(TEST_PREFIX); + let kept = new_key_name(TEST_PREFIX); + generate(&backend, &linked)?; + generate(&backend, &kept)?; + fs::hard_link(directory.key_path(&linked), directory.0.join("elsewhere"))?; + fs::set_permissions(directory.key_path(&linked), fs::Permissions::from_mode(0o000))?; + + assert_eq!(list(&backend, TEST_PREFIX)?, [kept]); + Ok(()) + } + // oracle: intent crates/device-key/INTENT.md §Operations #[test] fn a_key_file_readable_by_other_accounts_is_still_listed() -> anyhow::Result<()> { From 574820114981be82d62c0d3ba2a722bd2c4b7cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 23:13:17 +0900 Subject: [PATCH 56/60] test(agent): check that provisioning resets a narrowed identity directory's mode The test of an identity directory narrowed to 0500 now asserts that provisioning leaves it 0700, since a check alone accepts a narrowed mode and only the repair resets it. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/agent-identity/src/private_file.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/agent-identity/src/private_file.rs b/crates/agent-identity/src/private_file.rs index 3ce62115cb..ba6258a909 100644 --- a/crates/agent-identity/src/private_file.rs +++ b/crates/agent-identity/src/private_file.rs @@ -813,10 +813,12 @@ mod unix_tests { fs::set_permissions(&identity, fs::Permissions::from_mode(0o500))?; let provisioned = prepare_directories(&directory.0, true); + let mode = fs::metadata(&identity).map(|metadata| metadata.permissions().mode() & 0o777); // A failed reset would leave a directory the test can't remove. let _ = fs::set_permissions(&identity, fs::Permissions::from_mode(0o700)); provisioned?; + assert_eq!(mode?, 0o700); Ok(()) } From e3fe73e0f4e1f5b0bbf9693a07a3169360a9561b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 23:26:56 +0900 Subject: [PATCH 57/60] fix(private-fs): leave out a Windows entry pending deletion when listing An entry another program marked for deletion with the legacy semantics stays listed until its last handle closes, but opening it to read its link count fails with STATUS_DELETE_PENDING, which the Win32 mapping makes access denied and failed the whole listing. Such an entry is now left out, as one removed since it was listed is. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/windows/mod.rs | 9 ++++--- crates/private-fs/src/windows/nt.rs | 35 ++++++++++++++++++------- testsuite/tests/private_fs/windows.rs | 37 ++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index 122a462540..be0fb30cab 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -390,7 +390,8 @@ pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { Ok(entries) } -/// Returns the number of hard links of the entry `name` of `dir`, or `None` when it doesn't exist any more. +/// Returns the number of hard links of the entry `name` of `dir`, or `None` when it doesn't exist any more or is pending +/// deletion. /// /// The entry is opened with FILE_READ_ATTRIBUTES only, which the directory's FILE_LIST_DIRECTORY grants whatever the /// entry's own DACL says, without following a reparse point. @@ -408,8 +409,10 @@ fn entry_links(dir: BorrowedHandle<'_>, path: &Utf8Path, name: &OsStr) -> Result attributes: FILE_FLAGS_AND_ATTRIBUTES(0), descriptor: None, }; - let handle = match nt::create_wide(dir, &name, &request) { - Ok(handle) => handle, + let handle = match nt::open_listed(dir, &name, &request) { + Ok(Some(handle)) => handle, + // Pending deletion, the entry goes once its last handle closes, as one removed since it was listed has. + Ok(None) => return Ok(None), Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(open_error(error)), }; diff --git a/crates/private-fs/src/windows/nt.rs b/crates/private-fs/src/windows/nt.rs index 5a54faa064..f6fa5ded1b 100644 --- a/crates/private-fs/src/windows/nt.rs +++ b/crates/private-fs/src/windows/nt.rs @@ -16,8 +16,9 @@ use windows::Wdk::Storage::FileSystem::{ NtSetInformationFile, }; use windows::Win32::Foundation::{ - ERROR_NO_MORE_FILES, HANDLE, NTSTATUS, OBJ_CASE_INSENSITIVE, RtlNtStatusToDosError, STATUS_INVALID_DEVICE_REQUEST, - STATUS_INVALID_INFO_CLASS, STATUS_INVALID_PARAMETER, STATUS_NOT_IMPLEMENTED, STATUS_NOT_SUPPORTED, UNICODE_STRING, + ERROR_NO_MORE_FILES, HANDLE, NTSTATUS, OBJ_CASE_INSENSITIVE, RtlNtStatusToDosError, STATUS_DELETE_PENDING, + STATUS_INVALID_DEVICE_REQUEST, STATUS_INVALID_INFO_CLASS, STATUS_INVALID_PARAMETER, STATUS_NOT_IMPLEMENTED, + STATUS_NOT_SUPPORTED, UNICODE_STRING, }; use windows::Win32::Security::SECURITY_DESCRIPTOR; use windows::Win32::Storage::FileSystem::{ @@ -78,13 +79,29 @@ pub(super) fn create_absolute(path: &str, request: &Request<'_>) -> io::Result, name: &[u16], request: &Request<'_>) -> io::Result { - create_in(raw(root), name.to_vec(), request) +/// Opens the listed entry of the directory `root` named by the UTF-16 `name`, which need not be valid UTF-16, as NTFS +/// names need not be, or returns `None` when the entry is pending deletion. +/// +/// An entry another program marked for deletion with the legacy semantics stays listed until its last handle closes, +/// but can't be opened: NtCreateFile fails with STATUS_DELETE_PENDING, which the Win32 mapping makes access denied. +pub(super) fn open_listed( + root: BorrowedHandle<'_>, + name: &[u16], + request: &Request<'_>, +) -> io::Result> { + match create_status(raw(root), name.to_vec(), request)? { + Ok(handle) => Ok(Some(handle)), + Err(status) if status == STATUS_DELETE_PENDING => Ok(None), + Err(status) => Err(status_error(status)), + } } -fn create_in(root: HANDLE, mut wide: Vec, request: &Request<'_>) -> io::Result { +fn create_in(root: HANDLE, wide: Vec, request: &Request<'_>) -> io::Result { + create_status(root, wide, request)?.map_err(status_error) +} + +/// Calls NtCreateFile, and returns its failure as the NTSTATUS itself. +fn create_status(root: HANDLE, mut wide: Vec, request: &Request<'_>) -> io::Result> { let length = wide .len() .checked_mul(size_of::()) @@ -125,10 +142,10 @@ fn create_in(root: HANDLE, mut wide: Vec, request: &Request<'_>) -> io::Res }; // NT_SUCCESS: success and informational codes are not negative; warnings and errors are. if status.0 < 0 { - return Err(status_error(status)); + return Ok(Err(status)); } // SAFETY: NtCreateFile succeeded, so the handle is open and nothing else owns it. - Ok(unsafe { OwnedHandle::from_raw_handle(handle.0) }) + Ok(Ok(unsafe { OwnedHandle::from_raw_handle(handle.0) })) } /// Reads one fixed-size information class of `handle`. diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index 49531aaefa..a7307b2f19 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -28,7 +28,10 @@ use win_api_wrappers::raw::Win32::Security::{ self, ACE_FLAGS, DACL_SECURITY_INFORMATION, SecurityImpersonation, TOKEN_ADJUST_PRIVILEGES, TOKEN_DUPLICATE, TOKEN_IMPERSONATE, TOKEN_QUERY, TokenImpersonation, }; -use win_api_wrappers::raw::Win32::Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT}; +use win_api_wrappers::raw::Win32::Storage::FileSystem::{ + FILE_DISPOSITION_INFO, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FileDispositionInfo, + SetFileInformationByHandle, +}; use win_api_wrappers::security::acl::{ Acl, ExplicitAccess, InheritableAcl, InheritableAclKind, Trustee, set_named_security_info, }; @@ -885,6 +888,38 @@ fn entries_list_a_name_that_is_not_valid_utf16() -> anyhow::Result<()> { Ok(()) } +// oracle: mutant a listing that fails on an entry pending deletion instead of leaving it out +#[test] +fn entries_leave_out_an_entry_pending_deletion() -> anyhow::Result<()> { + // Catches a listing that fails as a whole on an entry another program marked for deletion with the legacy semantics + // and still holds open, which stays listed by name but can't be opened. + let test = TestDir::new(); + let base = Dir::create_base(test.path(), &user_rule())?; + drop(base.create_file("kept", &user_rule(), FileOptions::new())?); + drop(base.create_file("pending", &user_rule(), FileOptions::new())?); + let pending = test.path().join("pending"); + let holder = std::fs::File::open(&pending)?; + let deleting = std::fs::OpenOptions::new() + .access_mode(0x0001_0000) // DELETE + .share_mode(0x7) + .open(&pending)?; + let disposition = FILE_DISPOSITION_INFO { DeleteFile: true }; + // SAFETY: The handle is open with DELETE and the disposition structure stays live for the call. + unsafe { + SetFileInformationByHandle( + HANDLE(deleting.as_raw_handle()), + FileDispositionInfo, + (&raw const disposition).cast(), + u32::try_from(size_of::())?, + ) + }?; + drop(deleting); + let names: Vec<_> = base.entries()?.into_iter().map(|entry| entry.name).collect(); + drop(holder); + assert_eq!(names, ["kept"]); + Ok(()) +} + // oracle: mutant remove_file reporting the inverse of whether the file existed #[test] fn remove_file_reports_whether_the_file_existed() -> anyhow::Result<()> { From de1d633b1f394ed1f8f12ace3f6f64ba69fa7734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 23:35:50 +0900 Subject: [PATCH 58/60] fix(private-fs): list an entry whose link count can't be read Entry.links is now None when the count can't be read, instead of one such entry failing the whole listing: on Windows any failure to open the entry or read its count other than its absence, and on Linux and macOS a failed fstatat other than ENOENT, whose kind then comes from the directory entry. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/lib.rs | 4 ++- crates/private-fs/src/unix.rs | 22 ++++++++++++---- crates/private-fs/src/windows/mod.rs | 36 ++++++++++++++++++--------- testsuite/tests/private_fs/unix.rs | 6 ++--- testsuite/tests/private_fs/windows.rs | 10 ++++---- 5 files changed, 52 insertions(+), 26 deletions(-) diff --git a/crates/private-fs/src/lib.rs b/crates/private-fs/src/lib.rs index d4cbae2bb1..74654e9192 100644 --- a/crates/private-fs/src/lib.rs +++ b/crates/private-fs/src/lib.rs @@ -85,7 +85,9 @@ pub struct Entry { pub kind: EntryKind, /// The number of hard links of the entry, read without opening it for its content or its owner and access, so a /// caller can leave out a file with another hard link even when it can't open the file to check it. - pub links: u64, + /// + /// `None` when the count can't be read. + pub links: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/private-fs/src/unix.rs b/crates/private-fs/src/unix.rs index 999fed8ff6..4563d45478 100644 --- a/crates/private-fs/src/unix.rs +++ b/crates/private-fs/src/unix.rs @@ -389,16 +389,18 @@ pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { continue; } // fstatat without following a link needs only search access on the directory, not access to the entry. - let stat = match stat_at(&dir.file, name) { - Ok(stat) => stat, + let (kind, links) = match stat_at(&dir.file, name) { + Ok(stat) => (entry_kind(stat.st_mode), Some(link_count(stat.st_nlink))), // The entry was removed since it was read. Err(error) if error.kind() == io::ErrorKind::NotFound => continue, - Err(error) => return Err(list_error(error)), + // An entry that can't be inspected, as under a directory without search access, is still listed, with the + // kind the directory reports and no link count. + Err(_) => (dirent_kind(entry.d_type), None), }; entries.push(Entry { name: OsString::from_vec(name.to_bytes().to_vec()), - kind: entry_kind(stat.st_mode), - links: link_count(stat.st_nlink), + kind, + links, }); } } @@ -891,6 +893,16 @@ fn link_count(links: impl Into) -> u64 { links.into() } +/// Returns the kind a directory entry reports, `Other` when the file system doesn't report one. +fn dirent_kind(kind: u8) -> EntryKind { + match kind { + libc::DT_REG => EntryKind::File, + libc::DT_DIR => EntryKind::Directory, + libc::DT_LNK => EntryKind::Link, + _ => EntryKind::Other, + } +} + fn entry_kind(mode: libc::mode_t) -> EntryKind { match mode & libc::S_IFMT { libc::S_IFREG => EntryKind::File, diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index be0fb30cab..5855508889 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -377,9 +377,12 @@ pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { }; let mut entries = Vec::with_capacity(listed.len()); for (name, attributes, tag) in listed { - let Some(links) = entry_links(dir.handle(), path, &name)? else { - // The entry was removed since it was listed. - continue; + let links = match entry_links(dir.handle(), &name) { + Links::Count(links) => Some(links), + // An entry that can't be inspected is still listed, with no link count. + Links::Unreadable => None, + // The entry was removed since it was listed, or goes once its last handle closes. + Links::Gone => continue, }; entries.push(Entry { name, @@ -390,13 +393,20 @@ pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { Ok(entries) } -/// Returns the number of hard links of the entry `name` of `dir`, or `None` when it doesn't exist any more or is pending -/// deletion. +/// What reading the link count of a listed entry found. +enum Links { + Count(u64), + /// The entry exists, but its count can't be read. + Unreadable, + /// The entry doesn't exist any more, or is pending deletion. + Gone, +} + +/// Reads the number of hard links of the entry `name` of `dir`. /// /// The entry is opened with FILE_READ_ATTRIBUTES only, which the directory's FILE_LIST_DIRECTORY grants whatever the /// entry's own DACL says, without following a reparse point. -fn entry_links(dir: BorrowedHandle<'_>, path: &Utf8Path, name: &OsStr) -> Result> { - let open_error = |error| Error::io(path, "open entry", error); +fn entry_links(dir: BorrowedHandle<'_>, name: &OsStr) -> Links { // A listed name need not be valid UTF-16, so it is opened as listed. let name: Vec = name.encode_wide().collect(); let request = Request { @@ -412,12 +422,14 @@ fn entry_links(dir: BorrowedHandle<'_>, path: &Utf8Path, name: &OsStr) -> Result let handle = match nt::open_listed(dir, &name, &request) { Ok(Some(handle)) => handle, // Pending deletion, the entry goes once its last handle closes, as one removed since it was listed has. - Ok(None) => return Ok(None), - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(open_error(error)), + Ok(None) => return Links::Gone, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Links::Gone, + Err(_) => return Links::Unreadable, }; - let links = nt::link_count(handle.as_handle()).map_err(|error| Error::io(path, "read link count", error))?; - Ok(Some(u64::from(links))) + match nt::link_count(handle.as_handle()) { + Ok(links) => Links::Count(u64::from(links)), + Err(_) => Links::Unreadable, + } } /// Returns the kind of a listed entry: a reparse point that stands for another name is a link, and any other reparse diff --git a/testsuite/tests/private_fs/unix.rs b/testsuite/tests/private_fs/unix.rs index 2051c78679..b3f9c1d3fc 100644 --- a/testsuite/tests/private_fs/unix.rs +++ b/testsuite/tests/private_fs/unix.rs @@ -834,9 +834,9 @@ fn entries_report_each_file_s_hard_links() { assert_eq!( entries, [ - ("linked".to_owned(), 2), - ("other".to_owned(), 2), - ("single".to_owned(), 1), + ("linked".to_owned(), Some(2)), + ("other".to_owned(), Some(2)), + ("single".to_owned(), Some(1)), ] ); } diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs index a7307b2f19..9e3e1570ab 100644 --- a/testsuite/tests/private_fs/windows.rs +++ b/testsuite/tests/private_fs/windows.rs @@ -862,15 +862,15 @@ fn entries_report_each_file_s_hard_links() -> anyhow::Result<()> { assert_eq!( entries, [ - ("linked".to_owned(), 2), - ("other".to_owned(), 2), - ("single".to_owned(), 1) + ("linked".to_owned(), Some(2)), + ("other".to_owned(), Some(2)), + ("single".to_owned(), Some(1)) ] ); Ok(()) } -// oracle: spec MS-FSCC §2.1.5.2 Filename +// oracle: reference NTFS, which the test observes by creating the name #[test] fn entries_list_a_name_that_is_not_valid_utf16() -> anyhow::Result<()> { // Catches a listing that fails on a name with an unpaired surrogate, which NTFS allows, so that one stray name @@ -884,7 +884,7 @@ fn entries_list_a_name_that_is_not_valid_utf16() -> anyhow::Result<()> { let entries = base.entries()?; assert_eq!(entries.len(), 1); assert_eq!(entries[0].name, name); - assert_eq!(entries[0].links, 1); + assert_eq!(entries[0].links, Some(1)); Ok(()) } From adeaadb1b3c5a5a7324ab206e2446414a7d3d091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 26 Oct 2026 23:39:55 +0900 Subject: [PATCH 59/60] fix(agent): list a device key only when its file has one known link An entry whose link count can't be read could be a link, so listing skips it instead of failing. The repair docs say which mode a repair gives on Linux and macOS. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/device-key/src/file.rs | 5 +++-- crates/private-fs/src/lib.rs | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/device-key/src/file.rs b/crates/device-key/src/file.rs index 958a65c4e8..513a513a77 100644 --- a/crates/device-key/src/file.rs +++ b/crates/device-key/src/file.rs @@ -169,8 +169,9 @@ pub(super) fn list(dir: &Utf8Path, prefix: &str) -> anyhow::Result> .context("list key directory")? .into_iter() // `crates/device-key/INTENT.md` ("Operations"): listing never returns a link, and a file with another hard link is - // one; any other key that breaks the access rule is still listed, so the listing reads only the link count. - .filter(|entry| entry.kind == EntryKind::File && entry.links <= 1) + // one, so an entry whose link count can't be read is skipped too; any other key that breaks the access rule is + // still listed, so the listing reads only the link count. + .filter(|entry| entry.kind == EntryKind::File && entry.links == Some(1)) .filter_map(|entry| { let name = entry.name.to_str()?.strip_suffix(KEY_FILE_SUFFIX)?; name_matches_prefix(name, prefix).then(|| name.to_owned()) diff --git a/crates/private-fs/src/lib.rs b/crates/private-fs/src/lib.rs index 74654e9192..9de5d742e2 100644 --- a/crates/private-fs/src/lib.rs +++ b/crates/private-fs/src/lib.rs @@ -277,6 +277,8 @@ impl Dir { } /// Brings this directory into line with `rule`, without changing anything below it. + /// + /// On Linux and macOS the directory gets mode `0700`; a directory already in line is not written to. pub fn repair(&self, rule: &Rule) -> Result<()> { sys::repair_dir(&self.inner, &self.path, rule) } @@ -302,6 +304,7 @@ pub fn check_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { /// Brings an open file, at `path`, into line with `rule`, then checks it exactly. /// /// A file with more than one hard link is refused rather than changed, since a change through one name applies to all. +/// On Linux and macOS the file gets mode `0600`; a file already in line is not written to. pub fn repair_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { sys::repair_file(file, path, rule) } From 9b8b60380f6c5336903236d5e499a6aabf8aaecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 27 Oct 2026 00:01:11 +0900 Subject: [PATCH 60/60] docs(private-fs): say which listed entries go without a link count Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 --- crates/private-fs/src/unix.rs | 4 ++-- crates/private-fs/src/windows/mod.rs | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/private-fs/src/unix.rs b/crates/private-fs/src/unix.rs index 4563d45478..907be8a792 100644 --- a/crates/private-fs/src/unix.rs +++ b/crates/private-fs/src/unix.rs @@ -393,8 +393,8 @@ pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { Ok(stat) => (entry_kind(stat.st_mode), Some(link_count(stat.st_nlink))), // The entry was removed since it was read. Err(error) if error.kind() == io::ErrorKind::NotFound => continue, - // An entry that can't be inspected, as under a directory without search access, is still listed, with the - // kind the directory reports and no link count. + // An entry that can't be inspected, after an I/O error for example, is still listed, with the kind the + // directory reports and no link count. Err(_) => (dirent_kind(entry.d_type), None), }; entries.push(Entry { diff --git a/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs index 5855508889..9d7a252c03 100644 --- a/crates/private-fs/src/windows/mod.rs +++ b/crates/private-fs/src/windows/mod.rs @@ -406,6 +406,11 @@ enum Links { /// /// The entry is opened with FILE_READ_ATTRIBUTES only, which the directory's FILE_LIST_DIRECTORY grants whatever the /// entry's own DACL says, without following a reparse point. +/// +/// UNTESTED: an entry whose DACL grants the caller nothing still gets its link count, through the directory's +/// FILE_LIST_DIRECTORY. +/// No test lists such a file: a caller that keeps a key it can't open listed depends on it. +/// When changing this, keep asking for FILE_READ_ATTRIBUTES alone. fn entry_links(dir: BorrowedHandle<'_>, name: &OsStr) -> Links { // A listed name need not be valid UTF-16, so it is opened as listed. let name: Vec = name.encode_wide().collect(); @@ -421,7 +426,7 @@ fn entry_links(dir: BorrowedHandle<'_>, name: &OsStr) -> Links { }; let handle = match nt::open_listed(dir, &name, &request) { Ok(Some(handle)) => handle, - // Pending deletion, the entry goes once its last handle closes, as one removed since it was listed has. + // An entry pending deletion goes once its last handle closes, as one removed since it was listed has gone. Ok(None) => return Links::Gone, Err(error) if error.kind() == io::ErrorKind::NotFound => return Links::Gone, Err(_) => return Links::Unreadable,