diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 032cf2126..65a67b438 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 e1607cc28..92293e2f1 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]] @@ -8129,12 +8131,14 @@ dependencies = [ "hex", "ipnetwork", "jmux-proto", + "libc", "libsql", "mcp-proxy", "network-scanner", "network-scanner-proto", "nonempty", "picky", + "private-fs", "proxy-socks", "quinn", "rcgen", diff --git a/crates/agent-identity/Cargo.toml b/crates/agent-identity/Cargo.toml index 46a60c4da..e0d4171f1 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/INTENT.md b/crates/agent-identity/INTENT.md index 102fbe2d1..653bdf6be 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: diff --git a/crates/agent-identity/src/lib.rs b/crates/agent-identity/src/lib.rs index 012451bb8..a06131b32 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 ec6a6d765..000000000 --- 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 f43272a47..55d6e0418 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)) } @@ -352,22 +352,19 @@ 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 test_dir = private_file::TestDir::new()?; + 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)] { @@ -376,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(()) } @@ -396,23 +393,20 @@ 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 test_dir = private_file::TestDir::new()?; + 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":"{}"}}"#, @@ -429,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)] @@ -441,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(()) } @@ -460,47 +454,44 @@ 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 test_dir = private_file::TestDir::new()?; + 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))] @@ -522,26 +513,26 @@ 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(), "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)?; + 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 a5a24eae5..ba6258a90 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,181 @@ 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`, and +/// 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. +/// +/// [`crates/agent-identity/INTENT.md`]: ../INTENT.md +pub(crate) fn tree_rule(grant_current_user: bool) -> Rule { + tree_rule_owned_by(Account::CurrentUser, grant_current_user) +} + +/// 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() +} - use private_fs::DirChainError; +/// 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) +} - 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 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), grant_current_user) +} + +/// 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. +/// +/// 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( + 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 ``. +/// +/// 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. +/// +/// [`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_owned_by(Account::CurrentUser, grant_current_user), + )?; + return Ok(data); -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)?; + fn provision(parent: &Dir, name: &str, rule: &Rule) -> anyhow::Result { + // 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") } - Ok(()) } /// 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); } } @@ -135,224 +244,79 @@ 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 { - let untrusted = |source| { - anyhow::Error::new(UntrustedStoredIdentity { - path: path.to_owned(), - source, - }) +/// 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(None), + Err(error) => return Err(untrusted_stored_identity(path, error)), }; - 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"), - )); - } - }; - verify_authority_directory(path, &metadata, grant_current_user).map_err(untrusted)?; - Ok(true) + open_authority(&authorities, name, path, grant_current_user) } -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 -/// `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> { - 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) -} - -/// 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, - })), - } +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)) } -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) +/// 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, + 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. -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"); - } - } +fn create_authority(path: &Utf8Path, grant_current_user: bool) -> anyhow::Result { #[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" + grant_current_user || crate::windows::is_system()?, + "identity directory must be created by SYSTEM" ); - #[cfg(unix)] - { - sync_directory(path)?; - sync_directory(path.parent().context("authority directory has no parent")?)?; - } - Ok(()) -} - -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")?)?; - } - #[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(()) + 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 +325,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(()) -} - -pub(crate) fn open_read(path: &Utf8Path) -> anyhow::Result { - open_read_with_policy(path, None) + installed } -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 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)) } -#[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 +463,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 +488,1121 @@ 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")?; - ensure!( - file.metadata()?.permissions().mode() & 0o077 == 0, - "identity file volume does not enforce owner-only permissions" - ); - #[cfg(target_os = "macos")] - crate::macos_acl::ensure_private_file(&file)?; - Ok(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)] -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) +impl DropBox { + /// 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: 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), + } + } } -#[cfg(unix)] -fn replace(from: &Utf8Path, to: &Utf8Path) -> anyhow::Result<()> { - fs::rename(from, to).context("atomically replace identity file") +/// 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<()> { + submit_into_drop_box( + data_dir, + path, + contents, + &DropBox::provisioned_by(Account::System, false), + true, + ) } -#[cfg(unix)] -fn sync_directory(path: &Utf8Path) -> anyhow::Result<()> { - private_fs::sync_directory(path).context("sync identity directory") +/// 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, + backup: bool, +) -> anyhow::Result<()> { + 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")?; + // 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( + &staging, + &pending_file_rule(false), + FileOptions::new().share(Share::Exclusive), + ) + .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); + } + // The handle shares nothing, so the service can read the published file only once it is closed. + drop(file); + published.map(drop) } -#[cfg(test)] -mod tests { - use super::*; +/// 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)) +} - // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files - #[cfg(unix)] - #[test] - fn pending_files_do_not_require_service_ownership() { - // 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()); - } +/// 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); - // 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()); +/// 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, + share: Share, + started: Instant, +) -> anyhow::Result { + retry_sharing_violation(started, SUBMISSION_TIMEOUT, SUBMISSION_RETRY_INTERVAL, || { + Ok(parent.open_dir(name, rule, share)?) + })? + .ok_or_else(not_found) +} + +/// 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 { + match operation() { + Err(error) if is_sharing_violation(&error) => { + let remaining = timeout.saturating_sub(started.elapsed()); + if remaining.is_zero() { + return Err(error); + } + std::thread::sleep(remaining.min(interval)); + } + result => return result, } - assert!(verify_unix_file_permissions(0o600, 1, None).is_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()); +#[cfg(windows)] +pub(crate) 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 } +} - // oracle: intent crates/agent-identity/INTENT.md §Local state - #[test] - fn recognizes_only_own_temporary_file_names() { - let uuid = Uuid::new_v4(); - assert_eq!( - temporary_stem(&format!(".identity.json-{uuid}.tmp")), - Some(".identity.json") - ); +#[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 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"))? + .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::*; + + // oracle: intent crates/agent-identity/INTENT.md §Local state + #[test] + 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::*; + + impl TestDir { + fn provisioned() -> anyhow::Result { + let directory = Self::new()?; + prepare_directories(&directory.0, true)?; + Ok(directory) + } + + 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) + } + } + + /// 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. + 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 §Local state + #[test] + fn provisioning_gives_a_missing_data_directory_the_identity_rule() -> anyhow::Result<()> { + let directory = TestDir::new()?; + let data_dir = directory.0.join("parent").join("data"); + + 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(()) + } + + // 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); + 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(()) + } + + // 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::provisioned()?; + let path = directory.pending_file()?; + if !give_to_another_account(&path)? { + return 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::provisioned()?; + 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::provisioned()?; + 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 + #[test] + fn an_authority_directory_owned_by_another_account_fails_the_check() -> anyhow::Result<()> { + let directory = TestDir::provisioned()?; + 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 = open_authority_directory(&authority, true) + .err() + .context("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, FILE_GENERIC_READ, SYNCHRONIZE, WRITE_DAC, + }; + use win_api_wrappers::identity::sid::Sid; + + use super::*; + use crate::windows::test::{self as acl, Ace}; + + impl TestDir { + 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))) + } + } + + /// 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::provisioned_by(Account::CurrentUser, 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: 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()? { + 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 provisioning_gives_a_missing_data_directory_the_identity_rule() -> anyhow::Result<()> { + let directory = TestDir::new()?; + let data_dir = directory.0.join("parent").join("data"); + + prepare_directories(&data_dir, true)?; + + assert_eq!( + acl::protected_dacl_of(&data_dir)?, + Some(acl::sorted(&[ + full(&acl::system_sid()?, INHERITED), + 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_eq!( + acl::protected_dacl_of(&directory.0.join("parent"))?, + 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<()> { + // 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_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)?; + // 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)])?; + + 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<()> { + 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!( + acl::protected_dacl_of(&identity)?, + Some(acl::sorted(&[full(&system, INHERITED), full(&user, INHERITED)])) + ); + for (path, contents) in &state { + let actual = fs::read(path).with_context(|| format!("read {path} after the repair"))?; + assert_eq!(actual, *contents, "{path}"); + } + Ok(()) + } + + /// 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 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) + } + + // 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)?; + + 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::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)?; + if acl::set_owner(&path, &administrators).is_err() { + writeln!( + io::stderr(), + "SKIP: giving a file another owner needs an elevated token" + )?; + 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)) + } + + /// 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(()); + }; + + 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(()) + } + + // 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, 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, false)?; + assert_eq!(fs::read_dir(directory.pending())?.count(), 1); + Ok(()) + } + + /// 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, + 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_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] + 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 path = directory.pending_file('a'); + 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_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_into_drop_box(&directory.0, &path, b"test payload", &rules, true); + provisioned.restore()?; + + 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 §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<()> { + 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(), + false, + )?; + + 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, false); + 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(), + false, + ) + .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(Account::System, false) + }; + + 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, false) + .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(()) + } + + #[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"), + )) + }; + // 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, SUBMISSION_RETRY_INTERVAL, || { + 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(), 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); + + // 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"), + ); + 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 567e1f9e8..ceb8abe7c 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) @@ -195,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!( @@ -856,6 +856,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 _; @@ -880,20 +881,9 @@ mod tests { use x509_cert::time::{Time, Validity}; 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 +909,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] @@ -1492,14 +1476,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 +1500,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 +2836,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 +2857,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 +2875,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 +2944,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 +2956,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 +3028,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 +3185,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 +3212,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 54c8da3b8..690ccfeec 100644 --- a/crates/agent-identity/src/state_lock.rs +++ b/crates/agent-identity/src/state_lock.rs @@ -1,46 +1,54 @@ -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,75 +63,48 @@ 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 sharing_violation = i32::try_from(ERROR_SHARING_VIOLATION.0)?; - loop { - match private_file::prepare_directories(data_dir, grant_current_user) { - Ok(()) => {} - 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), + 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") + } else { + 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"), - } - } + })? + .context("identity directory disappeared after its creation") } -#[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)) +#[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 super::*; @@ -131,31 +112,27 @@ mod tests { // 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()); - fs::create_dir_all(&data_dir)?; - let lock = acquire(&data_dir, true)?; + let directory = private_file::TestDir::new()?; + let lock = acquire(directory.path(), 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); - }); - 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"))?; - fs::remove_dir_all(data_dir)?; - Ok(()) + let path = directory.path(); + std::thread::scope(|scope| { + let worker = scope.spawn(move || { + let result = acquire(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")) + }) } } diff --git a/crates/agent-identity/src/windows.rs b/crates/agent-identity/src/windows.rs index de128284b..de7e8a1dc 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/crates/device-key/Cargo.toml b/crates/device-key/Cargo.toml index 874a8025c..48fa8fc8f 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/INTENT.md b/crates/device-key/INTENT.md index 24c0b2176..e12019489 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/device-key/clippy.toml b/crates/device-key/clippy.toml index 286c23348..61d07ff18 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 04c91d9c4..513a513a7 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,44 @@ 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); - } +const KEY_FILE_SUFFIX: &str = ".p8"; + +fn key_file_name(name: &str) -> String { + format!("{name}{KEY_FILE_SUFFIX}") +} + +#[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 +86,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 +126,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) + let file_name = retire(&dir, &file_name, name).context("retire key file")?; + 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() + // `crates/device-key/INTENT.md` ("Operations"): listing never returns a link, and a file with another hard link is + // 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()) + }) + .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,72 +214,60 @@ 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")? { + // 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 { continue; }; let Some(name) = abandoned_name(file_name) else { continue; }; - #[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 - } + // `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") { + retire(dir, file_name, name).context("retire abandoned key file")? + } 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(()) } +/// 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) +} + #[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 +282,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 +315,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 +342,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 +357,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 +382,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 +419,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 +444,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 +517,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 +577,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. @@ -883,6 +591,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<()> { @@ -895,7 +633,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 +690,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 +730,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 +747,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")?; @@ -1024,13 +761,150 @@ mod tests { Ok(()) } + // 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_promptly() -> 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))?; + } + + 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:#}"); + // 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(()) + } + + // oracle: intent crates/device-key/INTENT.md §Operations + #[cfg(windows)] + #[test] + 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()?; + 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 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!(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_eq!(list(&backend, TEST_PREFIX)?, [name]); + assert!(!leftover.exists()); + 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 + #[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<()> { + 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<()> { 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 +920,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 +928,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 +966,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 +979,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] @@ -1159,20 +995,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"); @@ -1186,6 +1028,49 @@ 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. + + // 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 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!( + error.chain().any(|cause| cause + .downcast_ref::() + .is_some_and(private_fs::Error::is_refused)), + "{error:#}" + ); + assert!(!parent.join("keys").exists()); + Ok(()) + } + // oracle: intent crates/device-key/INTENT.md §Operations #[cfg(windows)] #[test] @@ -1194,11 +1079,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 +1097,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 +1115,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 +1128,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 +1157,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 +1168,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 +1225,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 +1260,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 +1280,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 +1292,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 +1359,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 5d4550f40..b66487f79 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 bcb5b6add..000000000 --- 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) -} diff --git a/crates/private-fs/Cargo.toml b/crates/private-fs/Cargo.toml index 0d6d66032..bcca30508 100644 --- a/crates/private-fs/Cargo.toml +++ b/crates/private-fs/Cargo.toml @@ -3,12 +3,29 @@ 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" + +[target.'cfg(windows)'.dependencies] +win-api-wrappers = { path = "../win-api-wrappers" } +windows = { version = "0.61", features = [ + "Wdk_Foundation", + "Wdk_Storage_FileSystem", + "Win32_Foundation", + "Win32_Globalization", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_SystemServices", + "Win32_System_Threading", +] } diff --git a/crates/private-fs/INTENT.md b/crates/private-fs/INTENT.md new file mode 100644 index 000000000..d5ee0c24f --- /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. diff --git a/crates/private-fs/src/error.rs b/crates/private-fs/src/error.rs new file mode 100644 index 000000000..ce506cdc9 --- /dev/null +++ b/crates/private-fs/src/error.rs @@ -0,0 +1,122 @@ +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 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, 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, + /// 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 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 the first existing directory of the path or a directory above it, 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 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", + }; + 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 4e1a6c994..9de5d742e 100644 --- a/crates/private-fs/src/lib.rs +++ b/crates/private-fs/src/lib.rs @@ -1,87 +1,316 @@ -//! 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; + +pub type Result = std::result::Result; -/// 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() } +/// 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, + /// 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. + /// + /// `None` when the count can't be read. + pub links: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EntryKind { + File, + Directory, + /// 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, +} + +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`. + /// + /// 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, + path: path.to_owned(), + }) + } + + /// 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(), + })) + } + + 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`. + /// 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 `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, 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. + 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)?; + 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. + /// + /// 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) + } + + /// 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. + /// + /// 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) + } + + 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) +} + +/// 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) +} + +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 000000000..db65342a6 --- /dev/null +++ b/crates/private-fs/src/linux.rs @@ -0,0 +1,448 @@ +//! 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; +use crate::{Error, Refusal, Result}; + +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; +/// 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; +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, +/// exFAT, ntfs3, NTFS (the legacy in-kernel ntfs driver; ntfs-3g is FUSE) and UDF. +const IGNORING_VOLUMES: [u32; 12] = [ + 0x6573_5546, + 0xFF53_4D42, + 0xFE53_4D42, + 0x0000_517B, + 0x0000_6969, + 0x0102_1997, + 0x786F_4256, + 0x0000_4D44, + 0x2011_BAB0, + 0x7366_746E, + 0x5346_544E, + 0x1501_3346, +]; + +#[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, NTFS or UDF 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 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; + }; + 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 => true, + _ => 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. + // `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() as libc::c_long, + fd, + to.as_ptr() as libc::c_long, + RENAME_NOREPLACE, + ) + }; + if status != 0 { + return Err(io::Error::last_os_error()); + } + 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() }; + // 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 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(); + 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 { + 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 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 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). + 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 + }] + ); + } + + // 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 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 UAPI posix_acl_xattr.h §posix_acl_xattr_entry + #[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_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)); + } + + // 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 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. + 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)); + } +} diff --git a/crates/private-fs/src/macos.rs b/crates/private-fs/src/macos.rs index 7dfc1bda4..9602a4590 100644 --- a/crates/private-fs/src/macos.rs +++ b/crates/private-fs/src/macos.rs @@ -1,78 +1,282 @@ -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}; + +/// 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; +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, 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) } -/// The step of [`has_extended_acl`] that failed. -#[derive(Debug)] -pub enum ExtendedAclError { - Inspect(io::Error), - Validate(io::Error), - ReadEntries(io::Error), +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(()) } -/// Reports whether `file` has extended ACL entries. +pub(crate) fn ancestor_acl_of_file(file: &File) -> io::Result { + acl_of_file(file).map(|summary| AncestorAcl { + replace_allowed: summary.replace_allowed, + }) +} + +/// 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 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(()) +} + +/// 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() }; + // 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()), + } + } + } +} + +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()), } - 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())), +} + +#[cfg(test)] +impl AncestorAcl { + pub(crate) fn empty() -> Self { + 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/rule.rs b/crates/private-fs/src/rule.rs new file mode 100644 index 000000000..535afdf7f --- /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 000000000..907be8a79 --- /dev/null +++ b/crates/private-fs/src/unix.rs @@ -0,0 +1,1082 @@ +//! 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::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::{Utf8Component, Utf8Path, Utf8PathBuf}; + +#[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, 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; +const GROUP_WRITE: u32 = 0o020; +const OTHER_WRITE: u32 = 0o002; +const STICKY: u32 = 0o1000; + +#[derive(Debug)] +pub(crate) struct Dir { + file: File, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Directory, + 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`]. + 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) + } +} + +pub(crate) fn create_base(path: &Utf8Path, rule: &Rule) -> Result { + 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> { + 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 { + check_creatable(path, rule, Kind::Directory)?; + let c_name = c_name(path, name)?; + // 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 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(existing == Existing::Repair)? { + 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)); + } + } + // 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) + } + }; + let dir = Dir { file }; + if created { + 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 { + 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) +} + +/// 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 or checked; fails with `denied` when `name` is not such a directory. +/// +/// 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 == 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())); + } + 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, + 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( + parent: &Dir, + path: &Utf8Path, + name: &str, + rule: &Rule, + options: FileOptions, +) -> Result> { + // 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( + parent: &Dir, + path: &Utf8Path, + name: &str, + rule: &Rule, + options: FileOptions, +) -> Result { + check_creatable(path, rule, Kind::File)?; + 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::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)), + } + 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. + 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) = protect_created(&file, path, rule, Kind::File) { + let _ = discard_entry(parent, path, &c_name, &file, 0); + return Err(error); + } + Ok(file) +} + +pub(crate) fn rename(parent: &Dir, path: &Utf8Path, from: &str, to: &str, replace: Replace) -> Result<()> { + 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(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, and a + // failed unlink leaving only the old name. + link_then_unlink(&parent.file, &from, &to) + } + renamed => renamed, + }, + }; + renamed.map_err(|error| Error::io(path, "rename", error)) +} + +pub(crate) fn persist( + parent: &Dir, + path: &Utf8Path, + file: &File, + from: &str, + to: &str, + replace: Replace, +) -> Result<()> { + 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 { + 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. +pub(crate) fn discard(parent: &Dir, path: &Utf8Path, file: &File, name: &str) -> Result<()> { + discard_entry(parent, path, &c_name(path, name)?, file, 0) +} + +pub(crate) fn entries(dir: &Dir, path: &Utf8Path) -> Result> { + 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; + } + // fstatat without following a link needs only search access on the directory, not access to the entry. + 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, + // 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 { + name: OsString::from_vec(name.to_bytes().to_vec()), + kind, + links, + }); + } +} + +pub(crate) fn sync(dir: &Dir, path: &Utf8Path) -> Result<()> { + 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<()> { + check_item(&dir.file, path, rule, Kind::Directory) +} + +pub(crate) fn repair_dir(dir: &Dir, path: &Utf8Path, rule: &Rule) -> Result<()> { + repair_item(&dir.file, path, rule, Kind::Directory) +} + +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 euid = effective_uid(); + 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 { + 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 owner = if owner_allowed(rule, metadata.uid(), euid) { + metadata.uid() + } else { + 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(kind.mode())) + .map_err(|error| Error::io(path, "change mode", error))?; + check_item(file, path, rule, kind) +} + +pub(crate) fn check_file(file: &File, path: &Utf8Path, rule: &Rule) -> Result<()> { + 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(); + // 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(); + let candidate = if prefix.as_str().is_empty() { + Utf8Path::new(".") + } else { + prefix + }; + 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(_) => { + 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 + } + Err(error) + if error.kind() == io::ErrorKind::NotFound || error.raw_os_error() == Some(libc::ENOTDIR) => + { + 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(); + 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), + _ if permission_denied(&error) => Error::refused(candidate, Refusal::Unreadable(error)), + _ => 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| { + 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 + .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(), 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` and `acl`. +/// +/// Write access to a directory with the sticky bit only lets an account add entries and remove its own. +/// 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; + } + if sticky { + return false; + } + let group_write = os::owning_group_writes(acl).unwrap_or(mode & GROUP_WRITE != 0); + mode & OTHER_WRITE != 0 || group_write +} + +/// 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 = 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. + if mode & 0o777 != expected { + file.set_permissions(Permissions::from_mode(expected)) + .map_err(|error| Error::io(path, "change mode", error))?; + } + check_item(file, path, rule, kind) +} + +fn check_item(file: &File, path: &Utf8Path, rule: &Rule, kind: Kind) -> Result<()> { + 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)), + 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<()> { + 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)); + } + 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`, 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(()) +} + +/// 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 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) +} + +/// 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 { + 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 +} + +/// 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() +} + +/// 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, + 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: 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() + .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 §vocabulary + #[test] + 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)); + 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. + // 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)); + 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/crates/private-fs/src/windows/mod.rs b/crates/private-fs/src/windows/mod.rs new file mode 100644 index 000000000..9d7a252c0 --- /dev/null +++ b/crates/private-fs/src/windows/mod.rs @@ -0,0 +1,1082 @@ +//! 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::cell::Cell; +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}; + +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_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, + 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::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, +); + +/// 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)] +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() + } +} + +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::new(handle, false); + } + 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. + // 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), + attributes: FILE_FLAGS_AND_ATTRIBUTES(0), + descriptor: Some(descriptor.as_ref()), + }; + 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 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. + // 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::new(handle, true)) + } + 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) + } + Existing::Repair => { + // A directory whose DACL no longer grants the current user anything is still repairable by its owner, + // 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: FILE_ACCESS_RIGHTS(READ_CONTROL.0 | FILE_READ_ATTRIBUTES.0), + 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, 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())?; + Ok(Dir::new(handle, false)) + } + }, + 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::new(handle, false))) +} + +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(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(handle.as_handle()).map_err(|error| Error::io(path, "remove file", error))?; + Ok(true) +} + +/// 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 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}"); + 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. + // 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))? + }; + let mut entries = Vec::with_capacity(listed.len()); + for (name, attributes, tag) in listed { + 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, + kind: entry_kind(attributes, tag), + links, + }); + } + Ok(entries) +} + +/// 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. +/// +/// 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(); + 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::open_listed(dir, &name, &request) { + Ok(Some(handle)) => handle, + // 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, + }; + 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 +/// 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. + // 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).map_err(|error| Error::io(path, "flush directory", error)) +} + +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)?; + repair(dir.handle(), path, Kind::Directory, &policy)?; + check_item(dir.handle(), path, Kind::Directory, &policy.exact()) +} + +/// Brings an open file's owner and DACL into line with `rule`, then checks it exactly. +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, 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))?; + // 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)?, + 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 = REPAIR_ACCESS.0; + let mut information = DACL_SECURITY_INFORMATION; + if !owner_allowed { + access |= WRITE_OWNER.0; + information |= OWNER_SECURITY_INFORMATION; + } + 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 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 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 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))) +} + +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. +/// 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 ()>, +} + +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(); + // 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)); + } + 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, 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, 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, + Ok(Some(_)) | Err(_) => {} + } + // 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::refused(path, Refusal::Owner)); + } + 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. + 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", + io::Error::from(io::ErrorKind::InvalidInput), + )); + } + 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_once('.') + .map_or(name, |(stem, _)| stem) + .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, + 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 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))?; + 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 { + 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 => {} + } + 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(()) +} + +/// 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)?; + 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) => { + // 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)); + } + 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::new(handle, false)) +} + +/// 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 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)); + } + // 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( + 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) +} + +/// 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() + .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)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // oracle: intent crates/private-fs/INTENT.md §paths + #[test] + 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". + /// + /// [MS-FSCC]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-fscc/ + const SYMLINK: u32 = 0xA000_000C; + const MOUNT_POINT: u32 = 0xA000_0003; + const CLOUD: u32 = 0x9000_001A; + const DEDUP: u32 = 0x8000_0013; + const LX_FIFO: u32 = 0x8000_0024; + + const REPARSE_POINT: u32 = FILE_ATTRIBUTE_REPARSE_POINT.0; + + // 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")); + 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: intent crates/private-fs/INTENT.md §paths + #[test] + fn a_name_surrogate_item_is_a_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: 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. + 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 + #[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 new file mode 100644 index 000000000..f6fa5ded1 --- /dev/null +++ b/crates/private-fs/src/windows/nt.rs @@ -0,0 +1,485 @@ +//! 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_DISPOSITION_DELETE, FILE_DISPOSITION_POSIX_SEMANTICS, FILE_INFORMATION_CLASS, FILE_RENAME_INFORMATION, + FILE_RENAME_POSIX_SEMANTICS, FILE_RENAME_REPLACE_IF_EXISTS, FileDispositionInformation, + 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_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::{ + 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, VOLUME_NAME_NONE, +}; +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 { + 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.encode_utf16().collect(), 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, 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::()) + .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: 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 or null, 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 Ok(Err(status)); + } + // SAFETY: NtCreateFile succeeded, so the handle is open and nothing else owns it. + Ok(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`, 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 { + 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) +} + +/// 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. + // 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. + 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 { + 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. + let length = unsafe { + GetFinalPathNameByHandleW( + raw(handle), + &mut buffer, + GETFINALPATHNAMEBYHANDLE_FLAGS(FILE_NAME_NORMALIZED.0 | volume.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`. +/// +/// 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<'_>, + root: BorrowedHandle<'_>, + 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)?; + // 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)?; + } + 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, + STATUS_INVALID_DEVICE_REQUEST, + STATUS_NOT_IMPLEMENTED, + ] + .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(invalid_name)?; + let name_offset = offset_of!(FILE_RENAME_INFORMATION, FileName); + let size = size_of::() + .checked_add(name_bytes) + .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::())]; + { + // 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); + 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(), + ); + 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 size = u32::try_from(size).map_err(|_| invalid_name())?; + // NtSetInformationFile takes the name relative to RootDirectory, which SetFileInformationByHandle refuses. + // 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, + ) + }; + // 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. + status = unsafe { + set_information( + handle, + (&raw const delete_file).cast(), + 1, /* DeleteFile */ + FileDispositionInformation, + ) + }; + } + if status.0 < 0 { + return Err(status_error(status)); + } + Ok(()) +} + +/// # Safety +/// +/// `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 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> { + // 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, 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))?; + // 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 { + 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); + 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, tag)); + } + 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 000000000..59693c00d --- /dev/null +++ b/crates/private-fs/src/windows/security.rs @@ -0,0 +1,990 @@ +//! 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<()> { + 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)? { + 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 exact { + let mut expected = self.entries(); + expected.sort(); + others.sort(); + // 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)); + } + } + 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, 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. + 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. + 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); + 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(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() }); + 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)); + // 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 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 { + (0, None) + }; + entries.push(Ace { + kind: header.AceType, + flags: header.AceFlags, + mask, + sid, + }); + } + Ok(entries) +} + +/// 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 +} + +/// 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"); + // An entry is its header and mask, then its SID. + 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)?; + // 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: 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. + // 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_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() { + // 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: 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() { + 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 "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. + 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: 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 "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. + 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() { + // 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/Cargo.toml b/testsuite/Cargo.toml index 2204d5d18..cbd210e7b 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" @@ -67,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/agent_identity/mod.rs b/testsuite/tests/agent_identity/mod.rs index cb44b1014..68f7687a2 100644 --- a/testsuite/tests/agent_identity/mod.rs +++ b/testsuite/tests/agent_identity/mod.rs @@ -312,10 +312,21 @@ 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"); - 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)?; + // 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_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?; let second = start_mock(second_dir.path()).await?; let stdout_path = first_dir.path().join("runner-stdout.log"); @@ -356,6 +367,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() diff --git a/testsuite/tests/main.rs b/testsuite/tests/main.rs index 00eb74ac1..be4e65b12 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 000000000..bb0338247 --- /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 000000000..b3f9c1d3f --- /dev/null +++ b/testsuite/tests/private_fs/unix.rs @@ -0,0 +1,886 @@ +//! 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::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}"); +} + +#[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") + } + + /// 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)], + ) + } + + /// 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) + } +} + +/// 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") + } +} + +// 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()); +} + +// 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()); +} + +// 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 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().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] +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() { + 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: mutant a check that takes a FIFO or a socket for a regular file +#[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()); +} + +// 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] +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); + + let error = Dir::open_base(&shared, &rule()).unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Replaceable)), "{error:?}"); + 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] +fn root_only_a_directory_above_that_roots_group_can_write_is_refused() { + let scratch = Scratch::new(); + if !scratch.as_root() { + skip( + "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::create_base(&shared.join("base"), &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 !acl::let_others_replace(&shared) { + skip( + "a_directory_above_that_an_acl_lets_others_replace_is_refused", + "needs ACL support", + ); + 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 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_directory_that_cannot_be_searched_is_unreadable() { + let scratch = Scratch::new(); + if scratch.as_root() { + skip( + "a_directory_that_cannot_be_searched_is_unreadable", + "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); + 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()); +} + +// 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() { + let scratch = Scratch::new(); + let base = scratch.base(); + drop(base.create_file("key", &rule(), FileOptions::new()).unwrap()); + 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:?}"); +} + +// 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(); + 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; + } + + 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!(!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:?}"); + + parent.repair(&rule()).unwrap(); + drop(parent.create_file("file", &rule(), FileOptions::new()).unwrap()); +} + +// 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 !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(); + 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: 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(); + 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(); + 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() + .create_dir("dir", &system_only, Existing::Check) + .unwrap_err(); + assert!(matches!(refusal(&error), Some(Refusal::Owner)), "{error:?}"); + assert!(!scratch.path.join("dir").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(); + // 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(); +} + +// 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: 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(); + 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 discard that removed by name alone would remove whatever was installed under the name since. +// 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(); + 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 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(), Some(2)), + ("other".to_owned(), Some(2)), + ("single".to_owned(), Some(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] +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), + ] + ); +} + +// 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()); +} diff --git a/testsuite/tests/private_fs/windows.rs b/testsuite/tests/private_fs/windows.rs new file mode 100644 index 000000000..9e3e1570a --- /dev/null +++ b/testsuite/tests/private_fs/windows.rs @@ -0,0 +1,1088 @@ +//! 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] "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 _; +use std::os::windows::io::AsRawHandle as _; +use std::process::{Command, Stdio}; + +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 rstest::rstest; +use win_api_wrappers::identity::sid::Sid; +use win_api_wrappers::process::Process; +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, DACL_SECURITY_INFORMATION, SecurityImpersonation, TOKEN_ADJUST_PRIVILEGES, TOKEN_DUPLICATE, + TOKEN_IMPERSONATE, TOKEN_QUERY, TokenImpersonation, +}; +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, +}; +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; +/// 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; +/// `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`. +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); + } +} + +/// 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() + .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); + +/// 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")) +} + +/// 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 + .protected_allow_entries()? + .into_iter() + .map(|entry| (entry.sid, entry.mask, entry.flags)) + .collect(); + entries.sort(); + 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 +} + +/// 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. + 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); + // The creating handle has READ_CONTROL whatever the DACL grants. + let file = base.create_file("file", &rule, FileOptions::new())?; + assert_eq!( + security_of(&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) => { + skip( + "the_backup_privilege_opens_a_file_the_token_cannot", + &format!("the process token doesn't hold the backup privilege: {error}"), + ); + return Ok(()); + } + }; + 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(()) +} + +// 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 + // 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() + .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<()> { + // 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_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())?; + 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())?); + 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"), + &[ + (user(), FULL, INHERITED_BY_CHILDREN), + (users(), FULL, INHERITED_BY_CHILDREN), + ], + 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!(dacl_sddl(&file)?, file_before); + 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<()> { + // 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<()> { + // 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))? { + skip( + "a_repair_takes_ownership_from_an_owner_the_rule_does_not_allow", + "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())? { + skip( + "administrators_as_an_owner_allow_a_member_of_the_group", + "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_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)?); + 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)), + "{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())?; + 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)), + "{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 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<()> { + // 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<()> { + // 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 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, + )?; + // 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. + 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 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)?); + 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), + ]; + // 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 §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(), Some(2)), + ("other".to_owned(), Some(2)), + ("single".to_owned(), Some(1)) + ] + ); + Ok(()) +} + +// 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 + // 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, Some(1)); + 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<()> { + // 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())?); + 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(()) +} + +#[rstest] +// oracle: spec MS-FSCC §2.1.5 Pathname +#[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(name, &user_rule(), FileOptions::new())); + assert!( + matches!(error.kind(), ErrorKind::Io { source, .. } if source.kind() == std::io::ErrorKind::InvalidInput), + "{error:?}" + ); + assert!(base.entries()?.is_empty()); + Ok(()) +} + +// 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. + 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: 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 + // 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: 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: 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 + // 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: 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. + 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: 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. + 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<()> { + // 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) => { + skip( + "a_thread_holds_one_backup_privilege_at_a_time", + &format!("the process token doesn't hold the backup privilege: {error}"), + ); + return Ok(()); + } + }; + assert!(BackupPrivilege::enable().is_err()); + drop(first); + drop(BackupPrivilege::enable()?); + Ok(()) +}