diff --git a/crates/now-package-broker/src/policy_loader.rs b/crates/now-package-broker/src/policy_loader.rs index 286df2692..8a51fc525 100644 --- a/crates/now-package-broker/src/policy_loader.rs +++ b/crates/now-package-broker/src/policy_loader.rs @@ -2,7 +2,8 @@ //! //! Loads policy documents from the configured directory. //! Supports JSON (`.json`) policies. -//! Default location: `%PROGRAMDATA%/Devolutions/Agent/` +//! Managed policies use `%PROGRAMDATA%/Devolutions/PackageBroker/`. +//! `%PROGRAMDATA%/Devolutions/Agent/` remains the legacy compatibility location. use std::io::Read as _; use std::path::{Path, PathBuf}; @@ -13,14 +14,28 @@ use tracing::info; use crate::policy_security; -/// Default policy directory. +fn program_data_dir() -> PathBuf { + std::env::var_os("PROGRAMDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")) +} + +/// Directory used by the managed policy store. +/// +/// This sibling of `Agent` avoids inheriting that shared directory's broader service ACL. +pub fn managed_default_policy_dir() -> PathBuf { + program_data_dir().join("Devolutions").join("PackageBroker") +} + +/// Legacy policy directory retained for upgrade compatibility. +pub fn legacy_default_policy_dir() -> PathBuf { + program_data_dir().join("Devolutions").join("Agent") +} + +/// Legacy alias retained for callers that explicitly need the pre-managed location. +#[deprecated(note = "use legacy_default_policy_dir; PolicyStore owns managed/legacy arbitration")] pub fn default_policy_dir() -> PathBuf { - if cfg!(windows) { - let program_data = std::env::var("PROGRAMDATA").unwrap_or_else(|_| r"C:\ProgramData".to_owned()); - PathBuf::from(program_data).join("Devolutions").join("Agent") - } else { - PathBuf::from("/etc/devolutions-agent") - } + legacy_default_policy_dir() } /// Base name for the policy file (without extension). @@ -78,11 +93,11 @@ fn deserialize_policy(content: &str, path: &Path) -> anyhow::Result anyhow::Result { - let dir = default_policy_dir(); +pub fn find_legacy_default_policy() -> anyhow::Result { + let dir = legacy_default_policy_dir(); if let Some(path) = find_default_policy_in(&dir) { return Ok(path); } @@ -93,14 +108,29 @@ pub fn find_default_policy() -> anyhow::Result { ) } +/// Legacy alias retained for callers that explicitly need the pre-managed location. +#[deprecated(note = "use find_legacy_default_policy; PolicyStore owns managed/legacy arbitration")] +pub fn find_default_policy() -> anyhow::Result { + find_legacy_default_policy() +} + fn find_default_policy_in(dir: &Path) -> Option { let path = dir.join(format!("{POLICY_FILE_BASE}.json")); path.exists().then_some(path) } -/// Candidate default policy path used when no default policy file exists yet. +/// Return managed and legacy policy candidates in arbitration order. +pub fn default_policy_candidates() -> [PathBuf; 2] { + [ + managed_default_policy_dir().join(format!("{POLICY_FILE_BASE}.json")), + legacy_default_policy_dir().join(format!("{POLICY_FILE_BASE}.json")), + ] +} + +/// Legacy alias retained for callers that explicitly need the pre-managed location. +#[deprecated(note = "use default_policy_candidates; PolicyStore owns managed/legacy arbitration")] pub fn default_policy_candidate() -> PathBuf { - default_policy_dir().join(format!("{POLICY_FILE_BASE}.json")) + legacy_default_policy_dir().join(format!("{POLICY_FILE_BASE}.json")) } #[cfg(test)] @@ -145,4 +175,33 @@ mod tests { std::fs::write(&json_path, "{}").expect("write JSON policy"); assert_eq!(find_default_policy_in(dir.path()), Some(json_path)); } + + #[test] + fn managed_and_legacy_default_candidates_are_explicit() { + let [managed, legacy] = default_policy_candidates(); + + assert_eq!( + managed.file_name().expect("managed candidate has a leaf"), + "package-broker-policy.json" + ); + assert_eq!( + legacy.file_name().expect("legacy candidate has a leaf"), + "package-broker-policy.json" + ); + assert_eq!( + managed + .parent() + .and_then(Path::file_name) + .expect("managed candidate has a parent"), + "PackageBroker" + ); + assert_eq!( + legacy + .parent() + .and_then(Path::file_name) + .expect("legacy candidate has a parent"), + "Agent" + ); + assert_ne!(managed, legacy); + } } diff --git a/crates/now-package-broker/src/policy_security.rs b/crates/now-package-broker/src/policy_security.rs index d4a68cf38..16a78c72a 100644 --- a/crates/now-package-broker/src/policy_security.rs +++ b/crates/now-package-broker/src/policy_security.rs @@ -1,7 +1,9 @@ //! Admin-only-writable file security validation. //! -//! Shared by two trust boundaries in the package broker: -//! - The policy file, which is the entire authorization control for the broker. +//! Shared by four trust boundaries in the package broker: +//! - Legacy policy files loaded from the Agent directory. +//! - The dedicated managed-policy directory and its files. +//! - Authenticated pipe-client executables. //! - Package-manager executables resolved for elevated/machine-scope execution //! (e.g. `winget.exe`, `choco.exe`). //! @@ -15,10 +17,11 @@ //! a trusted principal and that its DACL does not grant write access to any other //! principal. Callers fail closed when this check fails. //! -//! For the policy file, the trusted principals are SYSTEM, `LOCAL SERVICE`, and the -//! built-in Administrators group. For executables, `LOCAL SERVICE` is not trusted, but -//! `NT SERVICE\TrustedInstaller` is, since Windows-protected binaries (`System32`, -//! `Program Files`, `WindowsApps`) are owned by and writable by that service. +//! The legacy Agent-directory loader accepts SYSTEM, `LOCAL SERVICE`, and built-in +//! Administrators because that shared directory grants `LOCAL SERVICE` write access. +//! The managed policy store accepts only SYSTEM and built-in Administrators. +//! Executable checks also accept `NT SERVICE\TrustedInstaller` for Windows-protected +//! binaries under locations such as `System32`, `Program Files`, and `WindowsApps`. //! //! For elevated executables the verification additionally defends against //! time-of-check/time-of-use races: the file is opened without write or delete sharing @@ -28,32 +31,35 @@ //! retargeting of the originally supplied name), and every ancestor directory of that //! path is checked so untrusted principals cannot swap path components either. -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use std::fs::{File, OpenOptions}; use std::mem::size_of; use std::os::windows::ffi::{OsStrExt as _, OsStringExt as _}; -use std::os::windows::fs::OpenOptionsExt as _; +use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; use std::os::windows::io::AsRawHandle as _; use std::path::{Path, PathBuf}; use anyhow::{Context as _, bail}; use sha2::{Digest as _, Sha256}; +use win_api_wrappers::identity::sid::Sid; +use win_api_wrappers::security::acl::{Acl, InheritableAcl, InheritableAclKind}; +use win_api_wrappers::security::attributes::{SecurityAttributes, SecurityAttributesInit}; use windows::Win32::Foundation::{ ERROR_PATH_NOT_FOUND, ERROR_SUCCESS, GENERIC_ALL, GENERIC_WRITE, HANDLE, HLOCAL, LocalFree, }; use windows::Win32::Globalization::{CSTR_EQUAL, CompareStringOrdinal}; use windows::Win32::Security::Authorization::{ConvertSidToStringSidW, GetSecurityInfo, SE_FILE_OBJECT}; use windows::Win32::Security::{ - ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, DACL_SECURITY_INFORMATION, GetAce, INHERIT_ONLY_ACE, IsWellKnownSid, - OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, WinBuiltinAdministratorsSid, WinLocalServiceSid, - WinLocalSystemSid, + ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, DACL_SECURITY_INFORMATION, GetAce, GetLengthSid, INHERIT_ONLY_ACE, + IsWellKnownSid, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, WinBuiltinAdministratorsSid, + WinLocalServiceSid, WinLocalSystemSid, }; use windows::Win32::Storage::FileSystem::{ DELETE, FILE_APPEND_DATA, FILE_ATTRIBUTE_REPARSE_POINT, FILE_ATTRIBUTE_TAG_INFO, FILE_DELETE_CHILD, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_NAME_NORMALIZED, FILE_READ_ATTRIBUTES, - FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_WRITE_ATTRIBUTES, FILE_WRITE_DATA, FILE_WRITE_EA, - FileAttributeTagInfo, GETFINALPATHNAMEBYHANDLE_FLAGS, GetFileInformationByHandleEx, GetFinalPathNameByHandleW, - READ_CONTROL, VOLUME_NAME_GUID, WRITE_DAC, WRITE_OWNER, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, FILE_WRITE_ATTRIBUTES, FILE_WRITE_DATA, + FILE_WRITE_EA, FileAttributeTagInfo, GETFINALPATHNAMEBYHANDLE_FLAGS, GetFileInformationByHandleEx, + GetFinalPathNameByHandleW, READ_CONTROL, VOLUME_NAME_GUID, WRITE_DAC, WRITE_OWNER, }; use windows::core::PWSTR; @@ -108,13 +114,13 @@ const TRUSTED_INSTALLER_SID: &str = "S-1-5-80-956008885-3418522649-1831038044-18 enum TrustedWriters { /// SYSTEM, `LOCAL SERVICE`, and the built-in Administrators group (policy file). AdminOnly, + /// SYSTEM and built-in Administrators only for the managed policy store. + ManagedPolicy, /// SYSTEM, the built-in Administrators group, and `NT SERVICE\TrustedInstaller` /// (Windows-protected executables). `LOCAL SERVICE` is deliberately not trusted here: /// it is a low-privilege shared service identity, and accepting it for elevated /// executables would open a privilege-escalation path. AdminOrTrustedInstaller, - /// Policy-path ancestors may be controlled by the policy writers or TrustedInstaller. - PolicyAncestor, } // ACE type constants from winnt.h (the Win32_System_SystemServices feature is not enabled). @@ -179,25 +185,38 @@ pub(crate) fn verify_policy_file_security(file: &File) -> anyhow::Result<()> { verify_handle_security(file, "policy file", TrustedWriters::AdminOnly, WRITE_ACCESS_MASK) } +/// Verify the stricter managed-store policy-file ACL. +pub(crate) fn verify_managed_policy_file_security(file: &File) -> anyhow::Result<()> { + verify_handle_security(file, "policy file", TrustedWriters::ManagedPolicy, WRITE_ACCESS_MASK) +} + /// Verify that the directory hosting a managed policy is not writable by untrusted principals. pub(crate) fn verify_policy_directory_security(directory: &File) -> anyhow::Result<()> { verify_handle_security( directory, "policy directory", - TrustedWriters::AdminOnly, + TrustedWriters::ManagedPolicy, PARENT_DIRECTORY_TAMPER_MASK, ) } -/// Verify every lexical ancestor of a managed policy path. -pub(crate) fn verify_policy_path_ancestors(path: &Path) -> anyhow::Result<()> { - let subject = format!("policy file '{}'", path.display()); - verify_directory_chain( - path.parent(), - &subject, +/// Verify the legacy Agent policy directory without granting it managed-store write capability. +pub(crate) fn verify_legacy_policy_directory_security(directory: &File) -> anyhow::Result<()> { + verify_handle_security( + directory, + "legacy policy directory", TrustedWriters::AdminOnly, - TrustedWriters::PolicyAncestor, - true, + PARENT_DIRECTORY_TAMPER_MASK, + ) +} + +/// Verify the relaxed tamper policy used for an already-open policy ancestor. +pub(crate) fn verify_policy_ancestor_directory_security(dir: &File, subject: &str) -> anyhow::Result<()> { + verify_handle_security( + dir, + subject, + TrustedWriters::AdminOrTrustedInstaller, + DIRECTORY_TAMPER_MASK, ) } @@ -218,12 +237,107 @@ pub(crate) fn verify_policy_file_path(file: &File, path: &Path) -> anyhow::Resul /// Compare Windows paths using the operating system's ordinal case folding. pub(crate) fn windows_paths_equal(left: &Path, right: &Path) -> bool { - let left: Vec = left.as_os_str().encode_wide().collect(); - let right: Vec = right.as_os_str().encode_wide().collect(); + os_strings_match_case_insensitive(left.as_os_str(), right.as_os_str()) +} + +pub(crate) fn paths_match_case_insensitive(left: &Path, right: &Path) -> bool { + windows_paths_equal(left, right) +} + +pub(crate) fn os_strings_match_case_insensitive(left: &OsStr, right: &OsStr) -> bool { + let left: Vec = left.encode_wide().collect(); + let right: Vec = right.encode_wide().collect(); // SAFETY: Both slices contain valid, initialized UTF-16 code units. unsafe { CompareStringOrdinal(&left, &right, true) == CSTR_EQUAL } } +/// Verify and summarize retained policy ancestors in root-to-leaf order. +/// +/// Ordered file identities define the path without encoding path text. +/// This avoids case normalization and preserves ill-formed UTF-16 path semantics. +pub(crate) fn verified_policy_ancestor_digest(handles: &[File], subject: &str) -> anyhow::Result<[u8; 32]> { + let mut levels = Vec::with_capacity(handles.len()); + + for (index, handle) in handles.iter().enumerate() { + let dir_subject = format!("{subject} ancestor level {index}"); + if is_reparse_point(handle).with_context(|| format!("failed to inspect {dir_subject}"))? { + bail!("{dir_subject} is a reparse point"); + } + let attributes = handle + .metadata() + .with_context(|| format!("failed to query metadata for {dir_subject}"))? + .file_attributes(); + if attributes & windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_DIRECTORY.0 == 0 { + bail!("{dir_subject} is not a directory"); + } + verify_policy_ancestor_directory_security(handle, &dir_subject)?; + let security_digest = + security_state_digest(handle).with_context(|| format!("failed to digest {dir_subject} security"))?; + levels.push(( + file_identity(handle).with_context(|| format!("failed to identify {dir_subject}"))?, + security_digest, + )); + } + + Ok(canonical_ancestor_digest(&levels)) +} + +fn canonical_ancestor_digest(levels: &[(FileIdentity, [u8; 32])]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"devolutions-policy-ancestor-digest-v2\0"); + hasher.update( + u32::try_from(levels.len()) + .expect("ancestor count fits u32") + .to_le_bytes(), + ); + for &(identity, security_digest) in levels { + update_ancestor_level_digest(&mut hasher, identity, security_digest); + } + hasher.finalize().into() +} + +fn update_ancestor_level_digest(hasher: &mut Sha256, identity: FileIdentity, security_digest: [u8; 32]) { + hasher.update(identity.volume_serial.to_le_bytes()); + hasher.update(identity.file_id); + hasher.update(security_digest); +} + +#[cfg(test)] +pub(crate) fn test_ancestor_digest(identity: FileIdentity, security_digest: [u8; 32]) -> [u8; 32] { + canonical_ancestor_digest(&[(identity, security_digest)]) +} + +/// Open and retain every existing lexical component in a policy directory chain. +pub(crate) fn retain_policy_no_reparse_directory_chain(dir: &Path, subject: &str) -> anyhow::Result> { + let mut components: Vec<&Path> = dir.ancestors().filter(|path| !path.as_os_str().is_empty()).collect(); + components.reverse(); + let mut handles = Vec::with_capacity(components.len()); + + for component in components { + let component_subject = format!("{subject} component '{}'", component.display()); + let handle = OpenOptions::new() + .access_mode((FILE_READ_ATTRIBUTES | FILE_TRAVERSE | READ_CONTROL).0) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE).0) + .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(component) + .with_context(|| format!("failed to open {component_subject}"))?; + + if is_reparse_point(&handle).with_context(|| format!("failed to inspect {component_subject}"))? { + bail!("{component_subject} is a reparse point"); + } + let attributes = handle + .metadata() + .with_context(|| format!("failed to query metadata for {component_subject}"))? + .file_attributes(); + if attributes & windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_DIRECTORY.0 == 0 { + bail!("{component_subject} is not a directory"); + } + handles.push(handle); + } + + Ok(handles) +} + /// Digest verified owner and DACL state for opaque policy-store fingerprints. pub(crate) fn security_state_digest(file: &File) -> anyhow::Result<[u8; 32]> { let handle = HANDLE(file.as_raw_handle()); @@ -248,20 +362,20 @@ pub(crate) fn security_state_digest(file: &File) -> anyhow::Result<[u8; 32]> { bail!("failed to read policy security state: error {}", status.0); } - let mut hasher = Sha256::new(); - if owner.0.is_null() { - hasher.update(b"no-owner"); + let owner_bytes = if owner.0.is_null() { + None } else { // SAFETY: The owner SID points into the live security descriptor. - let owner = unsafe { sid_to_string(owner) }; - hasher.update(owner.as_bytes()); - } - if dacl.is_null() { - hasher.update(b"null-dacl"); + let length = usize::try_from(unsafe { GetLengthSid(owner) }).expect("SID length fits usize"); + // SAFETY: GetLengthSid returned the complete size of the SID in the live descriptor. + Some(unsafe { std::slice::from_raw_parts(owner.0.cast::(), length) }) + }; + let ace_bytes = if dacl.is_null() { + None } else { // SAFETY: The DACL points into the live security descriptor. let ace_count = u32::from(unsafe { (*dacl).AceCount }); - hasher.update(ace_count.to_le_bytes()); + let mut entries = Vec::with_capacity(usize::try_from(ace_count).expect("ACE count fits usize")); for index in 0..ace_count { let mut ace: *mut core::ffi::c_void = std::ptr::null_mut(); // SAFETY: The index is within the DACL's reported ACE count. @@ -269,10 +383,142 @@ pub(crate) fn security_state_digest(file: &File) -> anyhow::Result<[u8; 32]> { // SAFETY: GetAce returned a complete ACE beginning with ACE_HEADER. let size = usize::from(unsafe { (*ace.cast::()).AceSize }); // SAFETY: AceSize bounds the complete ACE within the validated ACL. - hasher.update(unsafe { std::slice::from_raw_parts(ace.cast::(), size) }); + entries.push(unsafe { std::slice::from_raw_parts(ace.cast::(), size) }); + } + Some(entries) + }; + Ok(canonical_security_digest(owner_bytes, ace_bytes.as_deref())) +} + +fn canonical_security_digest(owner: Option<&[u8]>, dacl: Option<&[&[u8]]>) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"devolutions-policy-security-digest-v1\0"); + update_optional_bytes(&mut hasher, owner); + match dacl { + Some(entries) => { + hasher.update([1]); + hasher.update(u32::try_from(entries.len()).expect("ACE count fits u32").to_le_bytes()); + for entry in entries { + update_fixed_width_bytes(&mut hasher, entry); + } } + None => hasher.update([0]), } - Ok(hasher.finalize().into()) + hasher.finalize().into() +} + +fn update_optional_bytes(hasher: &mut Sha256, bytes: Option<&[u8]>) { + match bytes { + Some(bytes) => { + hasher.update([1]); + update_fixed_width_bytes(hasher, bytes); + } + None => hasher.update([0]), + } +} + +fn update_fixed_width_bytes(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update( + u32::try_from(bytes.len()) + .expect("security component length fits u32") + .to_le_bytes(), + ); + hasher.update(bytes); +} + +fn admin_only_acl(inheritance: windows::Win32::Security::ACE_FLAGS) -> anyhow::Result { + use win_api_wrappers::security::acl::{ExplicitAccess, Trustee}; + use windows::Win32::Security::Authorization::GRANT_ACCESS; + + let system = Sid::from_well_known(WinLocalSystemSid, None).context("resolve SYSTEM SID")?; + let admins = Sid::from_well_known(WinBuiltinAdministratorsSid, None).context("resolve Administrators SID")?; + + Acl::new() + .context("initialize ACL")? + .set_entries(&[ + ExplicitAccess { + access_permissions: GENERIC_ALL.0, + access_mode: GRANT_ACCESS, + inheritance, + trustee: Trustee::Sid(system), + }, + ExplicitAccess { + access_permissions: GENERIC_ALL.0, + access_mode: GRANT_ACCESS, + inheritance, + trustee: Trustee::Sid(admins), + }, + ]) + .context("build admin-only ACL") +} + +pub(crate) fn admin_only_security_attributes(inherit_to_children: bool) -> anyhow::Result { + use windows::Win32::Security::{CONTAINER_INHERIT_ACE, NO_INHERITANCE, OBJECT_INHERIT_ACE}; + + let inheritance = if inherit_to_children { + CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE + } else { + NO_INHERITANCE + }; + let owner = Sid::from_well_known(WinLocalSystemSid, None).context("resolve SYSTEM SID")?; + let acl = admin_only_acl(inheritance)?; + + Ok(SecurityAttributesInit { + owner: Some(owner), + dacl: Some(InheritableAcl { + kind: InheritableAclKind::Protected, + acl, + }), + ..Default::default() + } + .init()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct FileIdentity { + pub(crate) volume_serial: u64, + pub(crate) file_id: [u8; 16], +} + +pub(crate) fn file_identity(file: &File) -> anyhow::Result { + use windows::Win32::Storage::FileSystem::{FILE_ID_INFO, FileIdInfo, GetFileInformationByHandleEx}; + + let mut info = FILE_ID_INFO::default(); + let info_size = u32::try_from(size_of::()).expect("FILE_ID_INFO size fits in u32"); + // SAFETY: `file` is open and the output buffer has the exact required size. + unsafe { + GetFileInformationByHandleEx( + HANDLE(file.as_raw_handle()), + FileIdInfo, + (&raw mut info).cast(), + info_size, + ) + } + .context("GetFileInformationByHandleEx(FileIdInfo) failed")?; + + Ok(FileIdentity { + volume_serial: info.VolumeSerialNumber, + file_id: info.FileId.Identifier, + }) +} + +pub(crate) fn file_link_count(file: &File) -> anyhow::Result { + use windows::Win32::Storage::FileSystem::{FILE_STANDARD_INFO, FileStandardInfo, GetFileInformationByHandleEx}; + + let mut info = FILE_STANDARD_INFO::default(); + let info_size = u32::try_from(size_of::()).expect("FILE_STANDARD_INFO size fits in u32"); + // SAFETY: `file` is open and the output buffer has the exact required size. + unsafe { + GetFileInformationByHandleEx( + HANDLE(file.as_raw_handle()), + FileStandardInfo, + (&raw mut info).cast(), + info_size, + ) + } + .context("GetFileInformationByHandleEx(FileStandardInfo) failed")?; + + Ok(info.NumberOfLinks) } /// A package-manager executable that was verified for elevated execution. @@ -401,7 +647,6 @@ pub(crate) fn verify_elevated_executable_security( &subject, TrustedWriters::AdminOrTrustedInstaller, TrustedWriters::AdminOrTrustedInstaller, - false, )?; Ok(Some(VerifiedExecutable { @@ -635,7 +880,6 @@ fn verify_directory_chain( subject: &str, first_writers: TrustedWriters, ancestor_writers: TrustedWriters, - reject_reparse: bool, ) -> anyhow::Result<()> { let mut tamper_mask = PARENT_DIRECTORY_TAMPER_MASK; let mut trusted_writers = first_writers; @@ -651,9 +895,6 @@ fn verify_directory_chain( .with_context(|| format!("failed to open {dir_subject}"))?; let is_reparse = is_reparse_point(&handle).with_context(|| format!("failed to inspect {dir_subject}"))?; - if reject_reparse && is_reparse { - bail!("{dir_subject} is a reparse point"); - } let reparse_mask = if is_reparse { REPARSE_POINT_TAMPER_MASK } else { 0 }; verify_handle_security(&handle, &dir_subject, trusted_writers, tamper_mask | reparse_mask)?; @@ -681,7 +922,7 @@ fn is_reparse_point(file: &File) -> anyhow::Result { } /// Resolve the normalized final path of an open file from its handle. -fn final_path_from_handle(file: &File) -> anyhow::Result { +pub(crate) fn final_path_from_handle(file: &File) -> anyhow::Result { let handle = HANDLE(file.as_raw_handle()); match final_path_name(handle, FILE_NAME_NORMALIZED) { Ok(path) => Ok(final_path_from_wide(&path, false)), @@ -892,7 +1133,7 @@ unsafe fn is_trusted_sid(sid: PSID, trusted_writers: TrustedWriters) -> bool { // write access for `LOCAL SERVICE`, so it must be trusted for the policy file. // It is a low-privilege shared service identity, however, so it is not trusted for // elevated executables, where accepting it would open a privilege-escalation path. - if trusted_writers != TrustedWriters::AdminOrTrustedInstaller + if trusted_writers == TrustedWriters::AdminOnly // SAFETY: Per function contract, `sid` points to a valid SID. && unsafe { IsWellKnownSid(sid, WinLocalServiceSid) }.as_bool() { @@ -904,7 +1145,10 @@ unsafe fn is_trusted_sid(sid: PSID, trusted_writers: TrustedWriters) -> bool { return true; } - if trusted_writers == TrustedWriters::AdminOnly { + if matches!( + trusted_writers, + TrustedWriters::AdminOnly | TrustedWriters::ManagedPolicy + ) { return false; } @@ -988,6 +1232,130 @@ mod tests { assert!(windows_paths_equal(&resolved, &executable)); } + #[test] + fn ace_digest_includes_callback_application_data() { + let left_entry: &[u8] = &[1, 2, 3, 4]; + let right_entry: &[u8] = &[1, 2, 3, 5]; + let left = canonical_security_digest(Some(&[1, 2]), Some(&[left_entry])); + let right = canonical_security_digest(Some(&[1, 2]), Some(&[right_entry])); + + assert_ne!(left, right); + } + + #[test] + fn security_digest_uses_fixed_width_golden_encoding() { + let owner: &[u8] = &[1, 1, 0, 0, 0, 0, 0, 5]; + let first: &[u8] = &[0, 0, 8, 0, 1, 0, 0, 0]; + let second: &[u8] = &[9, 0, 12, 0, 2, 0, 0, 0, 7, 8, 9, 10]; + let digest = canonical_security_digest(Some(owner), Some(&[first, second])); + + assert_eq!( + hex::encode(digest), + "af334410d4d80b647c235e0f3e550c9cc0598127282068cf78ca142b00f09154" + ); + + let native_32 = [u32::try_from(first.len()).unwrap().to_le_bytes().as_slice(), first].concat(); + let native_64 = [u64::try_from(first.len()).unwrap().to_le_bytes().as_slice(), first].concat(); + assert_ne!( + native_32, native_64, + "legacy native-width streams differ across architectures" + ); + assert_eq!( + canonical_security_digest(Some(owner), Some(&[first, second])), + digest, + "canonical digest is independent of native pointer width" + ); + } + + #[test] + fn ancestor_digest_changes_when_object_identity_changes_at_same_path_and_acl() { + let security = [7; 32]; + let first = FileIdentity { + volume_serial: 1, + file_id: [1; 16], + }; + let second = FileIdentity { + volume_serial: 1, + file_id: [2; 16], + }; + let digest = |identity| { + let mut hasher = Sha256::new(); + hasher.update(b"devolutions-policy-ancestor-digest-v2\0"); + hasher.update(1u32.to_le_bytes()); + update_ancestor_level_digest(&mut hasher, identity, security); + <[u8; 32]>::from(hasher.finalize()) + }; + + assert_ne!(digest(first), digest(second)); + } + + #[test] + fn ancestor_digest_binds_root_to_leaf_order() { + let root = FileIdentity { + volume_serial: 1, + file_id: [1; 16], + }; + let leaf = FileIdentity { + volume_serial: 1, + file_id: [2; 16], + }; + let security = [7; 32]; + + assert_ne!( + canonical_ancestor_digest(&[(root, security), (leaf, security)]), + canonical_ancestor_digest(&[(leaf, security), (root, security)]) + ); + } + + #[test] + fn ancestor_digest_is_path_text_independent_without_lossy_collapse() { + let upper = Path::new(r"C:\DÉVOLUTIONS"); + let lower = Path::new(r"c:\dévolutions"); + assert!(windows_paths_equal(upper, lower)); + let identity = FileIdentity { + volume_serial: 1, + file_id: [1; 16], + }; + let digest = canonical_ancestor_digest(&[(identity, [7; 32])]); + assert_eq!(digest, canonical_ancestor_digest(&[(identity, [7; 32])])); + + let first = OsString::from_wide(&[0xD800]); + let second = OsString::from_wide(&[0xD801]); + assert_eq!(first.to_string_lossy(), second.to_string_lossy()); + assert_ne!( + canonical_ancestor_digest(&[( + FileIdentity { + volume_serial: 1, + file_id: [1; 16], + }, + [7; 32], + )]), + canonical_ancestor_digest(&[( + FileIdentity { + volume_serial: 1, + file_id: [2; 16], + }, + [7; 32], + )]) + ); + } + + #[test] + fn admin_only_security_attributes_use_a_protected_dacl() { + let attributes = admin_only_security_attributes(false).expect("build admin-only security attributes"); + // SAFETY: `attributes` owns a live SECURITY_ATTRIBUTES and security descriptor. + let raw = unsafe { &*attributes.as_ptr() }; + // SAFETY: lpSecurityDescriptor points to the descriptor retained by `attributes`. + let descriptor = unsafe { + &*raw + .lpSecurityDescriptor + .cast::() + }; + + assert!(descriptor.Control.contains(windows::Win32::Security::SE_DACL_PROTECTED)); + assert!(descriptor.Control.contains(windows::Win32::Security::SE_DACL_PRESENT)); + } + /// SDDL-backed security descriptor together with its extracted owner and DACL pointers. struct SddlDescriptor { _descriptor: OwnedSecurityDescriptor, @@ -1055,6 +1423,19 @@ mod tests { self.verify_with_mask(WRITE_ACCESS_MASK) } + fn verify_as_managed_policy(&self, mask: u32) -> anyhow::Result<()> { + // SAFETY: `owner` and `dacl` point into the owned security descriptor. + unsafe { + verify_owner_and_dacl( + "managed policy", + self.owner, + self.dacl, + TrustedWriters::ManagedPolicy, + mask, + ) + } + } + fn verify_with_mask(&self, mask: u32) -> anyhow::Result<()> { // SAFETY: `owner` and `dacl` point into the owned security descriptor, which outlives // this call. @@ -1091,6 +1472,31 @@ mod tests { sd.verify().expect("LOCAL SERVICE write access must be accepted"); } + #[test] + fn local_service_write_ace_is_rejected_for_managed_policy_storage() { + let sd = SddlDescriptor::parse("O:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;LS)"); + let error = sd.verify_as_managed_policy(WRITE_ACCESS_MASK).unwrap_err(); + assert!( + error.to_string().contains("grants write access"), + "unexpected error: {error}" + ); + } + + #[test] + fn shared_create_rights_are_rejected_during_managed_directory_bootstrap() { + let shared = SddlDescriptor::parse("O:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x6;;;BU)"); + shared + .verify_with_mask(DIRECTORY_TAMPER_MASK) + .expect("create-only rights are safe after a child is pinned"); + let error = shared + .verify_as_managed_policy(PARENT_DIRECTORY_TAMPER_MASK) + .unwrap_err(); + assert!( + error.to_string().contains("grants write access"), + "unexpected error: {error}" + ); + } + #[test] fn local_service_write_ace_is_rejected_for_executables() { // LOCAL SERVICE is a low-privilege shared service identity; a LOCAL @@ -1411,7 +1817,7 @@ mod tests { "policy ancestor", sd.owner, sd.dacl, - TrustedWriters::PolicyAncestor, + TrustedWriters::AdminOrTrustedInstaller, DIRECTORY_TAMPER_MASK, ) } @@ -1546,10 +1952,60 @@ mod tests { let link = temp.path().join("link"); std::os::windows::fs::symlink_dir(&target, &link).unwrap(); - let error = verify_policy_path_ancestors(&link.join("policy.json")).unwrap_err(); + let error = retain_policy_no_reparse_directory_chain(&link, "configured policy directory").unwrap_err(); assert!(error.to_string().contains("reparse point"), "unexpected error: {error}"); } + #[test] + fn chained_policy_reparse_destinations_are_rejected() { + let temp = tempfile::tempdir().unwrap(); + let trusted_outer = temp.path().join("trusted-outer"); + let user_controlled = temp.path().join("user-controlled"); + let trusted_final = user_controlled.join("trusted-final"); + std::fs::create_dir(&trusted_outer).unwrap(); + std::fs::create_dir(&user_controlled).unwrap(); + std::fs::create_dir(&trusted_final).unwrap(); + + let intermediate = trusted_outer.join("intermediate"); + std::os::windows::fs::symlink_dir(&user_controlled, &intermediate).unwrap(); + let configured = temp.path().join("configured"); + std::os::windows::fs::symlink_dir(&trusted_outer, &configured).unwrap(); + + let escaped = configured.join("intermediate").join("trusted-final"); + let error = retain_policy_no_reparse_directory_chain(&escaped, "configured policy directory").unwrap_err(); + assert!( + error.to_string().contains("reparse point") || error.to_string().contains("unexpected location"), + "unexpected error: {error}" + ); + } + + #[test] + fn retained_policy_directory_chain_blocks_component_retargeting() { + let temp = tempfile::tempdir().unwrap(); + let ancestor = temp.path().join("ancestor"); + let directory = ancestor.join("policy"); + std::fs::create_dir_all(&directory).unwrap(); + + let handles = retain_policy_no_reparse_directory_chain(&directory, "configured policy directory").unwrap(); + let moved = temp.path().join("retargeted"); + assert!(std::fs::rename(&ancestor, &moved).is_err()); + + drop(handles); + std::fs::rename(&ancestor, &moved).expect("component can move after guards are dropped"); + } + + #[test] + fn retained_policy_directory_handles_support_security_queries() { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path().join("policy"); + std::fs::create_dir(&directory).unwrap(); + + let handles = retain_policy_no_reparse_directory_chain(&directory, "configured policy directory").unwrap(); + for handle in &handles { + security_state_digest(handle).expect("retained directory handle includes READ_CONTROL"); + } + } + #[test] fn policy_leaf_reparse_is_rejected() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index abcb682a3..14253c351 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -1,17 +1,10 @@ //! Serialized policy management, validation, persistence, and reload. -//! Store tokens serialize API writers and reloads, not privileged out-of-band writes. -//! Conditional handle-relative publication is deferred. - -use std::fs::{File, OpenOptions}; -use std::io::{Read as _, Write as _}; -use std::mem::size_of; -use std::os::windows::ffi::OsStrExt as _; -use std::os::windows::fs::OpenOptionsExt as _; -use std::os::windows::io::AsRawHandle as _; -use std::path::{Component, Path, PathBuf}; +//! Store tokens serialize API writers and reloads. +//! Retained handles and conditional handle-relative publication preserve privileged out-of-band writes. + +use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; -use anyhow::Context as _; use chrono::Utc; use now_policy::PolicyDocument; use now_policy_api::{ @@ -20,86 +13,137 @@ use now_policy_api::{ PolicyReplacementRequest, PolicyStoreToken, PolicyValidationResult, PolicyWriteCapability, ServerContext, Transport, }; -use sha2::{Digest as _, Sha256}; -use windows::Win32::Foundation::HANDLE; -use windows::Win32::Storage::FileSystem::{ - FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, - FILE_SHARE_READ, FILE_SHARE_WRITE, FileIdInfo, GetFileInformationByHandleEx, GetVolumeInformationW, - GetVolumePathNameW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, READ_CONTROL, -}; -use windows::core::PCWSTR; -use crate::policy_security; mod receipt; mod validation; +mod windows; #[derive(Clone, Copy, Debug)] pub enum ReloadCause { ExternalChange, } -#[derive(Clone, PartialEq, Eq)] -struct DiskFingerprint([u8; 32]); - -struct Observation { - state: PolicyManagementState, - policy: Option, - invalid_diagnostics: Option, - write_capability: PolicyWriteCapability, - read_only_reason: Option, - configured_path: PathBuf, - fingerprint: DiskFingerprint, -} - -struct PersistedPolicy { - policy: PolicyDocument, - observation: Observation, -} - -enum WriteFailure { - PrePublication(anyhow::Error), - PostPublication(anyhow::Error), -} +type DiskFingerprint = windows::DiskFingerprint; +type Observation = windows::DiskObservation; +type PersistedPolicy = windows::PersistedPolicy; +type WriteFailure = windows::WriteFailure; trait PolicyStorage: Send + Sync { fn observe(&self, source: PolicyConfigurationSource, path: &Path) -> Observation; + fn observe_for_write(&self, source: PolicyConfigurationSource, path: &Path) -> Observation { + self.observe(source, path) + } fn create( &self, + source: PolicyConfigurationSource, configured_path: &Path, observation: &Observation, bytes: &[u8], ) -> Result; fn replace( &self, + source: PolicyConfigurationSource, configured_path: &Path, - observation: &Observation, + observation: &mut Observation, bytes: &[u8], ) -> Result; } -struct FilePolicyStorage; +struct FilePolicyStorage { + probe_cache: windows::AtomicityProbeCache, +} + +impl FilePolicyStorage { + fn new() -> Self { + Self { + probe_cache: windows::AtomicityProbeCache::new(), + } + } +} impl PolicyStorage for FilePolicyStorage { fn observe(&self, source: PolicyConfigurationSource, path: &Path) -> Observation { - observe_file(source, path) + windows::observe(source, path, &self.probe_cache) + } + + fn observe_for_write(&self, source: PolicyConfigurationSource, path: &Path) -> Observation { + windows::observe_for_write(source, path, &self.probe_cache) } fn create( &self, + source: PolicyConfigurationSource, configured_path: &Path, observation: &Observation, bytes: &[u8], ) -> Result { - publish_file(configured_path, observation, bytes, false) + let hosting_dir = observation + .hosting_dir + .as_ref() + .expect("writable observations retain the verified hosting directory"); + windows::atomic_create( + hosting_dir, + &observation.fingerprint, + &observation.canonical_path, + bytes, + )?; + windows::ensure_published_managed_authority(source, configured_path, hosting_dir) + .map_err(WriteFailure::PostPublication)?; + self.authoritative_reobserve(configured_path, bytes) } fn replace( &self, + source: PolicyConfigurationSource, configured_path: &Path, - observation: &Observation, + observation: &mut Observation, bytes: &[u8], ) -> Result { - publish_file(configured_path, observation, bytes, true) + let hosting_dir = observation + .hosting_dir + .as_ref() + .expect("writable observations retain the verified hosting directory"); + windows::atomic_replace( + hosting_dir, + observation.retained_target.take(), + &observation.fingerprint, + &observation.canonical_path, + bytes, + )?; + windows::ensure_published_managed_authority(source, configured_path, hosting_dir) + .map_err(WriteFailure::PostPublication)?; + self.authoritative_reobserve(configured_path, bytes) + } +} + +impl FilePolicyStorage { + fn authoritative_reobserve( + &self, + configured_path: &Path, + expected_bytes: &[u8], + ) -> Result { + let observation = windows::observe( + PolicyConfigurationSource::ConfiguredPath, + configured_path, + &self.probe_cache, + ); + let policy = observation + .policy + .ok_or_else(|| WriteFailure::PostPublication(anyhow::anyhow!("published policy failed re-observation")))?; + let expected: serde_json::Value = + serde_json::from_slice(expected_bytes).map_err(|error| WriteFailure::PostPublication(error.into()))?; + if serde_json::to_value(&policy).map_err(|error| WriteFailure::PostPublication(error.into()))? != expected { + return Err(WriteFailure::PostPublication(anyhow::anyhow!( + "re-observed policy does not match the committed document" + ))); + } + Ok(PersistedPolicy { + policy, + fingerprint: observation.fingerprint, + write_capability: observation.write_capability, + read_only_reason: observation.read_only_reason, + canonical_path: observation.canonical_path, + }) } } @@ -130,6 +174,8 @@ pub struct ReplaceSuccess { pub struct PolicyStore { configured_path: PathBuf, + default_paths: Option<[PathBuf; 2]>, + default_managed_selected: std::sync::atomic::AtomicBool, source: PolicyConfigurationSource, snapshot: RwLock>, writer: tokio::sync::Mutex, @@ -139,7 +185,11 @@ pub struct PolicyStore { impl PolicyStore { pub fn load(configured_path: Option) -> Arc { - Self::load_with_storage(configured_path, Arc::new(FilePolicyStorage), Monitoring::Initializing) + Self::load_with_storage( + configured_path, + Arc::new(FilePolicyStorage::new()), + Monitoring::Initializing, + ) } fn load_with_storage( @@ -147,18 +197,36 @@ impl PolicyStore { storage: Arc, monitoring: Monitoring, ) -> Arc { - let (configured_path, source) = match configured_path { - Some(path) => (path, PolicyConfigurationSource::ConfiguredPath), - None => ( - crate::policy_loader::find_default_policy() - .unwrap_or_else(|_| crate::policy_loader::default_policy_candidate()), - PolicyConfigurationSource::DefaultPath, - ), + let (mut configured_path, default_paths, source) = match configured_path { + Some(path) => (path, None, PolicyConfigurationSource::ConfiguredPath), + None => { + let [managed, legacy] = windows::default_policy_paths(); + ( + windows::select_default_policy_path(managed.clone(), legacy.clone()), + Some([managed, legacy]), + PolicyConfigurationSource::DefaultPath, + ) + } }; - let observation = storage.observe(source, &configured_path); + let mut default_managed_selected = default_paths + .as_ref() + .is_some_and(|[managed, _]| crate::policy_security::windows_paths_equal(&configured_path, managed)); + let mut observation = storage.observe(source, &configured_path); + if let Some([managed, legacy]) = &default_paths + && !default_managed_selected + { + let final_path = windows::select_default_policy_path(managed.clone(), legacy.clone()); + if crate::policy_security::windows_paths_equal(&final_path, managed) { + configured_path = final_path; + default_managed_selected = true; + observation = storage.observe(source, &configured_path); + } + } let snapshot = Arc::new(snapshot_from_observation(observation, random_store_token())); Arc::new(Self { configured_path, + default_paths, + default_managed_selected: std::sync::atomic::AtomicBool::new(default_managed_selected), source, snapshot: RwLock::new(snapshot), writer: tokio::sync::Mutex::new(monitoring), @@ -180,8 +248,61 @@ impl PolicyStore { management_from_snapshot(&snapshot, self.source) } - pub(crate) fn configured_path(&self) -> PathBuf { - self.snapshot().configured_path.clone() + fn observation_path(&self) -> PathBuf { + match &self.default_paths { + Some([managed, _]) if self.default_managed_selected.load(std::sync::atomic::Ordering::Acquire) => { + managed.clone() + } + Some([managed, legacy]) => { + let selected = windows::select_default_policy_path(managed.clone(), legacy.clone()); + if crate::policy_security::windows_paths_equal(&selected, managed) { + self.default_managed_selected + .store(true, std::sync::atomic::Ordering::Release); + } + selected + } + None => self.configured_path.clone(), + } + } + + fn observe_storage(&self, retain_for_write: bool) -> (PathBuf, Observation) { + let path = self.observation_path(); + let observation = if retain_for_write { + self.storage.observe_for_write(self.source, &path) + } else { + self.storage.observe(self.source, &path) + }; + let Some([managed, legacy]) = &self.default_paths else { + return (path, observation); + }; + if self.default_managed_selected.load(std::sync::atomic::Ordering::Acquire) + || crate::policy_security::windows_paths_equal(&path, managed) + { + return (path, observation); + } + + let final_path = windows::select_default_policy_path(managed.clone(), legacy.clone()); + if crate::policy_security::windows_paths_equal(&final_path, managed) { + self.default_managed_selected + .store(true, std::sync::atomic::Ordering::Release); + if retain_for_write { + ( + final_path.clone(), + self.storage.observe_for_write(self.source, &final_path), + ) + } else { + (final_path.clone(), self.storage.observe(self.source, &final_path)) + } + } else { + (path, observation) + } + } + + pub(crate) fn watched_paths(&self) -> Vec { + match &self.default_paths { + Some(paths) => paths.to_vec(), + None => vec![self.snapshot().configured_path.clone()], + } } pub fn validate_draft(&self, raw: &serde_json::Value) -> PolicyValidationResult { @@ -201,7 +322,7 @@ impl PolicyStore { if *monitoring != Monitoring::Available { return self.management_snapshot(); } - let observation = self.storage.observe(self.source, &self.configured_path); + let (_, observation) = self.observe_storage(false); let management = self.publish_observation(observation); tracing::info!(?cause, state = ?management.state, "Reloaded package broker policy"); management @@ -212,7 +333,8 @@ impl PolicyStore { if *monitoring != Monitoring::Initializing { return self.management_snapshot(); } - let management = self.publish_observation(self.storage.observe(self.source, &self.configured_path)); + let (_, observation) = self.observe_storage(false); + let management = self.publish_observation(observation); *monitoring = Monitoring::Available; management } @@ -232,8 +354,10 @@ impl PolicyStore { }), write_capability: PolicyWriteCapability::ReadOnly, read_only_reason: Some(PolicyReadOnlyReason::ManagementDisabled), - configured_path: previous.configured_path.clone(), - fingerprint: DiskFingerprint(Sha256::digest(b"watcher unavailable").into()), + canonical_path: previous.configured_path.clone(), + fingerprint: windows::unavailable_fingerprint(previous.configured_path.clone()), + hosting_dir: None, + retained_target: None, }; self.publish_observation(observation); } @@ -248,7 +372,7 @@ impl PolicyStore { )); } let previous = self.snapshot(); - let observation = self.storage.observe(self.source, &self.configured_path); + let (write_configured_path, mut observation) = self.observe_storage(true); let fresh_token = token_for(&previous, &observation.fingerprint); // Both conflict modes require this exact token. @@ -320,15 +444,17 @@ impl PolicyStore { .map_err(|_| error_response(ErrorCode::InternalError, "failed to serialize the committed policy"))?; let persisted = if request.operation == PolicyReplacementOperation::Create { - self.storage.create(&self.configured_path, &observation, &bytes) + self.storage + .create(self.source, &write_configured_path, &observation, &bytes) } else { - self.storage.replace(&self.configured_path, &observation, &bytes) + self.storage + .replace(self.source, &write_configured_path, &mut observation, &bytes) }; let persisted = match persisted { Ok(persisted) => persisted, Err(WriteFailure::PrePublication(error)) => { tracing::warn!(error = format!("{error:#}"), "Policy persistence failed"); - let current = self.storage.observe(self.source, &self.configured_path); + let (_, current) = self.observe_storage(false); if current.fingerprint != observation.fingerprint { let management = self.publish_observation(current); return Err(error_with_management( @@ -342,12 +468,31 @@ impl PolicyStore { "failed to persist the policy", )); } + Err(WriteFailure::ConcurrentChange(error)) => { + tracing::warn!( + error = format!("{error:#}"), + "Conditional policy publication observed a concurrent storage change" + ); + let (_, current) = self.observe_storage(false); + if current.fingerprint == observation.fingerprint { + return Err(error_response( + ErrorCode::PolicyPersistenceFailed, + "failed to conditionally persist the policy", + )); + } + let management = self.publish_observation(current); + return Err(error_with_management( + ErrorCode::StalePolicyStoreToken, + "the policy storage changed during publication; retry with the current store token", + management, + )); + } Err(WriteFailure::PostPublication(error)) => { tracing::warn!( error = format!("{error:#}"), "Published policy failed authoritative reload" ); - let current = self.storage.observe(self.source, &self.configured_path); + let (_, current) = self.observe_storage(false); let management = self.publish_observation(current); return Err(error_with_management( ErrorCode::PolicyActivationFailed, @@ -357,16 +502,17 @@ impl PolicyStore { } }; - if persisted.observation.state != PolicyManagementState::Active { - let management = self.publish_observation(persisted.observation); - return Err(error_with_management( - ErrorCode::PolicyActivationFailed, - "the policy was published but failed authoritative reload", - management, - )); - } - let token = token_for(&previous, &persisted.observation.fingerprint); - let snapshot = Arc::new(snapshot_from_observation(persisted.observation, token)); + let token = token_for(&previous, &persisted.fingerprint); + let snapshot = Arc::new(Snapshot { + state: PolicyManagementState::Active, + policy: Some(Arc::new(persisted.policy.clone())), + invalid_diagnostics: None, + write_capability: persisted.write_capability, + read_only_reason: persisted.read_only_reason, + configured_path: persisted.canonical_path, + store_token: token, + fingerprint: persisted.fingerprint, + }); *self.snapshot.write().expect("policy store snapshot lock poisoned") = snapshot; Ok(ReplaceSuccess { @@ -400,9 +546,8 @@ impl PolicyStore { #[cfg(test)] pub(crate) fn test_set_active(&self, policy: Arc) { let previous = self.snapshot(); - let fingerprint = DiskFingerprint( - Sha256::digest(serde_json::to_vec(policy.as_ref()).expect("test policy serializes")).into(), - ); + let bytes = serde_json::to_vec(policy.as_ref()).expect("test policy serializes"); + let fingerprint = DiskFingerprint::test_active(&bytes, 1, 1, 1, 1); let snapshot = Arc::new(Snapshot { state: PolicyManagementState::Active, policy: Some(policy), @@ -457,7 +602,7 @@ fn snapshot_from_observation(observation: Observation, store_token: PolicyStoreT invalid_diagnostics: observation.invalid_diagnostics, write_capability: observation.write_capability, read_only_reason: observation.read_only_reason, - configured_path: observation.configured_path, + configured_path: observation.canonical_path, store_token, fingerprint: observation.fingerprint, } @@ -486,7 +631,7 @@ fn token_for(previous: &Snapshot, fingerprint: &DiskFingerprint) -> PolicyStoreT } fn random_store_token() -> PolicyStoreToken { - format!("store:{}", uuid::Uuid::new_v4().simple()).into() + windows::random_store_token() } fn error_response(code: ErrorCode, message: impl Into) -> ErrorResponse { @@ -525,473 +670,54 @@ fn error_with_management( response } -fn observe_file(_source: PolicyConfigurationSource, configured_path: &Path) -> Observation { - let mut hasher = Sha256::new(); - for unit in configured_path.as_os_str().encode_wide() { - hasher.update(unit.to_le_bytes()); - } - - if !is_safe_path_shape(configured_path) { - return invalid_observation( - configured_path.to_owned(), - PolicyWriteCapability::Unsupported, - Some(PolicyReadOnlyReason::UnsafePath), - validation::DiskFailureReason::InsecureStorage, - hasher, - ); - } - - let extension = configured_path - .extension() - .and_then(|value| value.to_str()) - .unwrap_or_default() - .to_ascii_lowercase(); - if extension != "json" { - return invalid_observation( - configured_path.to_owned(), - PolicyWriteCapability::Unsupported, - Some(PolicyReadOnlyReason::UnsupportedFormat), - validation::DiskFailureReason::UnsupportedFormat, - hasher, - ); - } - - let display_path = match canonical_display_path(configured_path) { - Ok(path) => path, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return missing_observation( - configured_path.to_owned(), - PolicyWriteCapability::ReadOnly, - Some(PolicyReadOnlyReason::InsufficientPermissions), - hasher, - ); - } - Err(error) => { - tracing::warn!(error = %error, "Failed to resolve policy path"); - return invalid_observation( - configured_path.to_owned(), - PolicyWriteCapability::ReadOnly, - Some(PolicyReadOnlyReason::UnsafePath), - validation::DiskFailureReason::InsecureStorage, - hasher, - ); - } - }; - for unit in display_path.as_os_str().encode_wide() { - hasher.update(unit.to_le_bytes()); - } - - let Some(parent) = display_path.parent() else { - return invalid_observation( - display_path, - PolicyWriteCapability::ReadOnly, - Some(PolicyReadOnlyReason::UnsafePath), - validation::DiskFailureReason::Unreadable, - hasher, - ); - }; - let directory = match open_directory(parent) { - Ok(directory) => directory, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return missing_observation( - display_path, - PolicyWriteCapability::ReadOnly, - Some(PolicyReadOnlyReason::InsufficientPermissions), - hasher, - ); - } - Err(error) => { - tracing::warn!(error = %error, "Failed to open policy directory"); - return invalid_observation( - display_path, - PolicyWriteCapability::ReadOnly, - Some(PolicyReadOnlyReason::UnsafePath), - validation::DiskFailureReason::InsecureStorage, - hasher, - ); - } - }; - hash_file_identity(&directory, &mut hasher); - let directory_safe = match policy_security::verify_policy_path_ancestors(configured_path) - .and_then(|()| policy_security::verify_policy_path_ancestors(&display_path)) - .and_then(|()| { - let current_path = canonical_display_path(configured_path) - .context("failed to resolve policy path after security validation")?; - if policy_security::windows_paths_equal(&display_path, ¤t_path) { - Ok(()) - } else { - anyhow::bail!("policy path canonical chain changed during security validation") - } - }) - .and_then(|()| policy_security::verify_policy_directory_security(&directory)) - .and_then(|()| policy_security::security_state_digest(&directory)) - { - Ok(digest) => { - hasher.update(digest); - true - } - Err(error) => { - tracing::warn!( - error = format!("{error:#}"), - "Policy directory security validation failed" - ); - false - } - }; - if !directory_safe { - return invalid_observation( - display_path, - PolicyWriteCapability::ReadOnly, - Some(PolicyReadOnlyReason::UnsafePath), - validation::DiskFailureReason::InsecureStorage, - hasher, - ); - } - let atomic_filesystem = directory_safe && supports_atomic_replace(parent); - let capability = if !atomic_filesystem { - PolicyWriteCapability::Unsupported - } else { - PolicyWriteCapability::Writable - }; - let read_only_reason = match capability { - PolicyWriteCapability::Writable => None, - PolicyWriteCapability::Unsupported => Some(PolicyReadOnlyReason::UnsupportedFileSystem), - PolicyWriteCapability::ReadOnly => unreachable!("unsafe directories returned above"), - }; - - let mut file = match OpenOptions::new() - .read(true) - .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) - .open(&display_path) - { - Ok(file) => file, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return missing_observation(display_path, capability, read_only_reason, hasher); - } - Err(error) => { - tracing::warn!(error = %error, "Failed to open configured policy"); - return invalid_observation( - display_path, - capability, - read_only_reason, - validation::DiskFailureReason::Unreadable, - hasher, - ); - } - }; - hash_file_identity(&file, &mut hasher); - if let Err(error) = policy_security::verify_policy_file_path(&file, &display_path) - .and_then(|()| policy_security::verify_policy_file_security(&file)) - { - tracing::warn!( - error = format!("{error:#}"), - "Configured policy security validation failed" - ); - return invalid_observation( - display_path, - PolicyWriteCapability::ReadOnly, - Some(PolicyReadOnlyReason::UnsafePath), - validation::DiskFailureReason::InsecureStorage, - hasher, - ); - } - match policy_security::security_state_digest(&file) { - Ok(digest) => hasher.update(digest), - Err(error) => { - tracing::warn!( - error = format!("{error:#}"), - "Failed to fingerprint configured policy security" - ); - return invalid_observation( - display_path, - PolicyWriteCapability::ReadOnly, - Some(PolicyReadOnlyReason::UnsafePath), - validation::DiskFailureReason::InsecureStorage, - hasher, - ); - } - } - let mut bytes = Vec::new(); - if let Err(error) = file.read_to_end(&mut bytes) { - tracing::warn!(error = %error, "Failed to read configured policy"); - return invalid_observation( - display_path, - capability, - read_only_reason, - validation::DiskFailureReason::Unreadable, - hasher, - ); - } - hasher.update(&bytes); - let policy = serde_json::from_slice::(&bytes); - let policy = match policy { - Ok(policy) => policy, - Err(error) => { - tracing::warn!(error = %error, "Configured policy parsing failed"); - return invalid_observation( - display_path, - capability, - read_only_reason, - validation::DiskFailureReason::MalformedContent, - hasher, - ); - } - }; - let committed_validation = validation::validate_committed_policy(&policy); - if !committed_validation.is_valid { - tracing::warn!( - findings = ?committed_validation.findings, - "Configured policy semantic validation failed" - ); - return invalid_observation( - display_path, - capability, - read_only_reason, - validation::DiskFailureReason::FailedSemanticValidation, - hasher, - ); - } - - Observation { - state: PolicyManagementState::Active, - policy: Some(policy), - invalid_diagnostics: None, - write_capability: capability, - read_only_reason, - configured_path: display_path, - fingerprint: DiskFingerprint(hasher.finalize().into()), - } -} - -fn publish_file( - configured_path: &Path, - observation: &Observation, - bytes: &[u8], - replace: bool, -) -> Result { - let path = &observation.configured_path; - let parent = path - .parent() - .ok_or_else(|| WriteFailure::PrePublication(anyhow::anyhow!("policy path has no parent")))?; - let leaf = path - .file_name() - .ok_or_else(|| WriteFailure::PrePublication(anyhow::anyhow!("policy path has no file name")))?; - let temp_path = parent.join(format!( - ".{}.{}.tmp", - leaf.to_string_lossy(), - uuid::Uuid::new_v4().simple() - )); - let prepared = (|| { - let mut temp = OpenOptions::new().write(true).create_new(true).open(&temp_path)?; - temp.write_all(bytes)?; - temp.sync_all()?; - policy_security::verify_policy_file_security(&temp)?; - drop(temp); - - let from = wide_path(&temp_path); - let to = wide_path(path); - let flags = if replace { - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH - } else { - MOVEFILE_WRITE_THROUGH - }; - // SAFETY: Both buffers are live, nul-terminated absolute paths. - unsafe { MoveFileExW(PCWSTR(from.as_ptr()), PCWSTR(to.as_ptr()), flags) }?; - Ok::<(), anyhow::Error>(()) - })(); - if let Err(error) = prepared { - let _ = std::fs::remove_file(&temp_path); - return Err(WriteFailure::PrePublication(error)); - } - - let reloaded = (|| { - let reloaded = observe_file(PolicyConfigurationSource::ConfiguredPath, configured_path); - let policy = reloaded - .policy - .clone() - .ok_or_else(|| anyhow::anyhow!("published policy failed authoritative reload"))?; - let expected: serde_json::Value = serde_json::from_slice(bytes)?; - if serde_json::to_value(&policy)? != expected { - anyhow::bail!("published policy does not match the requested committed document"); - } - Ok(PersistedPolicy { - policy, - observation: reloaded, - }) - })(); - reloaded.map_err(WriteFailure::PostPublication) -} - -fn missing_observation( - path: PathBuf, - capability: PolicyWriteCapability, - reason: Option, - mut hasher: Sha256, -) -> Observation { - hasher.update(b"missing"); - Observation { - state: PolicyManagementState::Missing, - policy: None, - invalid_diagnostics: None, - write_capability: capability, - read_only_reason: reason, - configured_path: path, - fingerprint: DiskFingerprint(hasher.finalize().into()), - } -} - -fn invalid_observation( - path: PathBuf, - capability: PolicyWriteCapability, - reason: Option, - failure: validation::DiskFailureReason, - mut hasher: Sha256, -) -> Observation { - hasher.update(format!("{failure:?}")); - Observation { - state: PolicyManagementState::Invalid, - policy: None, - invalid_diagnostics: Some(InvalidPolicyDiagnostics { - diagnostics_version: API_VERSION_STR.into(), - findings: vec![validation::disk_failure_finding(failure)], - }), - write_capability: capability, - read_only_reason: reason, - configured_path: path, - fingerprint: DiskFingerprint(hasher.finalize().into()), - } -} - -fn is_safe_path_shape(path: &Path) -> bool { - let raw = path.as_os_str().to_string_lossy(); - path.is_absolute() - && path.file_name().is_some() - && !raw.split(['\\', '/']).any(|segment| matches!(segment, "." | "..")) - && path - .components() - .all(|component| !matches!(component, Component::CurDir | Component::ParentDir)) -} - -fn canonical_display_path(path: &Path) -> std::io::Result { - let parent = path - .parent() - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "policy path has no parent"))?; - let leaf = path - .file_name() - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "policy path has no file name"))?; - Ok(parent.canonicalize()?.join(leaf)) -} - -fn open_directory(path: &Path) -> std::io::Result { - OpenOptions::new() - .access_mode(FILE_READ_ATTRIBUTES.0 | READ_CONTROL.0) - .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0) - .open(path) -} - -fn supports_atomic_replace(path: &Path) -> bool { - let path = wide_path(path); - let mut root = vec![0; 512]; - // SAFETY: The path is nul-terminated and the root buffer is writable. - if unsafe { GetVolumePathNameW(PCWSTR(path.as_ptr()), &mut root) }.is_err() { - return false; - } - let mut filesystem = vec![0; 261]; - // SAFETY: GetVolumePathNameW returned a nul-terminated root and the output buffer is writable. - if unsafe { GetVolumeInformationW(PCWSTR(root.as_ptr()), None, None, None, None, Some(&mut filesystem)) }.is_err() { - return false; - } - let length = filesystem - .iter() - .position(|unit| *unit == 0) - .unwrap_or(filesystem.len()); - matches!( - String::from_utf16_lossy(&filesystem[..length]).as_str(), - "NTFS" | "ReFS" - ) -} - -fn hash_file_identity(file: &File, hasher: &mut Sha256) { - let mut info = FILE_ID_INFO::default(); - let size = u32::try_from(size_of::()).expect("FILE_ID_INFO size fits u32"); - // SAFETY: The file handle and correctly sized output buffer are valid for the call. - if unsafe { GetFileInformationByHandleEx(HANDLE(file.as_raw_handle()), FileIdInfo, (&raw mut info).cast(), size) } - .is_ok() - { - hasher.update(info.VolumeSerialNumber.to_le_bytes()); - hasher.update(info.FileId.Identifier); - } -} - -fn wide_path(path: &Path) -> Vec { - path.as_os_str().encode_wide().chain(std::iter::once(0)).collect() +#[cfg(test)] +fn observe_file(source: PolicyConfigurationSource, path: &Path) -> Observation { + windows::observe(source, path, &windows::AtomicityProbeCache::new()) } #[cfg(test)] struct TestStorage { observation: parking_lot::Mutex, fail_persist: std::sync::atomic::AtomicBool, + fail_concurrent_check: std::sync::atomic::AtomicBool, + fail_target_retention: std::sync::atomic::AtomicBool, + race_before_persist: parking_lot::Mutex>, + post_persist_capability: parking_lot::Mutex)>>, + persisted_configured_paths: parking_lot::Mutex>, } #[cfg(test)] impl TestStorage { fn new(policy: Option) -> Self { - let state = if policy.is_some() { - PolicyManagementState::Active - } else { - PolicyManagementState::Missing - }; Self { - observation: parking_lot::Mutex::new(Observation { - state, - policy, - invalid_diagnostics: None, - write_capability: PolicyWriteCapability::Writable, - read_only_reason: None, - configured_path: PathBuf::from(r"C:\policy.json"), - fingerprint: DiskFingerprint([0; 32]), - }), + observation: parking_lot::Mutex::new(test_observation(policy, false, 0)), fail_persist: std::sync::atomic::AtomicBool::new(false), + fail_concurrent_check: std::sync::atomic::AtomicBool::new(false), + fail_target_retention: std::sync::atomic::AtomicBool::new(false), + race_before_persist: parking_lot::Mutex::new(None), + post_persist_capability: parking_lot::Mutex::new(None), + persisted_configured_paths: parking_lot::Mutex::new(Vec::new()), } } fn invalid() -> Self { - let mut storage = Self::new(None); - storage.observation = parking_lot::Mutex::new(Observation { - state: PolicyManagementState::Invalid, - policy: None, - invalid_diagnostics: Some(InvalidPolicyDiagnostics { - diagnostics_version: API_VERSION_STR.into(), - findings: vec![validation::disk_failure_finding( - validation::DiskFailureReason::MalformedContent, - )], - }), - write_capability: PolicyWriteCapability::Writable, - read_only_reason: None, - configured_path: PathBuf::from(r"C:\policy.json"), - fingerprint: DiskFingerprint([1; 32]), - }); - storage + Self { + observation: parking_lot::Mutex::new(test_observation(None, true, 1)), + fail_persist: std::sync::atomic::AtomicBool::new(false), + fail_concurrent_check: std::sync::atomic::AtomicBool::new(false), + fail_target_retention: std::sync::atomic::AtomicBool::new(false), + race_before_persist: parking_lot::Mutex::new(None), + post_persist_capability: parking_lot::Mutex::new(None), + persisted_configured_paths: parking_lot::Mutex::new(Vec::new()), + } } fn set_disk_state(&self, policy: Option, invalid: bool, marker: u8) { - let mut observation = self.observation.lock(); - observation.state = if invalid { - PolicyManagementState::Invalid - } else if policy.is_some() { - PolicyManagementState::Active - } else { - PolicyManagementState::Missing - }; - observation.policy = policy; - observation.invalid_diagnostics = invalid.then(|| InvalidPolicyDiagnostics { - diagnostics_version: API_VERSION_STR.into(), - findings: vec![validation::disk_failure_finding( - validation::DiskFailureReason::MalformedContent, - )], - }); - observation.fingerprint = DiskFingerprint([marker; 32]); + *self.observation.lock() = test_observation(policy, invalid, marker); + } + + fn race_before_next_persist(&self, policy: PolicyDocument) { + *self.race_before_persist.lock() = Some(policy); } } @@ -1001,21 +727,44 @@ impl PolicyStorage for TestStorage { clone_observation(&self.observation.lock()) } + fn observe_for_write(&self, source: PolicyConfigurationSource, path: &Path) -> Observation { + let mut observation = self.observe(source, path); + if observation.state != PolicyManagementState::Missing + && !self + .fail_target_retention + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + observation.retained_target = Some(windows::RetainedPolicyFile::for_fake(observation.fingerprint.clone())); + } + observation + } + fn create( &self, - _configured_path: &Path, + _source: PolicyConfigurationSource, + configured_path: &Path, observation: &Observation, bytes: &[u8], ) -> Result { + self.persisted_configured_paths.lock().push(configured_path.to_owned()); self.persist(observation, bytes) } fn replace( &self, - _configured_path: &Path, - observation: &Observation, + _source: PolicyConfigurationSource, + configured_path: &Path, + observation: &mut Observation, bytes: &[u8], ) -> Result { + self.persisted_configured_paths.lock().push(configured_path.to_owned()); + let retained = observation + .retained_target + .take() + .ok_or_else(|| WriteFailure::ConcurrentChange(anyhow::anyhow!("missing retained test target")))?; + retained + .verify_matches(&observation.fingerprint) + .map_err(WriteFailure::ConcurrentChange)?; self.persist(observation, bytes) } } @@ -1028,21 +777,83 @@ impl TestStorage { "injected persistence failure" ))); } + if self + .fail_concurrent_check + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + return Err(WriteFailure::ConcurrentChange(anyhow::anyhow!( + "injected identity query failure" + ))); + } + if let Some(external) = self.race_before_persist.lock().take() { + *self.observation.lock() = test_observation(Some(external), false, 9); + return Err(WriteFailure::ConcurrentChange(anyhow::anyhow!( + "injected external policy replacement" + ))); + } let policy: PolicyDocument = serde_json::from_slice(bytes).map_err(|error| WriteFailure::PrePublication(error.into()))?; let mut next = clone_observation(observation); next.state = PolicyManagementState::Active; next.policy = Some(policy.clone()); next.invalid_diagnostics = None; - next.fingerprint = DiskFingerprint(Sha256::digest(bytes).into()); + next.fingerprint = DiskFingerprint::test_active(bytes, 2, 1, 1, 1); + if let Some((capability, reason)) = self.post_persist_capability.lock().take() { + next.write_capability = capability; + next.read_only_reason = reason; + next.fingerprint = DiskFingerprint::test_active(bytes, 2, 1, 1, 2); + } *self.observation.lock() = clone_observation(&next); Ok(PersistedPolicy { policy, - observation: next, + fingerprint: next.fingerprint, + write_capability: next.write_capability, + read_only_reason: next.read_only_reason, + canonical_path: next.canonical_path, }) } } +#[cfg(test)] +fn test_observation(policy: Option, invalid: bool, marker: u8) -> Observation { + let state = if invalid { + PolicyManagementState::Invalid + } else if policy.is_some() { + PolicyManagementState::Active + } else { + PolicyManagementState::Missing + }; + let bytes = policy + .as_ref() + .map(|policy| serde_json::to_vec(policy).expect("test policy serializes")) + .unwrap_or_default(); + let fingerprint = match state { + PolicyManagementState::Active => DiskFingerprint::test_active(&bytes, marker.into(), 1, 1, 1), + PolicyManagementState::Missing => DiskFingerprint::test_missing(marker.into(), 1), + PolicyManagementState::Invalid => DiskFingerprint::test_invalid(&bytes, marker.into(), 1, 1, 1), + }; + Observation { + state, + policy, + invalid_diagnostics: invalid.then(|| InvalidPolicyDiagnostics { + diagnostics_version: API_VERSION_STR.into(), + findings: vec![validation::disk_failure_finding( + validation::DiskFailureReason::MalformedContent, + )], + }), + fingerprint, + write_capability: PolicyWriteCapability::Writable, + read_only_reason: None, + canonical_path: PathBuf::from(r"C:\policy.json"), + hosting_dir: Some(windows::VerifiedHostingDirectory::for_fake_storage( + PathBuf::from(r"C:\"), + windows::test_identity(1), + windows::test_security_digest(1), + )), + retained_target: None, + } +} + #[cfg(test)] fn clone_observation(observation: &Observation) -> Observation { Observation { @@ -1051,7 +862,374 @@ fn clone_observation(observation: &Observation) -> Observation { invalid_diagnostics: observation.invalid_diagnostics.clone(), write_capability: observation.write_capability, read_only_reason: observation.read_only_reason, - configured_path: observation.configured_path.clone(), + canonical_path: observation.canonical_path.clone(), fingerprint: observation.fingerprint.clone(), + hosting_dir: Some(windows::VerifiedHostingDirectory::for_fake_storage( + PathBuf::from(r"C:\"), + windows::test_identity(1), + windows::test_security_digest(1), + )), + retained_target: None, + } +} + +#[cfg(test)] +mod storage_tests { + use now_policy::PolicyDraftDocument; + use now_policy_api::{PolicyConflictHandling, PolicyReplacementRequestKind}; + + struct DefaultTransitionStorage { + managed: PathBuf, + legacy_policy: PolicyDocument, + managed_policy: parking_lot::RwLock>, + publish_managed_while_observing_legacy: std::sync::atomic::AtomicBool, + } + + impl PolicyStorage for DefaultTransitionStorage { + fn observe(&self, _source: PolicyConfigurationSource, path: &Path) -> Observation { + let managed = crate::policy_security::windows_paths_equal(path, &self.managed); + if !managed + && self + .publish_managed_while_observing_legacy + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + std::fs::create_dir_all(self.managed.parent().expect("managed path has a parent")) + .expect("create managed directory"); + std::fs::write(&self.managed, b"managed").expect("publish managed marker"); + } + let (policy, invalid, marker) = if managed { + let managed_policy = self.managed_policy.read().clone(); + let invalid = managed_policy.is_none(); + (managed_policy, invalid, 9) + } else { + (Some(self.legacy_policy.clone()), false, 1) + }; + let mut observation = test_observation(policy, invalid, marker); + observation.canonical_path = path.to_owned(); + observation + } + + fn create( + &self, + _source: PolicyConfigurationSource, + _configured_path: &Path, + _observation: &Observation, + _bytes: &[u8], + ) -> Result { + unreachable!("default transition tests do not write") + } + + fn replace( + &self, + _source: PolicyConfigurationSource, + _configured_path: &Path, + _observation: &mut Observation, + _bytes: &[u8], + ) -> Result { + unreachable!("default transition tests do not write") + } + } + + fn default_transition_store(paths: [PathBuf; 2], storage: Arc) -> Arc { + let configured_path = windows::select_default_policy_path(paths[0].clone(), paths[1].clone()); + let managed_selected = crate::policy_security::windows_paths_equal(&configured_path, &paths[0]); + let observation = storage.observe(PolicyConfigurationSource::DefaultPath, &configured_path); + Arc::new(PolicyStore { + configured_path, + default_paths: Some(paths), + default_managed_selected: std::sync::atomic::AtomicBool::new(managed_selected), + source: PolicyConfigurationSource::DefaultPath, + snapshot: RwLock::new(Arc::new(snapshot_from_observation(observation, random_store_token()))), + writer: tokio::sync::Mutex::new(Monitoring::Available), + storage, + receipt_key: receipt::ReceiptKey::generate(), + }) + } + + use super::*; + + fn draft(id: &str) -> PolicyDraftDocument { + serde_json::from_value(serde_json::json!({ + "$schema": now_policy::POLICY_DRAFT_SCHEMA_URI, + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { "Id": id, "Publisher": "Test" }, + "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Rules": [] + })) + .expect("valid draft") + } + + fn policy(id: &str, revision: u32) -> PolicyDocument { + draft(id) + .into_policy_document(revision, Utc::now()) + .expect("valid committed policy") + } + + fn update_request(store: &PolicyStore) -> PolicyReplacementRequest { + let raw = serde_json::to_value(draft("current")).expect("serialize draft"); + let validation = store.validate_draft(&raw); + PolicyReplacementRequest { + request_kind: PolicyReplacementRequestKind, + request_version: API_VERSION_STR.into(), + expected_store_token: store.management_snapshot().store_token, + operation: PolicyReplacementOperation::Update, + conflict_handling: PolicyConflictHandling::Reject, + warnings_acknowledged: false, + draft: raw, + validation_receipt: validation.validation_receipt.expect("valid receipt"), + } + } + + #[tokio::test] + async fn concurrent_external_replacement_is_preserved_and_published() { + let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); + let store = PolicyStore::load_with_storage( + Some(PathBuf::from(r"C:\policy.json")), + Arc::clone(&storage) as Arc, + Monitoring::Available, + ); + let request = update_request(&store); + storage.race_before_next_persist(policy("external", 7)); + + let error = store.replace(request).await.expect_err("external replacement wins"); + + assert_eq!(error.code, ErrorCode::StalePolicyStoreToken); + assert_eq!( + store.active_policy().expect("external policy is active").metadata.id.0, + "external" + ); + assert_eq!( + store + .active_policy() + .expect("external policy is active") + .metadata + .revision, + 7 + ); + } + + #[tokio::test] + async fn failed_identity_check_without_change_is_a_persistence_failure() { + let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); + let store = PolicyStore::load_with_storage( + Some(PathBuf::from(r"C:\policy.json")), + Arc::clone(&storage) as Arc, + Monitoring::Available, + ); + let request = update_request(&store); + let previous_token = store.management_snapshot().store_token; + storage + .fail_concurrent_check + .store(true, std::sync::atomic::Ordering::SeqCst); + + let error = store.replace(request).await.expect_err("identity check fails"); + + assert_eq!(error.code, ErrorCode::PolicyPersistenceFailed); + assert_eq!(store.management_snapshot().store_token, previous_token); + assert_eq!( + store + .active_policy() + .expect("previous policy remains active") + .metadata + .revision, + 1 + ); + } + + #[tokio::test] + async fn failed_target_retention_preserves_the_active_snapshot() { + let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); + let store = PolicyStore::load_with_storage( + Some(PathBuf::from(r"C:\policy.json")), + Arc::clone(&storage) as Arc, + Monitoring::Available, + ); + let request = update_request(&store); + let previous_token = store.management_snapshot().store_token; + storage + .fail_target_retention + .store(true, std::sync::atomic::Ordering::SeqCst); + + let error = store.replace(request).await.expect_err("target retention fails"); + + assert_eq!(error.code, ErrorCode::PolicyPersistenceFailed); + assert_eq!(store.management_snapshot().store_token, previous_token); + assert_eq!( + store + .active_policy() + .expect("previous policy remains active") + .metadata + .revision, + 1 + ); + } + + #[tokio::test] + async fn replacement_returns_authoritative_post_write_capability() { + let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); + let store = PolicyStore::load_with_storage( + Some(PathBuf::from(r"C:\policy.json")), + Arc::clone(&storage) as Arc, + Monitoring::Available, + ); + let request = update_request(&store); + *storage.post_persist_capability.lock() = + Some((PolicyWriteCapability::ReadOnly, Some(PolicyReadOnlyReason::UnsafePath))); + + let success = store.replace(request).await.expect("policy replacement succeeds"); + + assert_eq!(success.management.write_capability, PolicyWriteCapability::ReadOnly); + assert_eq!( + success.management.read_only_reason, + Some(PolicyReadOnlyReason::UnsafePath) + ); + assert_eq!( + store.management_snapshot().write_capability, + PolicyWriteCapability::ReadOnly + ); + } + + #[tokio::test] + async fn custom_watcher_uses_canonical_path_but_writes_reobserve_configured_path() { + let configured = PathBuf::from(r"C:\RUNNER~1\AppData\Local\Temp\policy.json"); + let canonical = PathBuf::from(r"C:\actions\runneradmin\AppData\Local\Temp\policy.json"); + let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); + storage.observation.lock().canonical_path = canonical.clone(); + let store = PolicyStore::load_with_storage( + Some(configured.clone()), + Arc::clone(&storage) as Arc, + Monitoring::Available, + ); + + assert_eq!(store.watched_paths().as_slice(), std::slice::from_ref(&canonical)); + let success = store.replace(update_request(&store)).await.expect("replace policy"); + assert_eq!(&*storage.persisted_configured_paths.lock(), &[configured]); + assert_eq!(store.watched_paths(), [canonical]); + + let post_write_token = success.management.store_token; + let reloaded = store.reload_from_disk(ReloadCause::ExternalChange).await; + assert_eq!(reloaded.store_token, post_write_token); + + let replacement_canonical = PathBuf::from(r"C:\actions\runneradmin\AppData\Local\Temp\replacement\policy.json"); + storage.set_disk_state(Some(policy("current", 2)), false, 9); + storage.observation.lock().canonical_path = replacement_canonical.clone(); + let replaced = store.reload_from_disk(ReloadCause::ExternalChange).await; + assert_ne!(replaced.store_token, post_write_token); + assert_eq!(store.watched_paths(), [replacement_canonical]); + } + + #[tokio::test] + async fn default_store_switches_from_legacy_when_managed_policy_appears() { + let dir = tempfile::tempdir().expect("create temp directory"); + let managed = dir.path().join("PackageBroker").join(windows::POLICY_FILE_NAME); + let legacy = dir.path().join("Agent").join(windows::POLICY_FILE_NAME); + std::fs::create_dir_all(legacy.parent().expect("legacy path has a parent")).expect("create legacy directory"); + std::fs::write(&legacy, b"legacy").expect("write legacy marker"); + let storage = Arc::new(DefaultTransitionStorage { + managed: managed.clone(), + legacy_policy: policy("legacy", 1), + managed_policy: parking_lot::RwLock::new(Some(policy("managed", 2))), + publish_managed_while_observing_legacy: std::sync::atomic::AtomicBool::new(false), + }); + let store = default_transition_store([managed.clone(), legacy], Arc::clone(&storage)); + assert_eq!( + store.active_policy().expect("legacy policy active").metadata.id.0, + "legacy" + ); + + std::fs::create_dir_all(managed.parent().expect("managed path has a parent")) + .expect("create managed directory"); + std::fs::write(&managed, b"managed").expect("write managed marker"); + store.reload_from_disk(ReloadCause::ExternalChange).await; + + assert_eq!( + store.active_policy().expect("managed policy active").metadata.id.0, + "managed" + ); + assert_eq!( + store.management_snapshot().configured_path, + managed.display().to_string() + ); + + std::fs::remove_file(&managed).expect("remove managed marker"); + *storage.managed_policy.write() = None; + store.reload_from_disk(ReloadCause::ExternalChange).await; + assert!(store.active_policy().is_none(), "managed selection must remain sticky"); + assert_eq!( + store.management_snapshot().configured_path, + managed.display().to_string() + ); + } + + #[tokio::test] + async fn managed_transaction_evidence_after_startup_fails_closed_instead_of_using_legacy() { + let dir = tempfile::tempdir().expect("create temp directory"); + let managed = dir.path().join("PackageBroker").join(windows::POLICY_FILE_NAME); + let legacy = dir.path().join("Agent").join(windows::POLICY_FILE_NAME); + std::fs::create_dir_all(legacy.parent().expect("legacy path has a parent")).expect("create legacy directory"); + std::fs::write(&legacy, b"legacy").expect("write legacy marker"); + let storage = Arc::new(DefaultTransitionStorage { + managed: managed.clone(), + legacy_policy: policy("legacy", 1), + managed_policy: parking_lot::RwLock::new(None), + publish_managed_while_observing_legacy: std::sync::atomic::AtomicBool::new(false), + }); + let store = default_transition_store([managed.clone(), legacy], storage); + assert_eq!( + store.active_policy().expect("legacy policy active").metadata.id.0, + "legacy" + ); + + std::fs::create_dir_all(managed.parent().expect("managed path has a parent")) + .expect("create managed directory"); + let marker = managed.parent().expect("managed path has a parent").join(format!( + ".{}.txn-{}.marker", + windows::POLICY_FILE_NAME, + uuid::Uuid::new_v4() + )); + std::fs::write(marker, b"unsafe remnant").expect("write managed transaction marker"); + store.reload_from_disk(ReloadCause::ExternalChange).await; + + assert!(store.active_policy().is_none()); + assert_eq!(store.management_snapshot().state, PolicyManagementState::Invalid); + assert_eq!( + store.management_snapshot().configured_path, + managed.display().to_string() + ); + } + + #[tokio::test] + async fn managed_policy_created_during_legacy_observation_is_never_published_as_legacy() { + let dir = tempfile::tempdir().expect("create temp directory"); + let managed = dir.path().join("PackageBroker").join(windows::POLICY_FILE_NAME); + let legacy = dir.path().join("Agent").join(windows::POLICY_FILE_NAME); + std::fs::create_dir_all(legacy.parent().expect("legacy path has a parent")).expect("create legacy directory"); + std::fs::write(&legacy, b"legacy").expect("write legacy marker"); + let storage = Arc::new(DefaultTransitionStorage { + managed: managed.clone(), + legacy_policy: policy("legacy", 1), + managed_policy: parking_lot::RwLock::new(Some(policy("managed", 2))), + publish_managed_while_observing_legacy: std::sync::atomic::AtomicBool::new(false), + }); + let store = default_transition_store([managed.clone(), legacy], Arc::clone(&storage)); + assert_eq!( + store.active_policy().expect("legacy policy active").metadata.id.0, + "legacy" + ); + storage + .publish_managed_while_observing_legacy + .store(true, std::sync::atomic::Ordering::SeqCst); + + store.reload_from_disk(ReloadCause::ExternalChange).await; + + assert_eq!( + store.active_policy().expect("managed policy active").metadata.id.0, + "managed" + ); + assert_eq!( + store.management_snapshot().configured_path, + managed.display().to_string() + ); } } diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs new file mode 100644 index 000000000..dc3af059b --- /dev/null +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -0,0 +1,5336 @@ +//! Windows filesystem primitives backing the policy store. +//! +//! Resolves and securely creates the default policy directory. +//! Verifies custom directories and their ancestor chains without modifying them. +//! Captures exact file state as an internal [`DiskFingerprint`] that `PolicyStore::token_for` converts into an opaque token. +//! Publishes crash-safe replacements within the hosting directory. +//! +//! Replacement observations retain the exact target without write or delete sharing. +//! Publication renames that handle to a reserved tombstone, then renames a flushed secure temporary handle to the final leaf without replacing any raced-in content. +//! A durable admin-only marker lets startup restore the exact tombstone or preserve a final file already present after interruption. + +use std::ffi::{OsStr, OsString}; +use std::fs::{File, OpenOptions}; +use std::mem::size_of; +use std::os::windows::ffi::{OsStrExt as _, OsStringExt as _}; +use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, bail, ensure}; +use now_policy::PolicyDocument; +use now_policy_api::{ + API_VERSION_STR, InvalidPolicyDiagnostics, PolicyConfigurationSource, PolicyManagementState, PolicyReadOnlyReason, + PolicyStoreToken, PolicyWriteCapability, +}; +use sha2::{Digest as _, Sha256}; +use win_api_wrappers::str::{U16CStrExt as _, U16CString}; +use win_api_wrappers::undoc::OBJECT_ATTRIBUTES; +use windows::Win32::Foundation::{ + ERROR_ACCESS_DENIED, ERROR_ALREADY_EXISTS, ERROR_FILE_EXISTS, ERROR_INVALID_FUNCTION, ERROR_INVALID_PARAMETER, + ERROR_NOT_SUPPORTED, ERROR_SHARING_VIOLATION, GENERIC_READ, GENERIC_WRITE, HANDLE, NTSTATUS, UNICODE_STRING, + WIN32_ERROR, +}; +use windows::Win32::Storage::FileSystem::{ + CREATE_NEW, CreateFileW, DELETE, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, + FILE_DISPOSITION_FLAG_DELETE, FILE_DISPOSITION_FLAG_IGNORE_READONLY_ATTRIBUTE, + FILE_DISPOSITION_FLAG_POSIX_SEMANTICS, FILE_DISPOSITION_INFO_EX, FILE_DISPOSITION_INFO_EX_FLAGS, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAG_WRITE_THROUGH, FILE_GENERIC_READ, + FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_RENAME_INFO_0, FILE_SHARE_DELETE, + FILE_SHARE_NONE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, FileDispositionInfoEx, FileRenameInfo, + FileRenameInfoEx, GetVolumeInformationW, GetVolumePathNameW, READ_CONTROL, SetFileInformationByHandle, +}; +#[cfg(test)] +use windows::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MoveFileExW}; + +use crate::policy_security::{self, FileIdentity}; +use crate::policy_store::validation; + +/// Base file name for the policy file (a fixed name inside its dedicated directory). +pub(super) const POLICY_FILE_NAME: &str = "package-broker-policy.json"; +const MANAGED_AUTHORITY_MARKER_NAME: &str = ".package-broker-managed-authority.v1"; +const FILE_SYNCHRONIZE: u32 = 0x0010_0000; +const FILE_RENAME_INFORMATION_EX_CLASS: i32 = 65; +const FILE_NON_DIRECTORY_FILE: u32 = 0x0000_0040; +const FILE_OPEN_REPARSE_POINT: u32 = 0x0020_0000; +const OBJ_CASE_INSENSITIVE: u32 = 0x0000_0040; + +#[repr(C)] +struct IoStatusBlock { + status_or_pointer: usize, + information: usize, +} + +#[link(name = "ntdll")] +unsafe extern "system" { + fn NtOpenFile( + file_handle: *mut HANDLE, + desired_access: u32, + object_attributes: *const OBJECT_ATTRIBUTES, + io_status_block: *mut IoStatusBlock, + share_access: u32, + open_options: u32, + ) -> NTSTATUS; + + fn NtSetInformationFile( + file_handle: HANDLE, + io_status_block: *mut IoStatusBlock, + file_information: *const core::ffi::c_void, + length: u32, + file_information_class: i32, + ) -> NTSTATUS; +} + +/// Return the managed and legacy default paths in arbitration order. +pub(super) fn default_policy_paths() -> [PathBuf; 2] { + crate::policy_loader::default_policy_candidates() +} + +pub(super) fn select_default_policy_path(managed: PathBuf, legacy: PathBuf) -> PathBuf { + select_default_policy_path_with(managed, legacy, || {}) +} + +fn select_default_policy_path_with( + managed: PathBuf, + legacy: PathBuf, + before_final_managed_check: impl FnOnce(), +) -> PathBuf { + if managed_default_has_state(&managed) { + return managed; + } + match std::fs::symlink_metadata(&legacy) { + Ok(_) => { + before_final_managed_check(); + if managed_default_has_state(&managed) { + managed + } else { + legacy + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => managed, + Err(_) => legacy, + } +} + +fn managed_default_has_state(managed: &Path) -> bool { + match std::fs::symlink_metadata(managed) { + Ok(_) => return true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return true, + } + let Some(dir) = managed.parent() else { + return true; + }; + match std::fs::symlink_metadata(dir.join(MANAGED_AUTHORITY_MARKER_NAME)) { + Ok(_) => return true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return true, + } + let Some(final_leaf) = managed.file_name().and_then(OsStr::to_str) else { + return true; + }; + let transaction_prefix = OsString::from(format!(".{final_leaf}.txn-")); + let create_prefix = OsString::from(format!(".{final_leaf}.tmp-")); + + let mut handles = match policy_security::retain_policy_no_reparse_directory_chain(dir, "managed policy directory") { + Ok(handles) => handles, + Err(error) + if error + .root_cause() + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) => + { + return false; + } + Err(_) => return true, + }; + let Some(dir_handle) = handles.pop() else { + return true; + }; + if policy_security::verify_policy_directory_security(&dir_handle).is_err() { + return true; + } + let canonical_dir = match policy_security::final_path_from_handle(&dir_handle) { + Ok(path) => path, + Err(_) => return true, + }; + let entries = match std::fs::read_dir(canonical_dir) { + Ok(entries) => entries, + Err(_) => return true, + }; + for entry in entries { + let Ok(entry) = entry else { + return true; + }; + let name = entry.file_name(); + if reserved_name_remainder(&name, &transaction_prefix).map_or(true, |remainder| remainder.is_some()) + || reserved_name_remainder(&name, &create_prefix).map_or(true, |remainder| remainder.is_some()) + { + return true; + } + } + false +} + +fn open_verified_managed_authority_marker(dir_path: &Path) -> anyhow::Result> { + let path = dir_path.join(MANAGED_AUTHORITY_MARKER_NAME); + match std::fs::symlink_metadata(&path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).context("failed to inspect managed authority marker"), + Ok(_) => {} + } + + let file = open_transaction_file(&path).context("failed to open managed authority marker")?; + verify_managed_authority_file(&file, &path)?; + Ok(Some(file)) +} + +#[cfg(test)] +fn verify_managed_authority_marker_if_present(dir_path: &Path) -> anyhow::Result { + open_verified_managed_authority_marker(dir_path).map(|marker| marker.is_some()) +} + +fn verify_managed_authority_file(file: &File, path: &Path) -> anyhow::Result<()> { + policy_security::verify_policy_file_path(file, path).context("managed authority marker path is invalid")?; + ensure!( + policy_security::file_link_count(file)? == 1, + "managed authority marker has multiple hard links" + ); + policy_security::verify_managed_policy_file_security(file) + .context("managed authority marker security is invalid")?; + _ = policy_security::file_identity(file).context("failed to identify managed authority marker")?; + _ = policy_security::security_state_digest(file) + .context("failed to summarize managed authority marker security")?; + ensure!(file.metadata()?.len() == 0, "managed authority marker is not empty"); + Ok(()) +} + +fn ensure_managed_authority_marker(dir: &File, dir_path: &Path) -> anyhow::Result<()> { + if open_verified_managed_authority_marker(dir_path)?.is_some() { + return Ok(()); + } + + let path = dir_path.join(MANAGED_AUTHORITY_MARKER_NAME); + let marker = match create_secure_transaction_file(&path) { + Ok(marker) => marker, + Err(create_error) => { + return match open_verified_managed_authority_marker(dir_path) { + Ok(Some(_)) => Ok(()), + Ok(None) => Err(create_error).context("failed to create managed authority marker"), + Err(verify_error) => Err(verify_error).context(format!( + "managed authority marker creation also failed: {create_error:#}" + )), + }; + } + }; + marker + .sync_all() + .context("failed to persist managed authority marker")?; + verify_managed_authority_file(&marker, &path)?; + let identity = + policy_security::file_identity(&marker).context("failed to identify new managed authority marker")?; + drop(marker); + verify_probe_directory_entry(dir, OsStr::new(MANAGED_AUTHORITY_MARKER_NAME), identity) + .context("managed authority marker directory entry is invalid") +} + +/// Validate the *shape* of a configured policy path before ever touching disk: it must +/// be an absolute path naming a `.json` (case-insensitive) leaf file, with no `.`/`..` +/// component anywhere and no trailing directory separator. Never applied to the default +/// path, which this crate builds and fully controls itself. +/// +/// This is deliberately independent of any filesystem access (a relative path must never +/// be silently resolved against the process's current directory by some later `open` +/// call) and independent of JSON-vs-other-format content sniffing: the extension alone +/// decides, so a legacy `.yaml`/`.yml` (or extensionless) configured path is rejected +/// up front rather than discovered only when its content fails to parse as JSON. +#[derive(Debug)] +enum ConfiguredPathError { + UnsafeShape(String), + UnsupportedFormat(String), +} + +impl std::fmt::Display for ConfiguredPathError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnsafeShape(message) | Self::UnsupportedFormat(message) => f.write_str(message), + } + } +} + +fn validate_configured_path_shape(path: &Path) -> Result<(), ConfiguredPathError> { + if !path.is_absolute() { + return Err(ConfiguredPathError::UnsafeShape(format!( + "configured policy path must be absolute: {}", + path.display() + ))); + } + + let raw = path.as_os_str().to_string_lossy(); + if raw.ends_with('\\') || raw.ends_with('/') { + return Err(ConfiguredPathError::UnsafeShape(format!( + "configured policy path must not end with a path separator: {}", + path.display() + ))); + } + + // Detected on the *raw* configured string, not via `path.components()`: per + // `Path::components()`'s own documented normalization, an intermediate `.` segment + // (e.g. `C:\foo\.\bar.json`) is silently normalized away and never surfaces as a + // `Component::CurDir` at all, so a components-based check would never catch it. + for segment in raw.split(['\\', '/']) { + if segment == "." { + return Err(ConfiguredPathError::UnsafeShape(format!( + "configured policy path must not contain a '.' component: {}", + path.display() + ))); + } + if segment == ".." { + return Err(ConfiguredPathError::UnsafeShape(format!( + "configured policy path must not contain a '..' component: {}", + path.display() + ))); + } + } + + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + return Err(ConfiguredPathError::UnsafeShape(format!( + "configured policy path must name a file: {}", + path.display() + ))); + }; + + let has_json_extension = Path::new(file_name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")); + if !has_json_extension { + return Err(ConfiguredPathError::UnsupportedFormat(format!( + "configured policy path must name a '.json' file (case-insensitive), got '{file_name}'; \ + the package broker no longer supports any other format" + ))); + } + + Ok(()) +} + +/// Outcome of the one-time filesystem atomic-replace capability probe, classified into +/// the advisory reason it would map to if unwritable. +type ProbeResult = Result<(), (PolicyReadOnlyReason, String)>; + +fn probe_failure_capability(reason: PolicyReadOnlyReason) -> PolicyWriteCapability { + match reason { + PolicyReadOnlyReason::UnsupportedFileSystem => PolicyWriteCapability::Unsupported, + _ => PolicyWriteCapability::ReadOnly, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct AtomicityProbeKey { + identity: FileIdentity, + security_digest: [u8; 32], +} + +#[derive(Clone)] +struct CachedAtomicityProbe { + key: AtomicityProbeKey, + result: ProbeResult, + retry_at: Option, +} + +/// Caches filesystem atomic-replace probes by directory identity and security digest. +/// Directory replacement or ACL changes invalidate every result. +/// Failed probes are retried after a bounded delay so fixed-name collisions recover without restart. +pub(super) struct AtomicityProbeCache { + cached: std::sync::Mutex>, +} + +impl AtomicityProbeCache { + const FAILURE_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); + + pub(super) fn new() -> Self { + Self { + cached: std::sync::Mutex::new(None), + } + } + + /// Returns the cached probe result for `dir`/`dir_identity`/`dir_security_digest`, + /// re-probing (and updating the cache) if this is the first call or either the + /// directory's identity or its own security digest no longer matches what was last + /// cached. + fn get_or_probe(&self, dir: &Path, dir_identity: FileIdentity, dir_security_digest: [u8; 32]) -> ProbeResult { + self.get_or_probe_at(dir, dir_identity, dir_security_digest, std::time::Instant::now()) + } + + fn get_or_probe_at( + &self, + dir: &Path, + dir_identity: FileIdentity, + dir_security_digest: [u8; 32], + now: std::time::Instant, + ) -> ProbeResult { + let key = AtomicityProbeKey { + identity: dir_identity, + security_digest: dir_security_digest, + }; + let mut cached = self.cached.lock().expect("atomicity probe cache lock poisoned"); + + if let Some(cached) = cached.as_ref() + && cached.key == key + && cached.retry_at.is_none_or(|retry_at| now < retry_at) + { + return cached.result.clone(); + } + + let result = probe_write_capability(dir).map_err(|error| { + let reason = if error.downcast_ref::().is_some() + || error.downcast_ref::().is_some() + { + PolicyReadOnlyReason::UnsupportedFileSystem + } else { + PolicyReadOnlyReason::InsufficientPermissions + }; + (reason, format!("{error:#}")) + }); + *cached = Some(CachedAtomicityProbe { + key, + result: result.clone(), + retry_at: result.is_err().then_some(now + Self::FAILURE_RETRY_INTERVAL), + }); + result + } +} + +/// Open a directory without following reparse points, sharing read/write but not delete, +/// so the object cannot be renamed or deleted while this handle (and any later handle +/// derived from re-verifying it) is alive. +fn open_directory_no_reparse(path: &Path) -> anyhow::Result { + OpenOptions::new() + .access_mode((FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | FILE_TRAVERSE | READ_CONTROL).0 | FILE_SYNCHRONIZE) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE).0) + .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(path) + .with_context(|| format!("failed to open {}", path.display())) +} + +/// Open `path`, confirm it is a genuine directory (not a reparse point standing in for +/// one), and resolve its final path from the handle. +/// +/// Fails closed on any ambiguity: missing path, wrong object type, or reparse point. +/// +/// This only verifies `path` itself; callers additionally verify retained ancestors with +/// [`policy_security::verified_policy_ancestor_digest`], so an untrusted principal further +/// up the tree (e.g. on the shared `%ProgramData%\Devolutions\Agent` parent, where the +/// installer grants `LOCAL SERVICE` write access for unrelated Agent features) cannot +/// delete or replace this directory out from under an already-verified identity check. +fn open_and_verify_directory_identity(path: &Path) -> anyhow::Result<(File, PathBuf)> { + let handle = open_directory_no_reparse(path)?; + verify_directory_handle_type(&handle, &path.display().to_string())?; + let final_path = policy_security::final_path_from_handle(&handle) + .with_context(|| format!("failed to resolve {}", path.display()))?; + + Ok((handle, final_path)) +} + +fn verify_directory_handle_type(handle: &File, subject: &str) -> anyhow::Result<()> { + let attributes = handle + .metadata() + .with_context(|| format!("failed to query metadata for {subject}"))? + .file_attributes(); + + if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { + bail!("{subject} is a reparse point (symlink/junction); the policy directory must be a real directory"); + } + if attributes & FILE_ATTRIBUTE_DIRECTORY.0 == 0 { + bail!("{subject} is not a directory"); + } + + Ok(()) +} + +/// Holds the verified hosting directory and its lexical ancestors through publication. +/// These handles block path-component replacement, and the hosting handle anchors relative transaction names. +/// Test storage models the same checks with identity and security generations. +pub(super) struct VerifiedHostingDirectory { + handle: Option, + ancestor_handles: Vec, + canonical_path: PathBuf, + identity: FileIdentity, + security_digest: [u8; 32], +} + +impl VerifiedHostingDirectory { + /// The canonical directory path resolved from the verified handle when this was + /// built (item 22). + pub(super) fn canonical_path(&self) -> &Path { + &self.canonical_path + } + + /// The hosting directory's identity as observed when this was built. + pub(super) fn identity(&self) -> FileIdentity { + self.identity + } + + /// Build a synthetic instance for the parent module's `TestStorage`. + /// It models the hosting directory with generation counters and has no Windows handle. + #[cfg(test)] + pub(super) fn for_fake_storage(canonical_path: PathBuf, identity: FileIdentity, security_digest: [u8; 32]) -> Self { + Self { + handle: None, + ancestor_handles: Vec::new(), + canonical_path, + identity, + security_digest, + } + } + + /// Re-verify that this held-open directory still has the identity and security state + /// observed for the transaction. + fn verify_unchanged(&self) -> anyhow::Result<[u8; 32]> { + let handle = self.handle.as_ref().expect( + "BUG: reverify is only ever called by the real Windows write path (atomic_replace/atomic_create), \ + which always holds a real handle", + ); + policy_security::verify_policy_directory_security(handle) + .context("hosting directory failed security verification during post-write verification")?; + let identity = policy_security::file_identity(handle) + .context("failed to re-query hosting directory identity during post-write verification")?; + ensure!( + identity == self.identity, + "hosting directory identity changed unexpectedly while its handle was held open" + ); + let current_security = policy_security::security_state_digest(handle) + .context("failed to recompute hosting directory security digest during write verification")?; + ensure!( + current_security == self.security_digest, + "hosting directory security changed while its handle was held open" + ); + Ok(current_security) + } + + fn ancestor_digest(&self) -> anyhow::Result<[u8; 32]> { + policy_security::verified_policy_ancestor_digest(&self.ancestor_handles, "policy directory") + } +} + +pub(super) fn ensure_published_managed_authority( + source: PolicyConfigurationSource, + configured_path: &Path, + hosting_dir: &VerifiedHostingDirectory, +) -> anyhow::Result<()> { + let [managed, _] = default_policy_paths(); + if source != PolicyConfigurationSource::DefaultPath + || !policy_security::windows_paths_equal(configured_path, &managed) + { + return Ok(()); + } + + hosting_dir.verify_unchanged()?; + let handle = hosting_dir + .handle + .as_ref() + .expect("real policy publication retains the hosting directory handle"); + ensure_managed_authority_marker(handle, hosting_dir.canonical_path()) +} + +/// Create the dedicated default policy directory (if it does not already exist) with an +/// admin-only ACL established atomically at creation, then verify it. +/// +/// The ACL is passed as explicit `SECURITY_ATTRIBUTES` to `CreateDirectoryW` itself (see +/// [`policy_security::admin_only_security_attributes`]), so there is no window between +/// creation and securing it during which an untrusted principal could race the directory. +/// The existing path through ProgramData is verified and retained before creation. +/// The runtime creates only the fixed `Devolutions` and `PackageBroker` components. +/// +/// The broker owns this directory end-to-end, but unlike a naive "create, then chmod" +/// approach, an *existing* directory (e.g. from a previous run) is only ever verified, +/// never rewritten: if it already exists with an insecure ACL (inherited, tampered with, +/// or planted by a race/reparse before this call ever ran), this fails closed instead of +/// silently repairing it, since repairing would extend trust to whatever object happened +/// to already occupy the path. +/// +/// Returns the retained directory, canonical path, ancestor-security digest, and retained lexical ancestors. +fn ensure_default_directory_secured(dir: &Path) -> anyhow::Result<(File, PathBuf, [u8; 32], Vec)> { + let security_attributes = policy_security::admin_only_security_attributes(true) + .context("build admin-only security attributes for the policy directory")?; + let vendor = dir.parent().context("default policy directory has no vendor parent")?; + let program_data = vendor + .parent() + .context("default policy directory is outside ProgramData")?; + let vendor_name = vendor + .file_name() + .context("default policy vendor directory has no name")?; + let leaf_name = dir.file_name().context("default policy directory has no name")?; + ensure!( + policy_security::os_strings_match_case_insensitive(vendor_name, OsStr::new("Devolutions")) + && policy_security::os_strings_match_case_insensitive(leaf_name, OsStr::new("PackageBroker")), + "default policy directory has an unexpected shape" + ); + + let mut ancestor_handles = + policy_security::retain_policy_no_reparse_directory_chain(program_data, "ProgramData directory")?; + let program_data_handle = ancestor_handles.pop().context("ProgramData directory chain is empty")?; + let canonical_program_data = policy_security::final_path_from_handle(&program_data_handle)?; + ensure!( + policy_security::paths_match_case_insensitive(&canonical_program_data, program_data), + "ProgramData resolved to an unexpected location" + ); + policy_security::verify_policy_ancestor_directory_security(&program_data_handle, "ProgramData directory")?; + + let vendor_handle = ensure_secure_directory_component( + &program_data_handle, + vendor_name, + &security_attributes, + DirectorySecurityRole::SharedAncestor, + |_| Ok(()), + )?; + let handle = ensure_secure_directory_component( + &vendor_handle, + leaf_name, + &security_attributes, + DirectorySecurityRole::DedicatedPolicy, + |_| { + verify_directory_handle_type(&vendor_handle, "shared policy ancestor directory")?; + policy_security::verify_policy_directory_security(&vendor_handle) + .context("shared policy ancestor grants unsafe create rights during bootstrap") + }, + )?; + ancestor_handles.push(program_data_handle); + ancestor_handles.push(vendor_handle); + let final_path = policy_security::final_path_from_handle(&handle)?; + let ancestor_security_digest = + policy_security::verified_policy_ancestor_digest(&ancestor_handles, "policy directory")?; + + Ok((handle, final_path, ancestor_security_digest, ancestor_handles)) +} + +#[derive(Clone, Copy)] +enum DirectorySecurityRole { + SharedAncestor, + DedicatedPolicy, +} + +fn ensure_secure_directory_component( + parent: &File, + name: &OsStr, + security_attributes: &win_api_wrappers::security::attributes::SecurityAttributes, + security_role: DirectorySecurityRole, + before_create: impl FnOnce(&Path) -> anyhow::Result<()>, +) -> anyhow::Result { + let path = policy_security::final_path_from_handle(parent) + .context("failed to resolve secure directory component parent")? + .join(name); + let opened = match open_and_verify_directory_identity(&path) { + Ok(opened) => opened, + Err(error) + if error + .root_cause() + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) => + { + before_create(&path)?; + let create_error = win_api_wrappers::fs::create_directory(&path, Some(security_attributes)).err(); + if let Some(create_error) = &create_error { + tracing::debug!( + path = %path.display(), + error = %format!("{create_error:#}"), + "Secure directory creation lost a race; reopening the winner" + ); + } + match open_and_verify_directory_identity(&path) { + Ok(opened) => opened, + Err(reopen_error) => { + if let Some(create_error) = create_error { + return Err(create_error).with_context(|| { + format!( + "failed to securely create {} and no race winner could be reopened ({reopen_error:#})", + path.display() + ) + }); + } + return Err(reopen_error).with_context(|| format!("failed to reopen {}", path.display())); + } + } + } + Err(error) => return Err(error), + }; + let (handle, final_path) = opened; + ensure!( + policy_security::paths_match_case_insensitive(&final_path, &path), + "{} resolved to unexpected location {}", + path.display(), + final_path.display() + ); + match security_role { + DirectorySecurityRole::SharedAncestor => { + policy_security::verify_policy_ancestor_directory_security(&handle, "shared policy ancestor directory") + } + DirectorySecurityRole::DedicatedPolicy => policy_security::verify_policy_directory_security(&handle), + } + .with_context(|| format!("{} does not meet the required directory security", path.display()))?; + Ok(handle) +} + +/// Verify (never rewrite) that a custom-configured policy directory already meets the +/// same security bar as the dedicated default directory, including its ancestor chain. +/// +/// Returns the retained directory, canonical path, ancestor-security digest, and retained lexical ancestors. +fn verify_custom_directory_secure(dir: &Path) -> anyhow::Result<(File, PathBuf, [u8; 32], Vec)> { + let mut ancestor_handles = + policy_security::retain_policy_no_reparse_directory_chain(dir, "configured policy directory")?; + let handle = ancestor_handles + .pop() + .context("configured policy directory chain is empty")?; + let final_path = policy_security::final_path_from_handle(&handle)?; + policy_security::verify_policy_directory_security(&handle)?; + let ancestor_security_digest = + policy_security::verified_policy_ancestor_digest(&ancestor_handles, "policy directory")?; + Ok((handle, final_path, ancestor_security_digest, ancestor_handles)) +} + +fn verify_legacy_default_directory_secure(dir: &Path) -> anyhow::Result<(File, PathBuf, [u8; 32], Vec)> { + let mut ancestor_handles = + policy_security::retain_policy_no_reparse_directory_chain(dir, "legacy policy directory")?; + let handle = ancestor_handles + .pop() + .context("legacy policy directory chain is empty")?; + let final_path = policy_security::final_path_from_handle(&handle)?; + policy_security::verify_legacy_policy_directory_security(&handle)?; + let ancestor_security_digest = + policy_security::verified_policy_ancestor_digest(&ancestor_handles, "legacy policy directory")?; + Ok((handle, final_path, ancestor_security_digest, ancestor_handles)) +} + +/// Marker error indicating [`probe_write_capability`] failed because the hosting +/// filesystem is not known to support the atomic same-directory replacement semantics +/// `atomic_replace` depends on (as opposed to an ACL/quota/permission problem). +#[derive(Debug)] +struct UnsupportedFilesystem(String); + +impl std::fmt::Display for UnsupportedFilesystem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "filesystem '{}' is not known to support atomic same-directory replacement", + self.0 + ) + } +} + +impl std::error::Error for UnsupportedFilesystem {} + +#[derive(Debug)] +struct UnsupportedAtomicSemantics(String); + +impl std::fmt::Display for UnsupportedAtomicSemantics { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for UnsupportedAtomicSemantics {} + +/// Filesystem names known to support atomic same-directory handle renames. +/// Conservative by design: an unrecognized filesystem is treated as unsupported. +const ATOMIC_REPLACE_CAPABLE_FILESYSTEMS: &[&str] = &["NTFS", "ReFS"]; + +/// Verifies that `dir` supports the handle-based tombstone and create-new publication semantics required by [`atomic_replace`]. +/// +/// First, it conservatively classifies the filesystem because some filesystems and filter drivers silently use non-atomic copy-then-delete renames. +/// It then runs a nondestructive probe with fixed create-new names so failed attempts remain bounded. +fn probe_write_capability(dir: &Path) -> anyhow::Result<()> { + let filesystem = volume_filesystem_name(dir).context("query volume filesystem")?; + if !ATOMIC_REPLACE_CAPABLE_FILESYSTEMS + .iter() + .any(|name| name.eq_ignore_ascii_case(&filesystem)) + { + return Err(UnsupportedFilesystem(filesystem).into()); + } + + let dir_handle = open_directory_no_reparse(dir)?; + let source_path = dir.join(".package-broker-write-probe-a.tmp"); + let target_path = dir.join(".package-broker-write-probe-b.tmp"); + let tombstone_path = dir.join(".package-broker-write-probe-old.tmp"); + let source = create_probe_file(&source_path, b"probe-source", false)?; + let target = match create_probe_file(&target_path, b"probe-target", true) { + Ok(target) => target, + Err(error) => { + return match cleanup_probe_file(source, &source_path, "write-capability probe source") { + Ok(()) => Err(error), + Err(cleanup_error) => { + Err(error.context(format!("probe source cleanup also failed: {cleanup_error:#}"))) + } + }; + } + }; + let mut target_tombstoned = false; + let mut source_published = false; + let mut target_deleted = false; + let probe_result = (|| -> anyhow::Result<()> { + verify_no_replace_collision(&source, &target, &dir_handle, &source_path, &target_path)?; + rename_file_handle( + &target, + &dir_handle, + tombstone_path.file_name().expect("probe path has leaf"), + ) + .context("probe target-to-tombstone handle rename")?; + target_tombstoned = true; + rename_file_handle( + &source, + &dir_handle, + target_path.file_name().expect("probe path has leaf"), + ) + .context("probe create-new handle publication")?; + source_published = true; + let replaced = std::fs::read(&target_path).context("read write-capability probe result")?; + ensure!( + replaced == b"probe-source", + "atomic replacement did not take effect on this filesystem" + ); + delete_file_handle(&target).context("probe POSIX tombstone unlink")?; + target_deleted = true; + Ok(()) + })(); + + let source_cleanup_path = if source_published { &target_path } else { &source_path }; + let source_cleanup = cleanup_probe_file(source, source_cleanup_path, "write-capability probe source"); + let target_cleanup = if target_deleted { + drop(target); + ensure_path_absent(&tombstone_path, "write-capability probe target") + } else { + let target_cleanup_path = if target_tombstoned { + &tombstone_path + } else { + &target_path + }; + cleanup_probe_file(target, target_cleanup_path, "write-capability probe target") + }; + + probe_result.and(source_cleanup).and(target_cleanup) +} + +fn verify_no_replace_collision( + source: &File, + target: &File, + dir: &File, + source_path: &Path, + target_path: &Path, +) -> anyhow::Result<()> { + let check = (|| -> anyhow::Result<()> { + let source_identity = policy_security::file_identity(source)?; + let target_identity = policy_security::file_identity(target)?; + let source_content = read_file_from_start(source)?; + let target_content = read_file_from_start(target)?; + + match rename_file_handle(source, dir, target_path.file_name().expect("probe target has a leaf")) { + Err(error) if error.is_collision() => {} + Err(error) if error.is_unsupported() => { + return Err(UnsupportedAtomicSemantics(format!("no-replace collision is unsupported: {error}")).into()); + } + Err(error) if error.is_permission_failure() => { + return Err(anyhow::Error::new(error).context("no-replace collision was blocked by permissions")); + } + Err(error) => { + return Err(anyhow::Error::new(error).context("no-replace collision returned an unexpected status")); + } + Ok(()) => bail!("no-replace rename unexpectedly replaced an occupied destination"), + } + verify_probe_directory_entry( + dir, + source_path.file_name().expect("probe source has a leaf"), + source_identity, + )?; + verify_probe_directory_entry( + dir, + target_path.file_name().expect("probe target has a leaf"), + target_identity, + )?; + ensure!( + policy_security::file_identity(source)? == source_identity + && policy_security::file_identity(target)? == target_identity, + "no-replace collision changed a retained probe identity" + ); + ensure!( + read_file_from_start(source)? == source_content && read_file_from_start(target)? == target_content, + "no-replace collision changed retained probe content" + ); + Ok(()) + })(); + + check.map_err(|error| { + if error + .downcast_ref::() + .is_some_and(RenameFailure::is_permission_failure) + || error.downcast_ref::().is_some() + { + error + } else { + UnsupportedAtomicSemantics(format!("filesystem failed no-replace collision semantics: {error:#}")).into() + } + }) +} + +fn verify_probe_directory_entry(dir: &File, leaf: &OsStr, expected_identity: FileIdentity) -> anyhow::Result<()> { + let reopened = open_file_relative(dir, leaf)?; + ensure!( + policy_security::file_identity(&reopened)? == expected_identity, + "probe directory entry no longer names the retained file" + ); + ensure!( + policy_security::file_link_count(&reopened)? == 1, + "probe directory entry has multiple hard links" + ); + Ok(()) +} + +fn open_file_relative(dir: &File, leaf: &OsStr) -> anyhow::Result { + let mut name: Vec = leaf.encode_wide().collect(); + ensure!( + !name.is_empty() && !name.contains(&0) && !name.contains(&u16::from(b'\\')) && !name.contains(&u16::from(b'/')), + "relative file name is not a single valid path component" + ); + let name_byte_len = name + .len() + .checked_mul(size_of::()) + .and_then(|length| u16::try_from(length).ok()) + .context("relative file name is too long")?; + let object_name = UNICODE_STRING { + Length: name_byte_len, + MaximumLength: name_byte_len, + Buffer: windows::core::PWSTR(name.as_mut_ptr()), + }; + let object_attributes = OBJECT_ATTRIBUTES { + Length: u32::try_from(size_of::()).expect("OBJECT_ATTRIBUTES size fits u32"), + RootDirectory: HANDLE(dir.as_raw_handle()), + ObjectName: &raw const object_name, + Attributes: OBJ_CASE_INSENSITIVE, + SecurityDescriptor: std::ptr::null(), + SecurityQualityOfService: std::ptr::null(), + }; + let mut handle = HANDLE::default(); + let mut io_status = IoStatusBlock { + status_or_pointer: 0, + information: 0, + }; + + // SAFETY: The retained directory handle, object attributes, name, output handle, and I/O status remain valid. + let status = unsafe { + NtOpenFile( + &mut handle, + FILE_READ_ATTRIBUTES.0, + &object_attributes, + &mut io_status, + (FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0, + FILE_NON_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT, + ) + }; + ensure!( + status.0 >= 0, + "failed to reopen probe directory entry with NT status {:#010X}", + status.0.cast_unsigned() + ); + + // SAFETY: Successful NtOpenFile returned a new owned handle. + Ok(File::from(unsafe { OwnedHandle::from_raw_handle(handle.0) })) +} + +fn cleanup_probe_file(file: File, path: &Path, subject: &str) -> anyhow::Result<()> { + let cleanup = delete_file_handle(&file).with_context(|| format!("failed to remove {subject}")); + drop(file); + cleanup.and_then(|()| ensure_path_absent(path, subject)) +} + +fn ensure_path_absent(path: &Path, subject: &str) -> anyhow::Result<()> { + match std::fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Ok(_) => bail!("{subject} cleanup did not remove the directory entry"), + Err(error) => Err(error).with_context(|| format!("failed to verify {subject} cleanup")), + } +} + +fn create_probe_file(path: &Path, bytes: &[u8], allow_delete_share: bool) -> anyhow::Result { + use std::io::Write as _; + + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .access_mode(GENERIC_READ.0 | GENERIC_WRITE.0 | DELETE.0 | READ_CONTROL.0) + .share_mode( + if allow_delete_share { + FILE_SHARE_READ | FILE_SHARE_DELETE + } else { + FILE_SHARE_READ + } + .0, + ) + .custom_flags((FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH).0) + .open(path) + .with_context(|| format!("failed to create write-capability probe {}", path.display()))?; + if let Err(error) = file + .write_all(bytes) + .and_then(|()| file.sync_all()) + .with_context(|| format!("failed to persist write-capability probe {}", path.display())) + { + return match delete_file_handle(&file) { + Ok(()) => Err(error), + Err(cleanup_error) => Err(error.context(format!("probe cleanup also failed: {cleanup_error:#}"))), + }; + } + Ok(file) +} + +/// Classify the filesystem hosting `dir` (e.g. `"NTFS"`, `"ReFS"`, `"FAT32"`). +fn volume_filesystem_name(dir: &Path) -> anyhow::Result { + let dir_wide = U16CString::from_os_str(dir.as_os_str()).context("directory path contains an interior NUL")?; + + let mut volume_root = vec![0u16; 512]; + // SAFETY: `dir_wide` is a valid NUL-terminated wide string live for the call, and + // `volume_root` is a live, writable buffer. + unsafe { GetVolumePathNameW(dir_wide.as_pcwstr(), &mut volume_root) }.context("GetVolumePathNameW failed")?; + + let mut filesystem_name = vec![0u16; 261]; + // SAFETY: `volume_root` is a valid, NUL-terminated wide root path as returned by + // `GetVolumePathNameW` above, live for the call; `filesystem_name` is a live, writable + // buffer; every other output parameter is `None`, which the API accepts. + unsafe { + GetVolumeInformationW( + windows::core::PCWSTR(volume_root.as_ptr()), + None, + None, + None, + None, + Some(&mut filesystem_name), + ) + } + .context("GetVolumeInformationW failed")?; + + let nul_at = filesystem_name + .iter() + .position(|&unit| unit == 0) + .unwrap_or(filesystem_name.len()); + Ok(String::from_utf16_lossy(&filesystem_name[..nul_at])) +} + +/// Internal identity of the object, content, and verified security state observed on disk. +/// Fingerprint changes rotate the opaque store token; fingerprints are never serialized. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum DiskFingerprint { + /// A successfully parsed, security-verified, and semantically-valid policy file. + Active { + parent: FileIdentity, + target: FileIdentity, + content_digest: [u8; 32], + security_digest: [u8; 32], + /// Digest of the hosting directory's own owner and DACL. + dir_security_digest: [u8; 32], + ancestor_security_digest: [u8; 32], + }, + /// No file at the resolved path. Carries the verified identity of the parent + /// directory (its own security digest, and its ancestor chain's security summary), + /// so a parent replacement (or a differently identified custom path) is still + /// distinguishable even though there is no leaf to identify. + /// `parent`/`dir_security_digest`/`ancestor_security_digest` are `None` when even the + /// directory itself could not be verified (its own security/ancestor check failed): + /// still Missing -- there is no leaf to distrust either way -- but `path` (the + /// canonical, or best-effort literal, configured path) still prevents two distinct + /// configured paths in that situation from colliding (mirrors `Invalid::path`; item + /// 15). + Missing { + path: PathBuf, + parent: Option, + dir_security_digest: Option<[u8; 32]>, + ancestor_security_digest: Option<[u8; 32]>, + }, + /// A file exists but could not be trusted or activated: unreadable, failed storage + /// security validation, not valid JSON matching the expected schema, or (structurally + /// valid JSON that is nonetheless) semantically invalid. + /// + /// Every component is independently optional because how far observation got before + /// failing determines what could actually be resolved (e.g. a target that cannot + /// even be opened has no identity or content digest yet). `path` -- the canonical + /// configured path, or the best-effort literal one when it could not be + /// canonicalized at all -- is always present precisely so that two distinct + /// configured paths that both fail identically (e.g. both "parent cannot be opened", + /// with no identity available to distinguish them) never collide (item 15). + Invalid { + path: PathBuf, + parent: Option, + dir_security_digest: Option<[u8; 32]>, + ancestor_security_digest: Option<[u8; 32]>, + target: Option, + content_digest: Option<[u8; 32]>, + security_digest: Option<[u8; 32]>, + /// Stable internal failure reason (never itself exposed by the management API; + /// see `validation::disk_failure_finding`), included so distinct reasons at the + /// exact same path/identity still rotate the token (e.g. a file that was + /// insecurely-stored becomes merely malformed after its ACL is fixed). + reason: String, + }, +} + +impl DiskFingerprint { + fn target_state(&self) -> Option<(FileIdentity, [u8; 32], [u8; 32])> { + match self { + Self::Active { + target, + content_digest, + security_digest, + .. + } + | Self::Invalid { + target: Some(target), + content_digest: Some(content_digest), + security_digest: Some(security_digest), + .. + } => Some((*target, *content_digest, *security_digest)), + _ => None, + } + } + + fn ancestor_security_digest(&self) -> Option<[u8; 32]> { + match self { + Self::Active { + ancestor_security_digest, + .. + } + | Self::Missing { + ancestor_security_digest: Some(ancestor_security_digest), + .. + } + | Self::Invalid { + ancestor_security_digest: Some(ancestor_security_digest), + .. + } => Some(*ancestor_security_digest), + _ => None, + } + } +} + +#[cfg(test)] +impl DiskFingerprint { + /// Build a synthetic fingerprint for the in-memory `TestStorage` test double, + /// which has no real Windows file handles to derive identity from. + /// + /// `target_generation` and `parent_generation` stand in for [`FileIdentity`]: bump + /// either to simulate the corresponding real-world object being deleted and recreated + /// (even with byte-identical content), and `acl_generation` to simulate a + /// security-descriptor change with no content change (folded into both the target's + /// own security digest and the ancestor-chain summary, since the fake models "some + /// security-relevant state changed" as a single dimension rather than distinguishing + /// which level of the tree). + /// `dir_acl_generation` is independent so a hosting-directory-only ACL change (with no + /// leaf or ancestor-chain change) still rotates the fingerprint on its own. + pub(super) fn test_active( + content: &[u8], + target_generation: u32, + parent_generation: u32, + acl_generation: u32, + dir_acl_generation: u32, + ) -> Self { + Self::Active { + parent: test_identity(parent_generation), + target: test_identity(target_generation), + content_digest: sha256_digest(content), + security_digest: sha256_digest(&acl_generation.to_le_bytes()), + dir_security_digest: sha256_digest(&dir_acl_generation.to_le_bytes()), + ancestor_security_digest: sha256_digest(&acl_generation.to_le_bytes()), + } + } + + pub(super) fn test_missing(parent_generation: u32, dir_acl_generation: u32) -> Self { + Self::Missing { + path: PathBuf::from(r"C:\fake\package-broker-policy.json"), + parent: Some(test_identity(parent_generation)), + dir_security_digest: Some(test_security_digest(dir_acl_generation)), + ancestor_security_digest: Some(sha256_digest(b"test-ancestor-security")), + } + } + + pub(super) fn test_invalid( + content: &[u8], + target_generation: u32, + parent_generation: u32, + acl_generation: u32, + dir_acl_generation: u32, + ) -> Self { + Self::Invalid { + path: PathBuf::from(r"C:\fake\package-broker-policy.json"), + parent: Some(test_identity(parent_generation)), + dir_security_digest: Some(test_security_digest(dir_acl_generation)), + ancestor_security_digest: Some(test_security_digest(acl_generation)), + target: Some(test_identity(target_generation)), + content_digest: Some(sha256_digest(content)), + security_digest: Some(test_security_digest(acl_generation)), + reason: format!("{:?}", validation::DiskFailureReason::MalformedContent), + } + } +} + +#[cfg(test)] +pub(super) fn test_identity(generation: u32) -> FileIdentity { + let mut file_id = [0u8; 16]; + file_id[..4].copy_from_slice(&generation.to_le_bytes()); + FileIdentity { + volume_serial: 0, + file_id, + } +} + +#[cfg(test)] +pub(super) fn test_security_digest(generation: u32) -> [u8; 32] { + sha256_digest(&generation.to_le_bytes()) +} + +pub(super) fn sha256_digest(bytes: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher.finalize().into() +} + +pub(super) fn unavailable_fingerprint(path: PathBuf) -> DiskFingerprint { + DiskFingerprint::Invalid { + path, + parent: None, + dir_security_digest: None, + ancestor_security_digest: None, + target: None, + content_digest: None, + security_digest: None, + reason: format!("{:?}", validation::DiskFailureReason::WatcherUnavailable), + } +} + +/// Exact policy file handle retained from a write transaction's token observation through publication. +pub(super) enum RetainedPolicyFile { + Real(File), + #[cfg(test)] + Fake(Box), +} + +impl RetainedPolicyFile { + pub(super) fn verify_matches(&self, expected: &DiskFingerprint) -> anyhow::Result<()> { + #[cfg(test)] + if let Self::Fake(observed) = self { + ensure!( + observed.as_ref() == expected, + "fake retained target does not match the observed fingerprint" + ); + return Ok(()); + } + let handle = match self { + Self::Real(handle) => handle, + #[cfg(test)] + Self::Fake(_) => unreachable!("fake retained target returned before Windows verification"), + }; + let (expected_target, expected_content, expected_security) = expected + .target_state() + .context("write observation did not retain a complete target fingerprint")?; + + ensure!( + policy_security::file_identity(handle)? == expected_target, + "retained policy file identity changed after token validation" + ); + ensure!( + policy_security::file_link_count(handle)? == 1, + "retained policy file acquired another hard link after token validation" + ); + policy_security::verify_managed_policy_file_security(handle) + .context("retained policy file security changed after token validation")?; + ensure!( + policy_security::security_state_digest(handle)? == expected_security, + "retained policy file security digest changed after token validation" + ); + + let bytes = read_file_from_start(handle)?; + ensure!( + sha256_digest(&bytes) == expected_content, + "retained policy file content changed after token validation" + ); + Ok(()) + } + + fn handle(&self) -> &File { + match self { + Self::Real(handle) => handle, + #[cfg(test)] + Self::Fake(_) => panic!("fake retained targets have no Windows handle"), + } + } + + #[cfg(test)] + fn into_handle(self) -> File { + match self { + Self::Real(handle) => handle, + Self::Fake(_) => panic!("fake retained targets have no Windows handle"), + } + } + + #[cfg(test)] + pub(super) fn for_fake(fingerprint: DiskFingerprint) -> Self { + Self::Fake(Box::new(fingerprint)) + } +} + +/// Exact observed state of the policy file on disk, together with the write capability +/// resolved *as part of the same observation* (item 20/26): capability is never derived +/// from a separately cached snapshot, so it can never silently drift from the state it +/// describes. A malformed-but-securely-stored file (capability follows the directory's +/// own resolved capability, allowing Repair) is distinguished from an insecure/unreadable +/// target (capability is forced to `ReadOnly`/`UnsafePath` regardless of the directory's +/// own capability, and Repair therefore fails): see item 26. +pub(super) struct DiskObservation { + pub state: PolicyManagementState, + pub policy: Option, + pub invalid_diagnostics: Option, + pub fingerprint: DiskFingerprint, + pub write_capability: PolicyWriteCapability, + pub read_only_reason: Option, + /// Canonical path formed from the verified parent handle and exact configured `.json` leaf name; see item 22. + /// Falls back to the literal configured path when the path cannot be canonicalized. + /// Trusted target access, publication, display, and watching use this path as applicable. + /// `PolicyStore` retains the original configured path for authoritative re-observation and security-chain validation. + pub canonical_path: PathBuf, + /// The hosting directory verified during this observation. + /// Writable observations keep it alive through publication and postverification. + pub hosting_dir: Option, + /// Exact target handle retained by write observations. + pub retained_target: Option, +} + +/// Context accumulated while observation fails partway through, for building the most +/// complete [`DiskFingerprint::Invalid`] the failure allows (item 15): every field is +/// optional because how far observation got before failing determines what could +/// actually be resolved (e.g. a directory that cannot even be opened has no parent +/// identity to report). +#[derive(Default)] +struct InvalidContext { + parent: Option, + dir_security_digest: Option<[u8; 32]>, + ancestor_security_digest: Option<[u8; 32]>, + target: Option, + content_digest: Option<[u8; 32]>, + security_digest: Option<[u8; 32]>, +} + +struct OpenedPolicyFile { + file: File, + retained_for_write: bool, +} + +fn verify_policy_leaf_type_if_present(path: &Path) -> anyhow::Result<()> { + let file = match OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES.0 | READ_CONTROL.0) + .share_mode(FILE_SHARE_READ.0) + .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error).with_context(|| format!("failed to inspect policy leaf {}", path.display())), + }; + let attributes = file.metadata()?.file_attributes(); + ensure!( + attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 == 0, + "policy leaf is a reparse point" + ); + ensure!( + attributes & FILE_ATTRIBUTE_DIRECTORY.0 == 0, + "policy leaf is a directory" + ); + Ok(()) +} + +fn open_policy_file(path: &Path, retain_for_write: bool) -> std::io::Result { + if retain_for_write { + match OpenOptions::new() + .access_mode(FILE_GENERIC_READ.0 | DELETE.0 | READ_CONTROL.0) + .share_mode(FILE_SHARE_READ.0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(path) + { + Ok(file) => { + return Ok(OpenedPolicyFile { + file, + retained_for_write: true, + }); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Err(error), + Err(error) => { + tracing::warn!( + path = %path.display(), + %error, + "Failed to retain the configured policy file for conditional publication" + ); + } + } + } + + OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ.0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(path) + .map(|file| OpenedPolicyFile { + file, + retained_for_write: false, + }) +} + +fn resolved_policy_path_matches(resolved: &Path, canonical_parent: &Path, configured_leaf: &OsStr) -> bool { + let Some(resolved_parent) = resolved.parent() else { + return false; + }; + let Some(resolved_leaf) = resolved.file_name() else { + return false; + }; + + policy_security::paths_match_case_insensitive(resolved_parent, canonical_parent) + && paths_component_matches_case_insensitive(resolved_leaf, configured_leaf) +} + +fn paths_component_matches_case_insensitive(a: &OsStr, b: &OsStr) -> bool { + policy_security::os_strings_match_case_insensitive(a, b) +} + +/// Observe the exact current disk state of the configured policy file. +/// +/// Resolves (and, for the default path, idempotently creates) the canonical directory +/// and re-verifies its shape/security/ancestor chain and write capability on every call +/// (item 20): only the one-time, side-effecting filesystem atomic-replace probe is +/// cached (`probe_cache`; see [`AtomicityProbeCache`]), never the cheap security checks. +/// +/// The hosting directory is opened without delete sharing and held open for the whole +/// observation: both to fold its identity into the fingerprint (detecting the directory +/// itself being replaced) and so it cannot be deleted or renamed out from under the +/// target file while it is being examined. The leaf file is opened without following +/// reparse points, and its own handle-resolved final path must match the canonical +/// directory and expected leaf name, case-insensitively (item 22): a reparse point or +/// hard-link alias standing in for the configured file is never trusted, whatever its +/// content, but a leaf whose on-disk casing merely differs from the configured path +/// (Windows filesystems are case-insensitive but case-preserving) is accepted as the same file. +/// Security is verified on the target's open handle before any content is trusted, and content is +/// read from that same handle, so the verified security descriptor always belongs to the +/// exact bytes subsequently parsed (no TOCTOU window via file replacement). A +/// structurally valid document is additionally, authoritatively revalidated the same +/// deterministic way a submitted draft is (item 30): a committed file is never activated +/// on structural parseability alone. +/// +/// A configured path whose shape/extension is unsupported (item 18/22) -- relative, +/// empty/non-file leaf, trailing separator, `.`/`..` component, or an extension other +/// than `.json` -- is reported with the shared contract's dedicated +/// [`PolicyReadOnlyReason::UnsupportedFormat`]. +pub(super) fn observe( + source: PolicyConfigurationSource, + configured_path: &Path, + probe_cache: &AtomicityProbeCache, +) -> DiskObservation { + observe_impl(source, configured_path, probe_cache, false) +} + +pub(super) fn observe_for_write( + source: PolicyConfigurationSource, + configured_path: &Path, + probe_cache: &AtomicityProbeCache, +) -> DiskObservation { + observe_impl(source, configured_path, probe_cache, true) +} + +fn observe_impl( + source: PolicyConfigurationSource, + configured_path: &Path, + probe_cache: &AtomicityProbeCache, + retain_target: bool, +) -> DiskObservation { + if let Err(diagnostic) = validate_configured_path_shape(configured_path) { + tracing::warn!( + path = %configured_path.display(), reason = %diagnostic, + "Configured policy path has an unsupported shape or extension" + ); + let (failure, read_only_reason) = match diagnostic { + ConfiguredPathError::UnsafeShape(_) => ( + validation::DiskFailureReason::InsecureStorage, + PolicyReadOnlyReason::UnsafePath, + ), + ConfiguredPathError::UnsupportedFormat(_) => ( + validation::DiskFailureReason::UnsupportedFormat, + PolicyReadOnlyReason::UnsupportedFormat, + ), + }; + return invalid_observation( + configured_path, + failure, + InvalidContext::default(), + PolicyWriteCapability::Unsupported, + Some(read_only_reason), + ); + } + + let dir = configured_path.parent().unwrap_or_else(|| Path::new(".")); + let leaf_name = configured_path + .file_name() + .expect("shape validation already required a named leaf file"); + let [_, legacy_default_path] = default_policy_paths(); + let legacy_default = matches!(source, PolicyConfigurationSource::DefaultPath) + && policy_security::windows_paths_equal(configured_path, &legacy_default_path); + let managed_default = matches!(source, PolicyConfigurationSource::DefaultPath) && !legacy_default; + + let secured = match (source, legacy_default) { + (PolicyConfigurationSource::DefaultPath, true) => verify_legacy_default_directory_secure(dir), + (PolicyConfigurationSource::DefaultPath, false) => ensure_default_directory_secured(dir), + (PolicyConfigurationSource::ConfiguredPath, _) => verify_custom_directory_secure(dir), + }; + + let (dir_handle, canonical_dir, _, ancestor_handles) = match secured { + Ok(resolved) => resolved, + Err(error) => { + tracing::warn!( + path = %dir.display(), error = %format!("{error:#}"), + "Configured policy directory failed security verification" + ); + let (write_capability, read_only_reason) = match source { + PolicyConfigurationSource::DefaultPath => ( + PolicyWriteCapability::Unsupported, + PolicyReadOnlyReason::InsufficientPermissions, + ), + PolicyConfigurationSource::ConfiguredPath => { + (PolicyWriteCapability::ReadOnly, PolicyReadOnlyReason::UnsafePath) + } + }; + return observe_leaf_under_unverifiable_directory(configured_path, write_capability, read_only_reason); + } + }; + let canonical_path = canonical_dir.join(leaf_name); + let parent = match policy_security::file_identity(&dir_handle) { + Ok(identity) => identity, + Err(error) => { + tracing::warn!( + path = %canonical_dir.display(), %error, + "Failed to query the configured policy directory identity" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + InvalidContext::default(), + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + + let directory_security = if legacy_default { + policy_security::verify_legacy_policy_directory_security(&dir_handle) + } else { + policy_security::verify_policy_directory_security(&dir_handle) + }; + if let Err(error) = directory_security { + tracing::warn!( + path = %canonical_dir.display(), error = %format!("{error:#}"), + "Held policy directory failed security verification" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + InvalidContext { + parent: Some(parent), + ..Default::default() + }, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + let dir_security_digest = match policy_security::security_state_digest(&dir_handle) { + Ok(digest) => digest, + Err(error) => { + tracing::warn!( + path = %canonical_dir.display(), %error, + "Failed to compute the policy directory security digest" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + InvalidContext { + parent: Some(parent), + ..Default::default() + }, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + let ancestor_security_digest = + match policy_security::verified_policy_ancestor_digest(&ancestor_handles, "policy directory") { + Ok(digest) => digest, + Err(error) => { + tracing::warn!( + path = %canonical_dir.display(), error = %format!("{error:#}"), + "Held policy directory ancestor chain failed security verification" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + InvalidContext { + parent: Some(parent), + dir_security_digest: Some(dir_security_digest), + ..Default::default() + }, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + let authority_marker_present = if managed_default { + match open_verified_managed_authority_marker(&canonical_dir) { + Ok(marker) => marker.is_some(), + Err(error) => { + tracing::error!( + path = %canonical_dir.display(), + error = %format!("{error:#}"), + "Managed policy authority marker failed closed" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + InvalidContext { + parent: Some(parent), + dir_security_digest: Some(dir_security_digest), + ancestor_security_digest: Some(ancestor_security_digest), + ..Default::default() + }, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + } + } else { + false + }; + + let recovery = if legacy_default { + Ok(()) + } else { + recover_create_temporary_files(&canonical_dir) + .and_then(|()| recover_interrupted_transaction(&dir_handle, &canonical_dir, leaf_name)) + }; + if let Err(error) = recovery { + tracing::error!( + path = %canonical_dir.display(), + error = %format!("{error:#}"), + "Policy transaction recovery failed closed" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + InvalidContext { + parent: Some(parent), + dir_security_digest: Some(dir_security_digest), + ancestor_security_digest: Some(ancestor_security_digest), + ..Default::default() + }, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + + // The one-time, side-effecting atomic-replace capability probe (item 20): cached per + // verified directory identity and security digest, never repeated on every observation. + // The installer successor migrates eligible legacy files before service startup. + // Until managed state appears, preserve legacy enforcement but never write through the legacy directory. + let (base_write_capability, base_read_only_reason) = if legacy_default { + ( + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::InsufficientPermissions), + ) + } else { + match probe_cache.get_or_probe(&canonical_dir, parent, dir_security_digest) { + Ok(()) => (PolicyWriteCapability::Writable, None), + Err((reason, diagnostic)) => { + tracing::warn!( + path = %canonical_dir.display(), %diagnostic, + "Policy directory is not writable through the management API" + ); + (probe_failure_capability(reason), Some(reason)) + } + } + }; + + let authority_dir = (managed_default && !authority_marker_present).then(|| dir_handle.try_clone()); + let hosting_dir = (base_write_capability == PolicyWriteCapability::Writable).then_some(VerifiedHostingDirectory { + handle: Some(dir_handle), + ancestor_handles, + canonical_path: canonical_dir.clone(), + identity: parent, + security_digest: dir_security_digest, + }); + + let invalid_ctx = InvalidContext { + parent: Some(parent), + dir_security_digest: Some(dir_security_digest), + ancestor_security_digest: Some(ancestor_security_digest), + ..Default::default() + }; + + if let Err(error) = verify_policy_leaf_type_if_present(&canonical_path) { + tracing::warn!( + path = %canonical_path.display(), + error = %format!("{error:#}"), + "Configured policy leaf has an unsafe type" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + let opened = match open_policy_file(&canonical_path, retain_target && !legacy_default) { + Ok(opened) => opened, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if let Err(error) = verify_policy_leaf_type_if_present(&canonical_path) { + tracing::warn!( + path = %canonical_path.display(), + error = %format!("{error:#}"), + "Configured policy leaf became unsafe while opening" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + return DiskObservation { + state: PolicyManagementState::Missing, + policy: None, + invalid_diagnostics: None, + fingerprint: DiskFingerprint::Missing { + path: canonical_path.clone(), + parent: Some(parent), + dir_security_digest: Some(dir_security_digest), + ancestor_security_digest: Some(ancestor_security_digest), + }, + write_capability: base_write_capability, + read_only_reason: base_read_only_reason, + canonical_path, + hosting_dir, + retained_target: None, + }; + } + Err(error) => { + tracing::warn!(path = %canonical_path.display(), %error, "Failed to open the configured policy file"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + let file = opened.file; + + if let Err(error) = policy_security::verify_policy_file_path(&file, &canonical_path) { + tracing::warn!( + path = %canonical_path.display(), + error = %format!("{error:#}"), + "Configured policy file failed path verification" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + + let attributes = match file.metadata() { + Ok(metadata) => metadata.file_attributes(), + Err(error) => { + tracing::warn!(path = %canonical_path.display(), %error, "Failed to query the configured policy file metadata"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { + tracing::warn!( + path = %canonical_path.display(), + "Configured policy file is a reparse point (symlink); refusing to trust a retargeted file" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + if attributes & FILE_ATTRIBUTE_DIRECTORY.0 != 0 { + tracing::warn!(path = %canonical_path.display(), "Configured policy path resolved to a directory, not a file"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + + // A policy leaf with multiple names is ambiguous regardless of which name + // GetFinalPathNameByHandleW happens to report. Reject it using file metadata rather + // than inferring link identity from that reported path. + let link_count = match policy_security::file_link_count(&file) { + Ok(link_count) => link_count, + Err(error) => { + tracing::warn!(path = %canonical_path.display(), %error, "Failed to query the configured policy file link count"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + if link_count != 1 { + tracing::warn!( + path = %canonical_path.display(), + link_count, + "Configured policy file has multiple hard links" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + + // Resolve both the parent and leaf from their held handles. This tolerates a lexical + // 8.3 alias in the configured parent while still requiring the resolved leaf to be + // exactly the configured name modulo Windows casing. + match policy_security::final_path_from_handle(&file) { + Ok(resolved) => { + let resolved_matches = resolved_policy_path_matches(&resolved, &canonical_dir, leaf_name); + if !resolved_matches { + tracing::warn!( + path = %canonical_path.display(), resolved = %resolved.display(), + "Configured policy file resolved to an unexpected location" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + } + + Err(error) => { + tracing::warn!( + path = %canonical_path.display(), %error, + "Failed to resolve the configured policy file's final path" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + } + + let target = match policy_security::file_identity(&file) { + Ok(identity) => identity, + Err(error) => { + tracing::warn!(path = %canonical_path.display(), %error, "Failed to query the configured policy file identity"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + let invalid_ctx = InvalidContext { + target: Some(target), + ..invalid_ctx + }; + + let file_security = if legacy_default { + policy_security::verify_policy_file_security(&file) + } else { + policy_security::verify_managed_policy_file_security(&file) + }; + if let Err(security_error) = file_security { + // Fail closed without ever reading content past a failed security check, exactly + // like the legacy loader: an insecurely-stored file is never trusted, whatever it + // contains. Forced ReadOnly regardless of the directory's own writable capability + // (item 26): an untrustworthy existing file must never be blindly overwritten + // through the management API either. The detailed reason is only ever traced, + // never exposed through the management API (see `validation::disk_failure_finding`). + tracing::warn!( + path = %canonical_path.display(), + error = %format!("{security_error:#}"), + "Configured policy file failed storage security validation" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + + let security_digest = match policy_security::security_state_digest(&file) { + Ok(digest) => digest, + Err(error) => { + tracing::warn!(path = %canonical_path.display(), %error, "Failed to compute the configured policy file's security digest"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + let invalid_ctx = InvalidContext { + security_digest: Some(security_digest), + ..invalid_ctx + }; + + let mut content = Vec::new(); + { + use std::io::Read as _; + if let Err(read_error) = (&file).read_to_end(&mut content) { + tracing::warn!(path = %canonical_path.display(), %read_error, "Failed to read the configured policy file"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + } + + // The file itself is securely stored (whatever its content turns out to be): a + // malformed/semantically-invalid document past this point still allows Repair + // through the directory's own (already resolved) capability -- item 26. + let retained_target = opened.retained_for_write.then_some(RetainedPolicyFile::Real(file)); + + let observation = observation_from_parts( + &canonical_path, + &content, + VerifiedIdentity { + parent, + dir_security_digest, + ancestor_security_digest, + target, + security_digest, + }, + base_write_capability, + base_read_only_reason, + hosting_dir, + retained_target, + ); + if observation.state == PolicyManagementState::Active + && let Some(authority_dir) = authority_dir + { + let marker_result = authority_dir + .context("failed to retain managed policy directory for authority marker") + .and_then(|dir| ensure_managed_authority_marker(&dir, &canonical_dir)); + if let Err(error) = marker_result { + tracing::error!( + path = %canonical_dir.display(), + error = %format!("{error:#}"), + "Failed to make managed policy selection durable" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + InvalidContext { + parent: Some(parent), + dir_security_digest: Some(dir_security_digest), + ancestor_security_digest: Some(ancestor_security_digest), + target: Some(target), + content_digest: Some(sha256_digest(&content)), + security_digest: Some(security_digest), + }, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + } + observation +} + +/// Verified identity/security components already resolved for the current observation, +/// grouped so [`observation_from_parts`] does not need one parameter per field. +struct VerifiedIdentity { + parent: FileIdentity, + dir_security_digest: [u8; 32], + ancestor_security_digest: [u8; 32], + target: FileIdentity, + security_digest: [u8; 32], +} + +/// Parse already-obtained (already security-verified) policy file bytes into a +/// [`DiskObservation`], given the fingerprint's already-resolved identity components and +/// the directory's already-resolved write capability. +/// +/// A structurally valid [`PolicyDocument`] is additionally, authoritatively revalidated +/// the same deterministic way a submitted draft is (item 30: see +/// [`validation::validate_committed_policy`]): a committed file is never activated on +/// structural parseability alone. Warnings alone (audit mode, default-allow, sensitive +/// options) do not block activation. +fn observation_from_parts( + path: &Path, + content: &[u8], + identity: VerifiedIdentity, + write_capability: PolicyWriteCapability, + read_only_reason: Option, + hosting_dir: Option, + retained_target: Option, +) -> DiskObservation { + let VerifiedIdentity { + parent, + dir_security_digest, + ancestor_security_digest, + target, + security_digest, + } = identity; + + let content_digest = sha256_digest(content); + + let mut retained_target = retained_target; + let mut invalid_with = |reason: validation::DiskFailureReason, hosting_dir| DiskObservation { + state: PolicyManagementState::Invalid, + policy: None, + invalid_diagnostics: Some(InvalidPolicyDiagnostics { + diagnostics_version: API_VERSION_STR.into(), + findings: vec![validation::disk_failure_finding(reason)], + }), + fingerprint: DiskFingerprint::Invalid { + path: path.to_owned(), + parent: Some(parent), + dir_security_digest: Some(dir_security_digest), + ancestor_security_digest: Some(ancestor_security_digest), + target: Some(target), + content_digest: Some(content_digest), + security_digest: Some(security_digest), + reason: format!("{reason:?}"), + }, + write_capability, + read_only_reason, + canonical_path: path.to_owned(), + hosting_dir, + retained_target: retained_target.take(), + }; + + let policy = match serde_json::from_slice::(content) { + Ok(policy) => policy, + Err(parse_error) => { + // Detailed parse error only ever traced, never exposed through the management + // API: it is heuristically derived from attacker/corruption-controlled bytes + // and could otherwise leak content fragments to any authenticated (but not + // necessarily elevated) caller of `GET /v1/policy/management`. + tracing::warn!(%parse_error, "Configured policy file content failed to parse"); + return invalid_with(validation::DiskFailureReason::MalformedContent, hosting_dir); + } + }; + + let committed_validation = validation::validate_committed_policy(&policy); + if !committed_validation.is_valid { + // Specific findings only ever traced, for the same reason raw parse errors are + // not exposed: they are derived from the committed file's own content, which + // `GET /v1/policy/management` exposes to any authenticated (but not necessarily + // elevated/Administrator, and not necessarily the file's author) caller. + tracing::warn!( + findings = ?committed_validation.findings, + "Configured policy file failed authoritative semantic validation" + ); + return invalid_with(validation::DiskFailureReason::FailedSemanticValidation, hosting_dir); + } + + DiskObservation { + state: PolicyManagementState::Active, + policy: Some(policy), + invalid_diagnostics: None, + fingerprint: DiskFingerprint::Active { + parent, + target, + content_digest, + security_digest, + dir_security_digest, + ancestor_security_digest, + }, + write_capability, + read_only_reason, + canonical_path: path.to_owned(), + hosting_dir, + retained_target, + } +} + +fn observe_leaf_under_unverifiable_directory( + configured_path: &Path, + write_capability: PolicyWriteCapability, + read_only_reason: PolicyReadOnlyReason, +) -> DiskObservation { + // Do not follow the leaf: a dangling reparse point is an existing unsafe entry, not + // an absent policy. Errors remain invalid because the directory could not be trusted. + match std::fs::symlink_metadata(configured_path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => DiskObservation { + state: PolicyManagementState::Missing, + policy: None, + invalid_diagnostics: None, + fingerprint: DiskFingerprint::Missing { + path: configured_path.to_owned(), + parent: None, + dir_security_digest: None, + ancestor_security_digest: None, + }, + write_capability, + read_only_reason: Some(read_only_reason), + canonical_path: configured_path.to_owned(), + hosting_dir: None, + retained_target: None, + }, + Ok(_) | Err(_) => invalid_observation( + configured_path, + validation::DiskFailureReason::Unreadable, + InvalidContext::default(), + write_capability, + Some(read_only_reason), + ), + } +} + +/// Build a generic, sanitized [`DiskObservation`] for a storage-level failure (shape, +/// I/O, or security) that prevented the configured policy file from even being read as +/// JSON. Never includes raw OS/security error text: see [`validation::disk_failure_finding`]. +fn invalid_observation( + path: &Path, + reason: validation::DiskFailureReason, + context: InvalidContext, + write_capability: PolicyWriteCapability, + read_only_reason: Option, +) -> DiskObservation { + DiskObservation { + state: PolicyManagementState::Invalid, + policy: None, + invalid_diagnostics: Some(InvalidPolicyDiagnostics { + diagnostics_version: API_VERSION_STR.into(), + findings: vec![validation::disk_failure_finding(reason)], + }), + fingerprint: DiskFingerprint::Invalid { + path: path.to_owned(), + parent: context.parent, + dir_security_digest: context.dir_security_digest, + ancestor_security_digest: context.ancestor_security_digest, + target: context.target, + content_digest: context.content_digest, + security_digest: context.security_digest, + reason: format!("{reason:?}"), + }, + write_capability, + read_only_reason, + canonical_path: path.to_owned(), + hosting_dir: None, + retained_target: None, + } +} + +/// Mint a fresh, process-random, opaque token conforming to `PolicyStoreToken`'s own +/// safe-ASCII/length contract. Tokens never encode or derive from disk content/identity: +/// [`PolicyStore::token_for`](super::PolicyStore) is the only place a token is ever +/// produced, and it only ever calls this when the observed [`DiskFingerprint`] changed. +pub(super) fn random_store_token() -> PolicyStoreToken { + uuid::Uuid::new_v4().hyphenated().to_string().into() +} + +/// Result of a successful atomic write. +pub(super) struct PersistedPolicy { + pub policy: PolicyDocument, + pub fingerprint: DiskFingerprint, + pub write_capability: PolicyWriteCapability, + pub read_only_reason: Option, + pub canonical_path: PathBuf, +} + +/// A write failure classified by whether publication occurred or a concurrent change won. +pub(super) enum WriteFailure { + /// Failed before new content was published. + /// Reobservation recovers any retained tombstone before this maps to `ErrorCode::PolicyPersistenceFailed`. + PrePublication(anyhow::Error), + /// A concurrent external change prevented identity-bound publication. + /// The caller must synchronously reobserve and return a stale-token conflict. + ConcurrentChange(anyhow::Error), + /// Failed after the rename made the new content live: the caller must synchronously + /// reobserve disk under the same write lock and publish whatever that reveals rather + /// than trusting the previous in-memory snapshot. Maps to + /// `ErrorCode::PolicyActivationFailed`. + PostPublication(anyhow::Error), +} + +/// Conditionally persist `bytes` against the exact retained target observed for the store token. +/// +/// The observed target is moved by handle to a reserved tombstone and the flushed replacement is moved by handle to the final leaf without replacement. +/// Any raced-in final entry is preserved and reported as [`WriteFailure::ConcurrentChange`]. +/// A durable marker makes every interruption recoverable before the next observation. +pub(super) fn atomic_replace( + hosting_dir: &VerifiedHostingDirectory, + observed_target: Option, + expected_fingerprint: &DiskFingerprint, + final_path: &Path, + bytes: &[u8], +) -> Result { + let observed_target = observed_target + .context("replacement observation did not retain the target handle") + .map_err(WriteFailure::ConcurrentChange)?; + verify_replacement_evidence(hosting_dir, &observed_target, expected_fingerprint) + .map_err(WriteFailure::ConcurrentChange)?; + + conditional_replace(hosting_dir, observed_target, expected_fingerprint, final_path, bytes) +} + +fn verify_replacement_evidence( + hosting_dir: &VerifiedHostingDirectory, + observed_target: &RetainedPolicyFile, + expected_fingerprint: &DiskFingerprint, +) -> anyhow::Result<()> { + verify_directory_evidence(hosting_dir, expected_fingerprint)?; + observed_target + .verify_matches(expected_fingerprint) + .context("observed policy changed before conditional publication") +} + +fn verify_directory_evidence( + hosting_dir: &VerifiedHostingDirectory, + expected_fingerprint: &DiskFingerprint, +) -> anyhow::Result<()> { + hosting_dir + .verify_unchanged() + .context("hosting directory changed after policy observation")?; + let expected_ancestor_security = expected_fingerprint + .ancestor_security_digest() + .context("write observation has no ancestor security fingerprint")?; + let current_ancestor_security = hosting_dir + .ancestor_digest() + .context("policy directory ancestor security changed after token validation")?; + if current_ancestor_security != expected_ancestor_security { + return Err(anyhow::anyhow!( + "policy directory ancestor security changed after token validation" + )); + } + Ok(()) +} + +#[derive(Debug)] +struct TransactionPaths { + id: uuid::Uuid, + marker_staging: PathBuf, + marker: PathBuf, + old: PathBuf, + new: PathBuf, +} + +impl TransactionPaths { + fn new(dir: &Path, final_path: &Path) -> anyhow::Result { + let leaf = final_path + .file_name() + .and_then(OsStr::to_str) + .context("policy path has no Unicode leaf name")?; + let id = uuid::Uuid::new_v4(); + let prefix = format!(".{leaf}.txn-{id}"); + Ok(Self { + id, + marker_staging: dir.join(format!("{prefix}.marker.prepare")), + marker: dir.join(format!("{prefix}.marker")), + old: dir.join(format!("{prefix}.old")), + new: dir.join(format!("{prefix}.new")), + }) + } +} + +#[derive(Debug)] +struct TransactionMarker { + id: uuid::Uuid, + final_leaf: String, + old_identity: FileIdentity, + old_content_digest: [u8; 32], + old_security_digest: [u8; 32], + new_identity: FileIdentity, + new_content_digest: [u8; 32], + new_security_digest: [u8; 32], +} + +impl TransactionMarker { + fn from_observation( + id: uuid::Uuid, + final_path: &Path, + expected: &DiskFingerprint, + new_file: &File, + new_bytes: &[u8], + ) -> anyhow::Result { + let (old_identity, old_content_digest, old_security_digest) = expected + .target_state() + .context("replacement requires a complete observed target fingerprint")?; + Ok(Self { + id, + final_leaf: final_path + .file_name() + .and_then(OsStr::to_str) + .context("policy path has no Unicode leaf name")? + .to_owned(), + old_identity, + old_content_digest, + old_security_digest, + new_identity: policy_security::file_identity(new_file) + .context("failed to capture replacement policy identity")?, + new_content_digest: sha256_digest(new_bytes), + new_security_digest: policy_security::security_state_digest(new_file) + .context("failed to capture replacement policy security")?, + }) + } + + fn to_bytes(&self) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "Version": 3, + "TransactionId": self.id.to_string(), + "FinalLeaf": self.final_leaf, + "OldVolumeSerial": self.old_identity.volume_serial, + "OldFileId": hex::encode(self.old_identity.file_id), + "OldContentDigest": hex::encode(self.old_content_digest), + "OldSecurityDigest": hex::encode(self.old_security_digest), + "NewVolumeSerial": self.new_identity.volume_serial, + "NewFileId": hex::encode(self.new_identity.file_id), + "NewContentDigest": hex::encode(self.new_content_digest), + "NewSecurityDigest": hex::encode(self.new_security_digest), + })) + .expect("transaction marker fields always serialize") + } + + fn from_bytes(bytes: &[u8]) -> anyhow::Result { + let value: serde_json::Value = serde_json::from_slice(bytes).context("transaction marker is not valid JSON")?; + let object = value.as_object().context("transaction marker must be an object")?; + ensure!(object.len() == 11, "transaction marker contains unexpected fields"); + ensure!( + object.get("Version").and_then(serde_json::Value::as_u64) == Some(3), + "unsupported transaction marker" + ); + let text = |name: &str| -> anyhow::Result<&str> { + object + .get(name) + .and_then(serde_json::Value::as_str) + .with_context(|| format!("transaction marker {name} is missing or invalid")) + }; + let decode = |name: &str| -> anyhow::Result<[u8; 32]> { + let mut output = [0u8; 32]; + hex::decode_to_slice(text(name)?, &mut output) + .with_context(|| format!("transaction marker {name} is invalid"))?; + Ok(output) + }; + let decode_file_id = |name: &str| -> anyhow::Result<[u8; 16]> { + let mut output = [0u8; 16]; + hex::decode_to_slice(text(name)?, &mut output) + .with_context(|| format!("transaction marker {name} is invalid"))?; + Ok(output) + }; + Ok(Self { + id: uuid::Uuid::parse_str(text("TransactionId")?).context("transaction marker id is invalid")?, + final_leaf: text("FinalLeaf")?.to_owned(), + old_identity: FileIdentity { + volume_serial: object + .get("OldVolumeSerial") + .and_then(serde_json::Value::as_u64) + .context("transaction marker OldVolumeSerial is missing or invalid")?, + file_id: decode_file_id("OldFileId")?, + }, + old_content_digest: decode("OldContentDigest")?, + old_security_digest: decode("OldSecurityDigest")?, + new_identity: FileIdentity { + volume_serial: object + .get("NewVolumeSerial") + .and_then(serde_json::Value::as_u64) + .context("transaction marker NewVolumeSerial is missing or invalid")?, + file_id: decode_file_id("NewFileId")?, + }, + new_content_digest: decode("NewContentDigest")?, + new_security_digest: decode("NewSecurityDigest")?, + }) + } +} + +fn conditional_replace( + hosting_dir: &VerifiedHostingDirectory, + observed_target: RetainedPolicyFile, + expected_fingerprint: &DiskFingerprint, + final_path: &Path, + bytes: &[u8], +) -> Result { + use std::io::Write as _; + + let dir_handle = hosting_dir + .handle + .as_ref() + .expect("real storage always retains the directory handle"); + let paths = + TransactionPaths::new(hosting_dir.canonical_path(), final_path).map_err(WriteFailure::PrePublication)?; + + let mut marker_file = + create_secure_transaction_file(&paths.marker_staging).map_err(WriteFailure::PrePublication)?; + let mut temp_file = match create_secure_transaction_file(&paths.new) { + Ok(file) => file, + Err(error) => { + let error = cleanup_transaction_files(error, &[(&marker_file, "marker staging cleanup also failed")]); + return Err(WriteFailure::PrePublication(error)); + } + }; + if let Err(error) = temp_file + .write_all(bytes) + .and_then(|()| temp_file.sync_all()) + .context("failed to persist replacement policy") + .and_then(|()| { + policy_security::verify_managed_policy_file_security(&temp_file) + .context("replacement policy temporary file failed security verification") + }) + { + let error = cleanup_transaction_files( + error, + &[ + (&temp_file, "temporary replacement cleanup also failed"), + (&marker_file, "marker staging cleanup also failed"), + ], + ); + return Err(WriteFailure::PrePublication(error)); + } + let marker = + match TransactionMarker::from_observation(paths.id, final_path, expected_fingerprint, &temp_file, bytes) { + Ok(marker) => marker, + Err(error) => { + let error = cleanup_transaction_files( + error, + &[ + (&temp_file, "replacement policy cleanup also failed"), + (&marker_file, "marker staging cleanup also failed"), + ], + ); + return Err(WriteFailure::ConcurrentChange(error)); + } + }; + + if let Err(error) = marker_file + .write_all(&marker.to_bytes()) + .and_then(|()| marker_file.sync_all()) + .context("failed to persist policy transaction marker") + { + let error = cleanup_transaction_files( + error, + &[ + (&marker_file, "incomplete transaction marker cleanup also failed"), + (&temp_file, "replacement policy cleanup also failed"), + ], + ); + return Err(WriteFailure::PrePublication(error)); + } + if let Err(error) = rename_file_handle( + &marker_file, + dir_handle, + paths.marker.file_name().expect("transaction marker path has leaf"), + ) { + let error = cleanup_transaction_files( + anyhow::Error::new(error).context("failed to publish completed transaction marker"), + &[ + (&marker_file, "transaction marker staging cleanup also failed"), + (&temp_file, "replacement policy cleanup also failed"), + ], + ); + return Err(WriteFailure::PrePublication(error)); + } + + let prepared = PreparedTransaction { + dir_handle, + observed_target: &observed_target, + temp_file: &temp_file, + paths: &paths, + final_path, + }; + publish_prepared_transaction( + &prepared, + || Ok(()), + || verify_replacement_evidence(hosting_dir, &observed_target, expected_fingerprint), + || Ok(()), + )?; + + let persisted = + verify_persisted_handle(hosting_dir, &temp_file, final_path, bytes).map_err(WriteFailure::PostPublication)?; + delete_file_handle(observed_target.handle()).map_err(WriteFailure::PostPublication)?; + drop(observed_target); + delete_file_handle(&marker_file).map_err(WriteFailure::PostPublication)?; + drop(marker_file); + Ok(persisted) +} + +fn cleanup_transaction_files(mut error: anyhow::Error, files: &[(&File, &str)]) -> anyhow::Error { + for (file, message) in files { + if let Err(cleanup_error) = delete_file_handle(file) { + error = error.context(format!("{message}: {cleanup_error:#}")); + } + } + error +} + +struct PreparedTransaction<'a> { + dir_handle: &'a File, + observed_target: &'a RetainedPolicyFile, + temp_file: &'a File, + paths: &'a TransactionPaths, + final_path: &'a Path, +} + +fn publish_prepared_transaction( + transaction: &PreparedTransaction<'_>, + after_tombstone: impl FnOnce() -> anyhow::Result<()>, + verify_before_publish: impl FnOnce() -> anyhow::Result<()>, + after_publish: impl FnOnce() -> anyhow::Result<()>, +) -> Result<(), WriteFailure> { + if let Err(error) = rename_file_handle( + transaction.observed_target.handle(), + transaction.dir_handle, + transaction.paths.old.file_name().expect("transaction path has leaf"), + ) { + return Err(WriteFailure::PrePublication( + anyhow::Error::new(error).context("failed to reserve observed policy as transaction tombstone"), + )); + } + + after_tombstone().map_err(|error| { + WriteFailure::PrePublication(error.context("transaction interrupted after reserving the observed policy")) + })?; + + if let Err(error) = verify_before_publish() { + let restore_error = rename_file_handle( + transaction.observed_target.handle(), + transaction.dir_handle, + transaction.final_path.file_name().expect("policy path has leaf"), + ) + .err(); + return Err(WriteFailure::ConcurrentChange(match restore_error { + Some(restore_error) => error.context(format!( + "pre-publication evidence changed and the exact tombstone could not be restored: {restore_error}" + )), + None => error.context("pre-publication evidence changed; the exact tombstone was restored"), + })); + } + + if let Err(publish_error) = rename_file_handle( + transaction.temp_file, + transaction.dir_handle, + transaction.final_path.file_name().expect("policy path has leaf"), + ) { + let restore_result = rename_file_handle( + transaction.observed_target.handle(), + transaction.dir_handle, + transaction.final_path.file_name().expect("policy path has leaf"), + ); + if let Err(restore_error) = restore_result { + let final_guard = open_optional_final_guard(transaction.final_path).map_err(|error| { + WriteFailure::ConcurrentChange(error.context(format!( + "replacement publication and tombstone restoration failed: {publish_error}; {restore_error}" + ))) + })?; + let Some(final_guard) = final_guard else { + return Err(WriteFailure::PrePublication( + anyhow::Error::new(publish_error).context(format!( + "replacement publication failed and the original tombstone could not be restored: {restore_error}" + )), + )); + }; + drop(final_guard); + return Err(WriteFailure::ConcurrentChange( + anyhow::Error::new(publish_error).context("replacement lost a create-new publication race"), + )); + } + return Err(WriteFailure::PrePublication( + anyhow::Error::new(publish_error).context("replacement publication failed and the tombstone was restored"), + )); + } + + after_publish() + .context("transaction interrupted after replacement publication") + .map_err(WriteFailure::PostPublication)?; + + Ok(()) +} + +fn create_secure_transaction_file(path: &Path) -> anyhow::Result { + let security_attributes = + policy_security::admin_only_security_attributes(false).context("build transaction file security")?; + let path = U16CString::from_os_str(path.as_os_str()).context("transaction path contains an interior NUL")?; + // SAFETY: The path and security attributes remain valid for the call, and the returned handle is owned. + let handle = unsafe { + CreateFileW( + path.as_pcwstr(), + GENERIC_READ.0 | GENERIC_WRITE.0 | DELETE.0 | READ_CONTROL.0, + FILE_SHARE_NONE, + Some(security_attributes.as_ptr()), + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH, + None, + ) + } + .context("failed to create secure transaction file")?; + // SAFETY: CreateFileW returned a new owned handle. + Ok(File::from(unsafe { OwnedHandle::from_raw_handle(handle.0) })) +} + +fn open_transaction_file(path: &Path) -> anyhow::Result { + OpenOptions::new() + .access_mode(FILE_GENERIC_READ.0 | DELETE.0 | READ_CONTROL.0) + .share_mode(FILE_SHARE_READ.0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(path) + .with_context(|| format!("failed to open transaction remnant {}", path.display())) +} + +#[derive(Debug)] +enum RenameFailure { + Win32(std::io::Error), + Native { win32: std::io::Error, status: NTSTATUS }, +} + +impl RenameFailure { + const STATUS_OBJECT_NAME_COLLISION: u32 = 0xC000_0035; + const STATUS_ACCESS_DENIED: u32 = 0xC000_0022; + const STATUS_SHARING_VIOLATION: u32 = 0xC000_0043; + const STATUS_INVALID_INFO_CLASS: u32 = 0xC000_0003; + const STATUS_INVALID_PARAMETER: u32 = 0xC000_000D; + const STATUS_INVALID_DEVICE_REQUEST: u32 = 0xC000_0010; + const STATUS_NOT_SUPPORTED: u32 = 0xC000_00BB; + + fn is_collision(&self) -> bool { + match self { + Self::Win32(error) => win32_error_is(error, &[ERROR_FILE_EXISTS, ERROR_ALREADY_EXISTS]), + Self::Native { status, .. } => status.0.cast_unsigned() == Self::STATUS_OBJECT_NAME_COLLISION, + } + } + + fn is_permission_failure(&self) -> bool { + match self { + Self::Win32(error) => win32_error_is(error, &[ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION]), + Self::Native { status, .. } => { + matches!( + status.0.cast_unsigned(), + Self::STATUS_ACCESS_DENIED | Self::STATUS_SHARING_VIOLATION + ) + } + } + } + + fn is_unsupported(&self) -> bool { + match self { + Self::Win32(error) => win32_error_is( + error, + &[ERROR_INVALID_FUNCTION, ERROR_NOT_SUPPORTED, ERROR_INVALID_PARAMETER], + ), + Self::Native { status, .. } => matches!( + status.0.cast_unsigned(), + Self::STATUS_INVALID_INFO_CLASS + | Self::STATUS_INVALID_PARAMETER + | Self::STATUS_INVALID_DEVICE_REQUEST + | Self::STATUS_NOT_SUPPORTED + ), + } + } +} + +impl std::fmt::Display for RenameFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Win32(error) => write!(f, "handle-relative rename failed through Win32: {error}"), + Self::Native { win32, status } => write!( + f, + "handle-relative rename failed through Win32 ({win32}) and NT ({:#010X})", + status.0.cast_unsigned() + ), + } + } +} + +impl std::error::Error for RenameFailure {} + +fn win32_error_is(error: &std::io::Error, codes: &[WIN32_ERROR]) -> bool { + let Some(raw) = error.raw_os_error() else { + return false; + }; + + codes.iter().any(|code| { + raw == code.0.cast_signed() + // windows-rs converts its Error to io::Error with the HRESULT as raw_os_error. + || raw == code.to_hresult().0 + }) +} + +fn rename_with_fallback( + preferred: impl FnOnce() -> std::io::Result<()>, + fallback: impl FnOnce() -> Result<(), NTSTATUS>, +) -> Result<(), RenameFailure> { + match preferred() { + Ok(()) => Ok(()), + Err(error) + if win32_error_is( + &error, + &[ + ERROR_FILE_EXISTS, + ERROR_ALREADY_EXISTS, + ERROR_ACCESS_DENIED, + ERROR_SHARING_VIOLATION, + ], + ) => + { + Err(RenameFailure::Win32(error)) + } + Err(win32) => fallback().map_err(|status| RenameFailure::Native { win32, status }), + } +} + +fn rename_file_handle(file: &File, root: &File, new_name: &OsStr) -> Result<(), RenameFailure> { + let information = RenameInformation::new(HANDLE(root.as_raw_handle()), new_name); + rename_with_fallback( + || set_file_rename_information(file, &information), + || nt_set_file_rename_information(file, &information), + ) +} + +struct RenameInformation { + buffer: Vec, + length: u32, +} + +impl RenameInformation { + #[expect( + clippy::multiple_unsafe_ops_per_block, + reason = "initializing one variable-length Win32 structure is one logical unsafe operation" + )] + fn new(root: HANDLE, new_name: &OsStr) -> Self { + let name: Vec = new_name.encode_wide().collect(); + let name_bytes = name.len().checked_mul(2).expect("file name byte length fits usize"); + let name_offset = std::mem::offset_of!(FILE_RENAME_INFO, FileName); + let buffer_len = size_of::() + .checked_add(name_bytes) + .expect("rename information length fits usize"); + let mut buffer = vec![0usize; buffer_len.div_ceil(size_of::())]; + let info = buffer.as_mut_ptr().cast::(); + // SAFETY: The aligned buffer is sized for FILE_RENAME_INFO plus the complete UTF-16 name. + unsafe { + (*info).Anonymous = FILE_RENAME_INFO_0 { Flags: 0 }; + (*info).RootDirectory = root; + (*info).FileNameLength = u32::try_from(name_bytes).expect("Windows file name length fits u32"); + std::ptr::copy_nonoverlapping(name.as_ptr(), info.cast::().add(name_offset).cast(), name.len()); + } + Self { + buffer, + length: u32::try_from(buffer_len).expect("rename buffer length fits u32"), + } + } + + fn as_ptr(&self) -> *const core::ffi::c_void { + self.buffer.as_ptr().cast() + } +} + +fn set_file_rename_information(file: &File, information: &RenameInformation) -> std::io::Result<()> { + // SAFETY: `information` contains a valid variable-length FILE_RENAME_INFO buffer. + let result = unsafe { + SetFileInformationByHandle( + HANDLE(file.as_raw_handle()), + FileRenameInfoEx, + information.as_ptr(), + information.length, + ) + }; + match result { + Ok(()) => Ok(()), + Err(error) if error.code() == windows::Win32::Foundation::E_INVALIDARG => { + // SAFETY: The same validated buffer is accepted by the older information class. + unsafe { + SetFileInformationByHandle( + HANDLE(file.as_raw_handle()), + FileRenameInfo, + information.as_ptr(), + information.length, + ) + .map_err(std::io::Error::from) + } + } + Err(error) => Err(std::io::Error::from(error)), + } +} + +fn nt_set_file_rename_information(file: &File, information: &RenameInformation) -> Result<(), NTSTATUS> { + let mut io_status = IoStatusBlock { + status_or_pointer: 0, + information: 0, + }; + + // SAFETY: `information` contains a valid variable-length FILE_RENAME_INFORMATION_EX buffer. + let status = unsafe { + NtSetInformationFile( + HANDLE(file.as_raw_handle()), + &mut io_status, + information.as_ptr(), + information.length, + FILE_RENAME_INFORMATION_EX_CLASS, + ) + }; + if status.0 >= 0 { Ok(()) } else { Err(status) } +} + +fn delete_file_handle(file: &File) -> anyhow::Result<()> { + let extended = FILE_DISPOSITION_INFO_EX { + Flags: FILE_DISPOSITION_INFO_EX_FLAGS( + FILE_DISPOSITION_FLAG_DELETE.0 + | FILE_DISPOSITION_FLAG_POSIX_SEMANTICS.0 + | FILE_DISPOSITION_FLAG_IGNORE_READONLY_ATTRIBUTE.0, + ), + }; + // SAFETY: The file handle is valid and extended points to a correctly sized input structure. + unsafe { + SetFileInformationByHandle( + HANDLE(file.as_raw_handle()), + FileDispositionInfoEx, + std::ptr::from_ref(&extended).cast(), + u32::try_from(size_of::()).expect("disposition structure size fits u32"), + ) + } + .context("failed to unlink transaction file by handle")?; + + Ok(()) +} + +fn prepublication_failure_after_cleanup( + file: &File, + error: anyhow::Error, + cleanup_failure_message: &str, +) -> WriteFailure { + match delete_file_handle(file) { + Ok(()) => WriteFailure::PrePublication(error), + Err(cleanup_error) => { + WriteFailure::PrePublication(error.context(format!("{cleanup_failure_message}: {cleanup_error:#}"))) + } + } +} + +fn recover_create_temporary_files(dir_path: &Path) -> anyhow::Result<()> { + let prefix = OsString::from(format!(".{POLICY_FILE_NAME}.tmp-")); + for entry in std::fs::read_dir(dir_path).context("failed to enumerate policy directory for create recovery")? { + let entry = entry.context("failed to enumerate policy create remnant")?; + let Some(remainder) = reserved_name_remainder(&entry.file_name(), &prefix)? else { + continue; + }; + uuid::Uuid::parse_str(&remainder).context("policy directory contains a malformed create remnant")?; + let path = entry.path(); + let file = open_transaction_file(&path)?; + verify_transaction_file_path(&file, &path)?; + policy_security::verify_managed_policy_file_security(&file) + .context("policy create remnant security is invalid")?; + delete_file_handle(&file).context("failed to retire policy create remnant")?; + drop(file); + } + Ok(()) +} + +fn reserved_name_remainder(name: &OsStr, prefix: &OsStr) -> anyhow::Result> { + let name_wide: Vec = name.encode_wide().collect(); + let prefix_wide: Vec = prefix.encode_wide().collect(); + if name_wide.len() < prefix_wide.len() { + return Ok(None); + } + let candidate_prefix = OsString::from_wide(&name_wide[..prefix_wide.len()]); + if !policy_security::os_strings_match_case_insensitive(&candidate_prefix, prefix) { + return Ok(None); + } + String::from_utf16(&name_wide[prefix_wide.len()..]) + .context("reserved policy remnant name is not Unicode") + .map(Some) +} + +fn recover_interrupted_transaction(dir: &File, dir_path: &Path, final_leaf: &OsStr) -> anyhow::Result<()> { + let final_leaf = final_leaf.to_str().context("policy leaf is not valid Unicode")?; + let transaction_prefix = OsString::from(format!(".{final_leaf}.txn-")); + let mut transaction_id = None; + let mut marker_staging_path = None; + let mut marker_path = None; + let mut old_path = None; + let mut new_path = None; + + for entry in std::fs::read_dir(dir_path).context("failed to enumerate policy directory for transaction recovery")? { + let entry = entry.context("failed to enumerate policy transaction remnant")?; + let name = entry.file_name(); + let Some(remainder) = reserved_name_remainder(&name, &transaction_prefix)? else { + continue; + }; + let Some((id, kind)) = remainder.split_once('.') else { + bail!("policy directory contains a malformed transaction remnant"); + }; + let id = uuid::Uuid::parse_str(id).context("policy directory contains a malformed transaction id")?; + if transaction_id.replace(id).is_some_and(|previous| previous != id) { + bail!("policy directory contains multiple interrupted transactions"); + } + let slot = match kind { + "marker.prepare" => &mut marker_staging_path, + "marker" => &mut marker_path, + "old" => &mut old_path, + "new" => &mut new_path, + _ => bail!("policy directory contains an unsupported transaction remnant"), + }; + ensure!( + slot.replace(entry.path()).is_none(), + "policy directory contains duplicate transaction remnants" + ); + } + + let Some(id) = transaction_id else { + return Ok(()); + }; + if let Some(marker_staging_path) = marker_staging_path { + ensure!( + marker_path.is_none() && old_path.is_none(), + "incomplete marker staging is mixed with published transaction remnants" + ); + let marker_staging = open_transaction_file(&marker_staging_path)?; + verify_transaction_file_path(&marker_staging, &marker_staging_path)?; + policy_security::verify_managed_policy_file_security(&marker_staging) + .context("transaction marker staging security is invalid")?; + let final_path = dir_path.join(final_leaf); + let new_file = if let Some(new_path) = new_path { + let file = open_transaction_file(&new_path)?; + verify_orphan_transaction_file(&file, &new_path)?; + Some(file) + } else { + None + }; + return recover_marker_staging(&final_path, marker_staging, new_file); + } + if marker_path.is_none() && old_path.is_none() { + let new_path = new_path.context("interrupted policy transaction has no durable state")?; + let new_file = open_transaction_file(&new_path)?; + verify_orphan_transaction_file(&new_file, &new_path)?; + let final_file = open_optional_final_policy(&dir_path.join(final_leaf))? + .context("orphan replacement has no original final")?; + verify_safe_existing_policy(&final_file, &dir_path.join(final_leaf))?; + delete_file_handle(&new_file).context("failed to retire orphan replacement")?; + drop(new_file); + return Ok(()); + } + let marker_path = marker_path.context("interrupted policy transaction has no marker")?; + let paths = TransactionPaths { + id, + marker_staging: dir_path.join(format!(".{final_leaf}.txn-{id}.marker.prepare")), + marker: marker_path, + old: old_path.unwrap_or_else(|| dir_path.join(format!(".{final_leaf}.txn-{id}.old"))), + new: new_path.unwrap_or_else(|| dir_path.join(format!(".{final_leaf}.txn-{id}.new"))), + }; + let marker_file = open_transaction_file(&paths.marker)?; + verify_transaction_file_path(&marker_file, &paths.marker)?; + policy_security::verify_managed_policy_file_security(&marker_file) + .context("transaction marker security is invalid")?; + let marker_bytes = read_file_from_start(&marker_file)?; + let marker = TransactionMarker::from_bytes(&marker_bytes)?; + ensure!(marker.id == id, "transaction marker id does not match its name"); + ensure!( + policy_security::os_strings_match_case_insensitive(OsStr::new(&marker.final_leaf), OsStr::new(final_leaf)), + "transaction marker targets a different policy leaf" + ); + + let old_file = open_optional_transaction_file(&paths.old)?; + if let Some(old_file) = &old_file { + verify_transaction_file_path(old_file, &paths.old)?; + verify_transaction_file_state( + old_file, + marker.old_identity, + marker.old_content_digest, + marker.old_security_digest, + ) + .context("transaction tombstone does not match the observed policy")?; + } + + let new_file = open_optional_transaction_file(&paths.new)?; + if let Some(new_file) = &new_file { + verify_transaction_file_path(new_file, &paths.new)?; + verify_transaction_file_state( + new_file, + marker.new_identity, + marker.new_content_digest, + marker.new_security_digest, + ) + .context("transaction replacement does not match the prepared policy")?; + } + + recover_verified_transaction_with_evidence( + dir, + dir_path, + OsStr::new(final_leaf), + &marker, + marker_file, + old_file, + new_file, + ) +} + +fn recover_marker_staging(final_path: &Path, marker_staging: File, new_file: Option) -> anyhow::Result<()> { + let final_guard = open_optional_final_policy(final_path)? + .context("incomplete marker staging exists but the original policy is absent")?; + let marker_bytes = read_file_from_start(&marker_staging)?; + if let Ok(marker) = TransactionMarker::from_bytes(&marker_bytes) { + verify_transaction_file_state( + &final_guard, + marker.old_identity, + marker.old_content_digest, + marker.old_security_digest, + ) + .context("original policy changed during marker preparation")?; + if let Some(new_file) = &new_file { + verify_transaction_file_state( + new_file, + marker.new_identity, + marker.new_content_digest, + marker.new_security_digest, + ) + .context("prepared replacement changed during marker preparation")?; + } + } else { + verify_safe_existing_policy(&final_guard, final_path)?; + } + if let Some(new_file) = new_file { + delete_file_handle(&new_file).context("failed to retire pre-marker replacement")?; + drop(new_file); + } + delete_file_handle(&marker_staging).context("failed to retire incomplete transaction marker staging")?; + drop(marker_staging); + drop(final_guard); + Ok(()) +} + +fn verify_orphan_transaction_file(file: &File, expected_path: &Path) -> anyhow::Result<()> { + verify_transaction_file_path(file, expected_path)?; + policy_security::verify_policy_file_path(file, expected_path)?; + policy_security::verify_managed_policy_file_security(file)?; + Ok(()) +} + +fn verify_safe_existing_policy(file: &File, expected_path: &Path) -> anyhow::Result<()> { + verify_orphan_transaction_file(file, expected_path)?; + let policy = serde_json::from_slice::(&read_file_from_start(file)?) + .context("existing final is not a policy document")?; + ensure!( + validation::validate_committed_policy(&policy).is_valid, + "existing final failed semantic validation" + ); + Ok(()) +} + +fn recover_verified_transaction_with_evidence( + dir: &File, + dir_path: &Path, + final_leaf: &OsStr, + marker: &TransactionMarker, + marker_file: File, + mut old_file: Option, + new_file: Option, +) -> anyhow::Result<()> { + let final_path = dir_path.join(final_leaf); + let mut final_guard = open_optional_final_policy(&final_path)?; + let mut restored = false; + if final_guard.is_none() { + let old_file = old_file + .as_ref() + .context("interrupted transaction has neither a final policy nor a valid tombstone")?; + match rename_file_handle(old_file, dir, final_leaf) { + Ok(()) => restored = true, + Err(error) => { + final_guard = open_optional_final_policy(&final_path)?; + if final_guard.is_none() { + return Err(error).context("failed to restore interrupted policy transaction"); + } + tracing::warn!(%error, "An external policy appeared while transaction recovery restored the tombstone"); + } + } + } + + if !restored { + let final_file = final_guard + .as_ref() + .context("interrupted transaction has no final policy after recovery")?; + let final_state = verify_recovery_final(final_file, &final_path, marker)?; + if old_file.is_some() && final_state != RecoveryFinalState::PublishedReplacement { + bail!("raced final policy is not the intended published replacement; preserving recovery remnants"); + } + if let Some(old_file) = old_file.take() { + delete_file_handle(&old_file).context("failed to retire policy transaction tombstone")?; + drop(old_file); + } + } + if let Some(new_file) = new_file { + delete_file_handle(&new_file).context("failed to retire unpublished policy transaction replacement")?; + drop(new_file); + } + delete_file_handle(&marker_file).context("failed to retire policy transaction marker")?; + drop(marker_file); + drop(final_guard); + Ok(()) +} + +#[cfg(test)] +fn recover_verified_transaction( + dir: &File, + dir_path: &Path, + final_leaf: &OsStr, + marker_file: File, + old_file: Option, + new_file: Option, +) -> anyhow::Result<()> { + let final_path = dir_path.join(final_leaf); + let mut final_guard = open_optional_final_guard(&final_path)?; + let mut old_file = old_file; + let restored = if final_guard.is_none() { + let old = old_file + .as_ref() + .context("test transaction has neither a final policy nor a tombstone")?; + rename_file_handle(old, dir, final_leaf)?; + true + } else { + false + }; + if !restored && let Some(old) = old_file.take() { + delete_file_handle(&old)?; + } + if let Some(new_file) = new_file { + delete_file_handle(&new_file)?; + } + delete_file_handle(&marker_file)?; + drop(final_guard.take()); + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RecoveryFinalState { + ObservedOriginal, + PublishedReplacement, +} + +fn verify_recovery_final( + file: &File, + expected_path: &Path, + marker: &TransactionMarker, +) -> anyhow::Result { + verify_transaction_file_path(file, expected_path)?; + policy_security::verify_policy_file_path(file, expected_path)?; + let attributes = file.metadata()?.file_attributes(); + ensure!( + attributes & FILE_ATTRIBUTE_DIRECTORY.0 == 0, + "transaction final path is a directory" + ); + + if verify_transaction_file_state( + file, + marker.old_identity, + marker.old_content_digest, + marker.old_security_digest, + ) + .is_ok() + { + return Ok(RecoveryFinalState::ObservedOriginal); + } + + verify_transaction_file_state( + file, + marker.new_identity, + marker.new_content_digest, + marker.new_security_digest, + ) + .context("transaction final policy is not the prepared replacement")?; + let content = read_file_from_start(file)?; + let policy = serde_json::from_slice::(&content) + .context("transaction final replacement is not a policy document")?; + let validation = validation::validate_committed_policy(&policy); + ensure!( + validation.is_valid, + "transaction final replacement failed committed-policy validation" + ); + Ok(RecoveryFinalState::PublishedReplacement) +} + +fn open_optional_final_policy(path: &Path) -> anyhow::Result> { + match OpenOptions::new() + .access_mode(FILE_GENERIC_READ.0 | READ_CONTROL.0) + .share_mode(FILE_SHARE_READ.0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(path) + { + Ok(file) => Ok(Some(file)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("failed to retain raced policy {}", path.display())), + } +} + +fn open_optional_final_guard(path: &Path) -> anyhow::Result> { + match OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES.0) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE).0) + .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(path) + { + Ok(file) => Ok(Some(file)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("failed to retain raced policy {}", path.display())), + } +} + +fn open_optional_transaction_file(path: &Path) -> anyhow::Result> { + match open_transaction_file(path) { + Ok(file) => Ok(Some(file)), + Err(error) + if error + .root_cause() + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) => + { + Ok(None) + } + Err(error) => Err(error), + } +} + +fn verify_transaction_file_path(file: &File, expected: &Path) -> anyhow::Result<()> { + ensure!( + policy_security::file_link_count(file)? == 1, + "transaction file has multiple hard links" + ); + let resolved = policy_security::final_path_from_handle(file)?; + ensure!( + policy_security::paths_match_case_insensitive(&resolved, expected), + "transaction file resolved to an unexpected path" + ); + Ok(()) +} + +fn verify_transaction_file_state( + file: &File, + identity: FileIdentity, + content_digest: [u8; 32], + security_digest: [u8; 32], +) -> anyhow::Result<()> { + ensure!( + policy_security::file_identity(file)? == identity, + "transaction file identity changed" + ); + policy_security::verify_managed_policy_file_security(file)?; + ensure!( + policy_security::security_state_digest(file)? == security_digest, + "transaction file security changed" + ); + ensure!( + sha256_digest(&read_file_from_start(file)?) == content_digest, + "transaction file content changed" + ); + Ok(()) +} + +fn read_file_from_start(file: &File) -> anyhow::Result> { + use std::io::{Read as _, Seek as _, SeekFrom}; + let mut file = file; + file.seek(SeekFrom::Start(0))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + Ok(bytes) +} + +/// Atomically persist `bytes` to `final_path` only if nothing exists there yet: unlike +/// [`atomic_replace`], this never overwrites an existing destination. +/// +/// Used for `Create`, where the store already observed Missing under its write lock. If a +/// leaf has raced into existence between that observation and this call, the rename fails +/// (a [`WriteFailure::PrePublication`], since the destination was never touched) and the +/// caller must re-observe and report a stale token (see `PolicyStore::replace`) rather +/// than ever silently overwriting a file it never actually observed as absent. +pub(super) fn atomic_create( + hosting_dir: &VerifiedHostingDirectory, + expected_fingerprint: &DiskFingerprint, + final_path: &Path, + bytes: &[u8], +) -> Result { + use std::io::Write as _; + + verify_directory_evidence(hosting_dir, expected_fingerprint).map_err(WriteFailure::ConcurrentChange)?; + let dir_handle = hosting_dir + .handle + .as_ref() + .expect("real storage always retains the directory handle"); + let temp_path = hosting_dir + .canonical_path() + .join(format!(".{POLICY_FILE_NAME}.tmp-{}", uuid::Uuid::new_v4())); + let mut temp_file = create_secure_transaction_file(&temp_path).map_err(WriteFailure::PrePublication)?; + if let Err(error) = temp_file + .write_all(bytes) + .and_then(|()| temp_file.sync_all()) + .context("failed to persist new policy") + .and_then(|()| { + policy_security::verify_managed_policy_file_security(&temp_file) + .context("new policy temporary file failed security verification") + }) + { + return Err(prepublication_failure_after_cleanup( + &temp_file, + error, + "temporary policy cleanup also failed", + )); + } + if let Err(failure) = publish_created_file(&temp_file, dir_handle, final_path, || { + verify_directory_evidence(hosting_dir, expected_fingerprint) + }) { + let cleanup_error = delete_file_handle(&temp_file).err(); + return Err(match (failure, cleanup_error) { + (WriteFailure::ConcurrentChange(error), Some(cleanup_error)) => WriteFailure::ConcurrentChange( + error.context(format!("temporary policy cleanup also failed: {cleanup_error:#}")), + ), + (WriteFailure::PrePublication(error), Some(cleanup_error)) => WriteFailure::PrePublication( + error.context(format!("temporary policy cleanup also failed: {cleanup_error:#}")), + ), + (WriteFailure::PostPublication(error), Some(cleanup_error)) => WriteFailure::PostPublication( + error.context(format!("temporary policy cleanup also failed: {cleanup_error:#}")), + ), + (failure, _) => failure, + }); + } + verify_persisted_handle(hosting_dir, &temp_file, final_path, bytes).map_err(WriteFailure::PostPublication) +} + +fn publish_created_file( + temp_file: &File, + dir_handle: &File, + final_path: &Path, + verify_before_publish: impl FnOnce() -> anyhow::Result<()>, +) -> Result<(), WriteFailure> { + verify_before_publish() + .context("policy directory evidence changed before create publication") + .map_err(WriteFailure::ConcurrentChange)?; + rename_file_handle( + temp_file, + dir_handle, + final_path.file_name().expect("policy path has leaf"), + ) + .map_err(|error| { + WriteFailure::PrePublication(anyhow::Error::new(error).context("failed to atomically create policy file")) + }) +} + +/// Verify the published handle, directory, ancestor chain, exact bytes, and parsed policy. +/// The returned [`PersistedPolicy`] reflects the object made active by the handle-relative rename. +/// This check repeats committed-policy validation instead of trusting the earlier draft validation. +/// +fn verify_persisted_handle( + hosting_dir: &VerifiedHostingDirectory, + final_file: &File, + final_path: &Path, + expected_bytes: &[u8], +) -> anyhow::Result { + let dir_security_digest = hosting_dir + .verify_unchanged() + .context("held policy directory changed during replacement")?; + let parent = hosting_dir.identity(); + let ancestor_security_digest = hosting_dir + .ancestor_digest() + .context("policy directory ancestor chain failed verification immediately after writing")?; + let resolved = + policy_security::final_path_from_handle(final_file).context("failed to resolve persisted policy handle")?; + ensure!( + policy_security::paths_match_case_insensitive(&resolved, final_path), + "persisted policy handle resolved to an unexpected path" + ); + let target = policy_security::file_identity(final_file) + .context("failed to query policy file identity for post-write verification")?; + + policy_security::verify_managed_policy_file_security(final_file) + .context("policy file failed security verification immediately after being written")?; + + let security_digest = policy_security::security_state_digest(final_file) + .context("failed to compute policy file security digest immediately after being written")?; + + let persisted = read_file_from_start(final_file).context("failed to re-read persisted policy file")?; + + if persisted != expected_bytes { + bail!("persisted policy file content does not match what was written"); + } + + let policy = serde_json::from_slice::(&persisted) + .context("failed to reparse the freshly persisted policy file")?; + + let committed_validation = validation::validate_committed_policy(&policy); + ensure!( + committed_validation.is_valid, + "freshly persisted policy file failed authoritative semantic validation: {:?}", + committed_validation.findings + ); + + let content_digest = sha256_digest(&persisted); + + Ok(PersistedPolicy { + policy, + fingerprint: DiskFingerprint::Active { + parent, + target, + content_digest, + security_digest, + dir_security_digest, + ancestor_security_digest, + }, + write_capability: PolicyWriteCapability::Writable, + read_only_reason: None, + canonical_path: final_path.to_owned(), + }) +} + +#[cfg(test)] +fn move_replace(from: &Path, to: &Path) -> anyhow::Result<()> { + let from = U16CString::from_os_str(from.as_os_str()).context("replacement path contains an interior NUL")?; + let to = U16CString::from_os_str(to.as_os_str()).context("target path contains an interior NUL")?; + // SAFETY: Both paths are valid, NUL-terminated UTF-16 strings live for the call. + unsafe { MoveFileExW(from.as_pcwstr(), to.as_pcwstr(), MOVEFILE_REPLACE_EXISTING) } + .context("MoveFileExW replacement failed") +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + fn temp_dir() -> tempfile::TempDir { + tempfile::tempdir().expect("create temp dir") + } + + fn secure_test_hosting_directory(root: &Path) -> Option { + let attributes = policy_security::admin_only_security_attributes(true).ok()?; + let parent = open_directory_no_reparse(root).ok()?; + let handle = ensure_secure_directory_component( + &parent, + OsStr::new("PackageBroker"), + &attributes, + DirectorySecurityRole::DedicatedPolicy, + |_| Ok(()), + ) + .ok()?; + let canonical_path = policy_security::final_path_from_handle(&handle).ok()?; + let identity = policy_security::file_identity(&handle).ok()?; + let security_digest = policy_security::security_state_digest(&handle).ok()?; + Some(VerifiedHostingDirectory { + handle: Some(handle), + ancestor_handles: Vec::new(), + canonical_path, + identity, + security_digest, + }) + } + + fn committed_policy_bytes(default_decision: &str) -> Vec { + let draft: now_policy::PolicyDraftDocument = serde_json::from_value(serde_json::json!({ + "$schema": now_policy::POLICY_DRAFT_SCHEMA_URI, + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { "Id": "recovery-test", "Publisher": "Test" }, + "Enforcement": { "DefaultDecision": default_decision, "RulePrecedence": "PriorityThenDeny" }, + "Rules": [] + })) + .unwrap(); + let policy = draft.into_policy_document(1, chrono::Utc::now()).unwrap(); + serde_json::to_vec(&policy).unwrap() + } + + fn valid_committed_policy_bytes() -> Vec { + committed_policy_bytes("Deny") + } + + #[test] + fn default_path_prefers_managed_then_legacy_then_new_location() { + let dir = temp_dir(); + let managed = dir.path().join("PackageBroker").join(POLICY_FILE_NAME); + let legacy = dir.path().join("Agent").join(POLICY_FILE_NAME); + + assert_eq!(select_default_policy_path(managed.clone(), legacy.clone()), managed); + + std::fs::create_dir(legacy.parent().unwrap()).unwrap(); + std::fs::write(&legacy, b"legacy").unwrap(); + assert_eq!(select_default_policy_path(managed.clone(), legacy.clone()), legacy); + + let selected = select_default_policy_path_with(managed.clone(), legacy.clone(), || { + std::fs::create_dir(managed.parent().unwrap()).unwrap(); + std::fs::write(&managed, b"raced-managed").unwrap(); + }); + assert_eq!(selected, managed, "managed policy created during arbitration must win"); + std::fs::remove_file(&managed).unwrap(); + + let marker = managed + .parent() + .unwrap() + .join(format!(".{POLICY_FILE_NAME}.txn-{}.marker", uuid::Uuid::new_v4())); + std::fs::write(&marker, b"interrupted").unwrap(); + assert_eq!( + select_default_policy_path(managed.clone(), legacy.clone()), + managed, + "an interrupted managed transaction must never fall back to legacy policy" + ); + + std::fs::remove_file(marker).unwrap(); + std::fs::write(&managed, b"managed").unwrap(); + assert_eq!(select_default_policy_path(managed.clone(), legacy), managed); + } + + #[test] + fn durable_authority_marker_prevents_legacy_rollback_after_restart() { + let root = temp_dir(); + let Some(hosting) = secure_test_hosting_directory(root.path()) else { + return; + }; + let managed = hosting.canonical_path().join(POLICY_FILE_NAME); + let legacy = root.path().join("Agent").join(POLICY_FILE_NAME); + std::fs::create_dir_all(legacy.parent().unwrap()).unwrap(); + std::fs::write(&legacy, b"legacy").unwrap(); + + assert_eq!(select_default_policy_path(managed.clone(), legacy.clone()), legacy); + ensure_managed_authority_marker(hosting.handle.as_ref().unwrap(), hosting.canonical_path()).unwrap(); + + assert_eq!(select_default_policy_path(managed.clone(), legacy), managed); + assert!( + verify_managed_authority_marker_if_present(hosting.canonical_path()).unwrap(), + "restart evidence must remain durable without a managed final policy" + ); + } + + #[test] + fn published_managed_policy_establishes_authority_before_success() { + let root = temp_dir(); + let Some(hosting) = secure_test_hosting_directory(root.path()) else { + return; + }; + let [managed, _] = default_policy_paths(); + + ensure_published_managed_authority(PolicyConfigurationSource::DefaultPath, &managed, &hosting).unwrap(); + + assert!(verify_managed_authority_marker_if_present(hosting.canonical_path()).unwrap()); + assert_eq!( + std::fs::metadata(hosting.canonical_path().join(MANAGED_AUTHORITY_MARKER_NAME)) + .unwrap() + .len(), + 0, + "the marker must be complete at atomic creation" + ); + } + + #[test] + fn custom_policy_publication_does_not_create_managed_authority() { + let hosting = VerifiedHostingDirectory::for_fake_storage( + PathBuf::from(r"C:\custom"), + test_identity(1), + test_security_digest(1), + ); + + ensure_published_managed_authority( + PolicyConfigurationSource::ConfiguredPath, + Path::new(r"C:\custom\policy.json"), + &hosting, + ) + .unwrap(); + + assert!(!verify_managed_authority_marker_if_present(hosting.canonical_path()).unwrap()); + } + + #[test] + fn invalid_managed_authority_marker_fails_closed_and_is_not_recovered() { + let root = temp_dir(); + let marker_path = root.path().join(MANAGED_AUTHORITY_MARKER_NAME); + std::fs::write(&marker_path, b"invalid").unwrap(); + let dir = open_directory_no_reparse(root.path()).unwrap(); + + assert!(verify_managed_authority_marker_if_present(root.path()).is_err()); + assert!(ensure_managed_authority_marker(&dir, root.path()).is_err()); + assert_eq!(std::fs::read(&marker_path).unwrap(), b"invalid"); + recover_interrupted_transaction(&dir, root.path(), OsStr::new(POLICY_FILE_NAME)).unwrap(); + assert!( + marker_path.exists(), + "routine recovery must not retire authority evidence" + ); + } + + #[test] + fn reparse_managed_authority_marker_fails_closed() { + let root = temp_dir(); + let marker = root.path().join(MANAGED_AUTHORITY_MARKER_NAME); + create_directory_junction(&marker, &root.path().join("missing-target")); + + assert!(verify_managed_authority_marker_if_present(root.path()).is_err()); + } + + #[test] + fn hard_link_managed_authority_marker_fails_closed_and_is_preserved() { + let root = temp_dir(); + let original = root.path().join("original"); + let marker = root.path().join(MANAGED_AUTHORITY_MARKER_NAME); + let Ok(original_file) = create_secure_transaction_file(&original) else { + return; + }; + original_file.sync_all().unwrap(); + drop(original_file); + std::fs::hard_link(&original, &marker).unwrap(); + let dir = open_directory_no_reparse(root.path()).unwrap(); + + assert!(ensure_managed_authority_marker(&dir, root.path()).is_err()); + assert!(marker.exists()); + assert!(original.exists()); + } + + fn open_deletable_test_file(path: &Path, content: &[u8]) -> File { + std::fs::write(path, content).unwrap(); + OpenOptions::new() + .access_mode(FILE_GENERIC_READ.0 | DELETE.0 | READ_CONTROL.0) + .share_mode(FILE_SHARE_READ.0) + .open(path) + .unwrap() + } + + #[test] + fn handle_rename_never_replaces_an_existing_destination() { + let dir = temp_dir(); + let source = dir.path().join("source.tmp"); + let destination = dir.path().join("destination.json"); + let source_file = open_deletable_test_file(&source, b"source"); + std::fs::write(&destination, b"destination").unwrap(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + + rename_file_handle(&source_file, &dir_file, destination.file_name().unwrap()).unwrap_err(); + + assert_eq!(std::fs::read(&source).unwrap(), b"source"); + assert_eq!(std::fs::read(&destination).unwrap(), b"destination"); + } + + #[test] + fn create_evidence_change_never_publishes_the_temporary_file() { + let dir = temp_dir(); + let temporary = dir.path().join("temporary.tmp"); + let final_path = dir.path().join("policy.json"); + let temporary_file = open_deletable_test_file(&temporary, b"new"); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + + let result = publish_created_file(&temporary_file, &dir_file, &final_path, || { + anyhow::bail!("simulated ancestor ACL change") + }); + + assert!(matches!(result, Err(WriteFailure::ConcurrentChange(_)))); + assert!(!final_path.exists()); + assert_eq!(std::fs::read(&temporary).unwrap(), b"new"); + } + + #[test] + fn relative_handle_rename_uses_retained_directory_root() { + let dir = temp_dir(); + let source = dir.path().join("source.tmp"); + let destination = dir.path().join("destination.json"); + let source_file = open_deletable_test_file(&source, b"source"); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + + rename_file_handle(&source_file, &dir_file, destination.file_name().unwrap()).unwrap(); + + assert!(!source.exists()); + assert_eq!(std::fs::read(&destination).unwrap(), b"source"); + } + + #[test] + fn retained_custom_directory_handle_supports_create_and_replace_renames() { + let dir = temp_dir(); + let mut handles = + policy_security::retain_policy_no_reparse_directory_chain(dir.path(), "custom policy directory").unwrap(); + let dir_handle = handles.pop().expect("retained custom directory handle"); + let first_path = dir.path().join("first.tmp"); + let replacement_path = dir.path().join("replacement.tmp"); + let final_path = dir.path().join("policy.json"); + let tombstone_path = dir.path().join("policy.old"); + let first = open_deletable_test_file(&first_path, b"first"); + let replacement = open_deletable_test_file(&replacement_path, b"replacement"); + + rename_file_handle(&first, &dir_handle, final_path.file_name().unwrap()).unwrap(); + rename_file_handle(&replacement, &dir_handle, final_path.file_name().unwrap()).unwrap_err(); + rename_file_handle(&first, &dir_handle, tombstone_path.file_name().unwrap()).unwrap(); + rename_file_handle(&replacement, &dir_handle, final_path.file_name().unwrap()).unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), b"replacement"); + assert_eq!(std::fs::read(&tombstone_path).unwrap(), b"first"); + } + + #[test] + fn native_relative_handle_rename_uses_retained_directory_root() { + let dir = temp_dir(); + let source = dir.path().join("source-native.tmp"); + let destination = dir.path().join("destination-native.json"); + let source_file = open_deletable_test_file(&source, b"source"); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + + let information = RenameInformation::new(HANDLE(dir_file.as_raw_handle()), destination.file_name().unwrap()); + nt_set_file_rename_information(&source_file, &information).unwrap(); + + assert!(!source.exists()); + assert_eq!(std::fs::read(&destination).unwrap(), b"source"); + } + + #[test] + fn retained_directory_prevents_path_retarget_during_handle_rename() { + let root = temp_dir(); + let dir = root.path().join("held"); + std::fs::create_dir(&dir).unwrap(); + let source = dir.join("source.tmp"); + let destination = dir.join("destination.json"); + let source_file = open_deletable_test_file(&source, b"source"); + let dir_file = open_directory_no_reparse(&dir).unwrap(); + + assert!(std::fs::rename(&dir, root.path().join("retargeted")).is_err()); + rename_file_handle(&source_file, &dir_file, destination.file_name().unwrap()).unwrap(); + + assert_eq!(std::fs::read(&destination).unwrap(), b"source"); + assert!(!root.path().join("retargeted").exists()); + } + + #[test] + fn retained_write_observation_blocks_external_target_changes() { + let dir = temp_dir(); + let path = dir.path().join("policy.json"); + let replacement = dir.path().join("replacement.json"); + let retained = open_deletable_test_file(&path, b"observed"); + std::fs::write(&replacement, b"replacement").unwrap(); + + assert!(std::fs::write(&path, b"edited").is_err()); + assert!(std::fs::remove_file(&path).is_err()); + assert!(std::fs::rename(&path, dir.path().join("replaced.json")).is_err()); + assert!(move_replace(&replacement, &path).is_err()); + assert_eq!(read_file_from_start(&retained).unwrap(), b"observed"); + assert_eq!(std::fs::read(&replacement).unwrap(), b"replacement"); + } + + #[test] + fn ordinary_reader_blocks_retention_but_not_read_observation() { + let dir = temp_dir(); + let path = dir.path().join("policy.json"); + std::fs::write(&path, b"observed").unwrap(); + let reader = OpenOptions::new() + .read(true) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE).0) + .open(&path) + .unwrap(); + + let ordinary_observation = open_policy_file(&path, false).unwrap(); + assert!(!ordinary_observation.retained_for_write); + let fallback_observation = open_policy_file(&path, true).unwrap(); + assert!(!fallback_observation.retained_for_write); + assert_eq!(read_file_from_start(&reader).unwrap(), b"observed"); + } + + #[test] + fn ordinary_observation_waits_for_preexisting_writer_or_deleter() { + let dir = temp_dir(); + let path = dir.path().join("policy.json"); + std::fs::write(&path, b"observed").unwrap(); + let writer = OpenOptions::new() + .read(true) + .write(true) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) + .open(&path) + .unwrap(); + + assert!(open_policy_file(&path, false).is_err()); + drop(writer); + assert!(open_policy_file(&path, false).is_ok()); + + let deleter = OpenOptions::new() + .access_mode(FILE_GENERIC_READ.0 | DELETE.0) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) + .open(&path) + .unwrap(); + assert!(open_policy_file(&path, false).is_err()); + drop(deleter); + assert!(open_policy_file(&path, false).is_ok()); + } + + #[test] + fn handle_cleanup_removes_read_only_transaction_files() { + let dir = temp_dir(); + let path = dir.path().join("readonly.old"); + std::fs::write(&path, b"old").unwrap(); + let mut permissions = std::fs::metadata(&path).unwrap().permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&path, permissions).unwrap(); + let file = open_transaction_file(&path).unwrap(); + + delete_file_handle(&file).unwrap(); + drop(file); + + assert!(!path.exists()); + } + + #[test] + fn conditional_publication_preserves_a_final_created_after_tombstoning() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let paths = TransactionPaths::new(dir.path(), &final_path).unwrap(); + let observed = RetainedPolicyFile::Real(open_deletable_test_file(&final_path, b"observed")); + let marker = open_deletable_test_file(&paths.marker, b"marker"); + let replacement = open_deletable_test_file(&paths.new, b"replacement"); + + let prepared = PreparedTransaction { + dir_handle: &dir_file, + observed_target: &observed, + temp_file: &replacement, + paths: &paths, + final_path: &final_path, + }; + let result = publish_prepared_transaction( + &prepared, + || { + std::fs::write(&final_path, b"external")?; + Ok(()) + }, + || Ok(()), + || Ok(()), + ); + + assert!(matches!(result, Err(WriteFailure::ConcurrentChange(_)))); + let old = observed.into_handle(); + recover_verified_transaction( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + marker, + Some(old), + Some(replacement), + ) + .unwrap(); + assert_eq!(std::fs::read(&final_path).unwrap(), b"external"); + assert!(!paths.old.exists()); + assert!(!paths.new.exists()); + assert!(!paths.marker.exists()); + } + + #[test] + fn interrupted_publication_recovers_the_exact_observed_target() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let paths = TransactionPaths::new(dir.path(), &final_path).unwrap(); + let observed = RetainedPolicyFile::Real(open_deletable_test_file(&final_path, b"observed")); + let marker = open_deletable_test_file(&paths.marker, b"marker"); + let replacement = open_deletable_test_file(&paths.new, b"replacement"); + + let prepared = PreparedTransaction { + dir_handle: &dir_file, + observed_target: &observed, + temp_file: &replacement, + paths: &paths, + final_path: &final_path, + }; + let result = publish_prepared_transaction( + &prepared, + || anyhow::bail!("simulated crash after tombstone"), + || Ok(()), + || Ok(()), + ); + assert!(matches!(result, Err(WriteFailure::PrePublication(_)))); + assert!(!final_path.exists()); + assert!(paths.old.exists()); + + let old = observed.into_handle(); + recover_verified_transaction( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + marker, + Some(old), + Some(replacement), + ) + .unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), b"observed"); + assert!(!paths.new.exists()); + assert!(!paths.marker.exists()); + } + + #[test] + fn changed_post_tombstone_evidence_restores_observed_target_before_conflict() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let paths = TransactionPaths::new(dir.path(), &final_path).unwrap(); + let observed = RetainedPolicyFile::Real(open_deletable_test_file(&final_path, b"observed")); + let marker = open_deletable_test_file(&paths.marker, b"marker"); + let replacement = open_deletable_test_file(&paths.new, b"replacement"); + let prepared = PreparedTransaction { + dir_handle: &dir_file, + observed_target: &observed, + temp_file: &replacement, + paths: &paths, + final_path: &final_path, + }; + + let result = publish_prepared_transaction( + &prepared, + || Ok(()), + || anyhow::bail!("simulated retained target mutation"), + || Ok(()), + ); + + assert!(matches!(result, Err(WriteFailure::ConcurrentChange(_)))); + assert_eq!(read_file_from_start(observed.handle()).unwrap(), b"observed"); + assert!(final_path.exists()); + assert!(!paths.old.exists()); + + drop(observed); + recover_verified_transaction( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + marker, + None, + Some(replacement), + ) + .unwrap(); + assert_eq!(std::fs::read(&final_path).unwrap(), b"observed"); + assert!(!paths.new.exists()); + assert!(!paths.marker.exists()); + } + + #[test] + fn recovery_preserves_published_replacement_after_interruption() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let paths = TransactionPaths::new(dir.path(), &final_path).unwrap(); + let observed = RetainedPolicyFile::Real(open_deletable_test_file(&final_path, b"observed")); + let marker = open_deletable_test_file(&paths.marker, b"marker"); + let replacement = open_deletable_test_file(&paths.new, b"replacement"); + + let prepared = PreparedTransaction { + dir_handle: &dir_file, + observed_target: &observed, + temp_file: &replacement, + paths: &paths, + final_path: &final_path, + }; + let result = publish_prepared_transaction( + &prepared, + || Ok(()), + || Ok(()), + || anyhow::bail!("simulated crash after publication"), + ); + assert!(matches!(result, Err(WriteFailure::PostPublication(_)))); + assert_eq!(read_file_from_start(&replacement).unwrap(), b"replacement"); + assert!(paths.old.exists()); + + let old = observed.into_handle(); + drop(replacement); + recover_verified_transaction( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + marker, + Some(old), + None, + ) + .unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), b"replacement"); + assert!(!paths.old.exists()); + assert!(!paths.marker.exists()); + } + + #[test] + fn recovery_restores_exact_tombstone_when_final_is_absent() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let marker_path = dir.path().join("marker"); + let old_path = dir.path().join("old"); + let new_path = dir.path().join("new"); + let marker = open_deletable_test_file(&marker_path, b"marker"); + let old = open_deletable_test_file(&old_path, b"old"); + let new = open_deletable_test_file(&new_path, b"new"); + + recover_verified_transaction( + &dir_file, + dir.path(), + OsStr::new("policy.json"), + marker, + Some(old), + Some(new), + ) + .unwrap(); + + assert_eq!(std::fs::read(dir.path().join("policy.json")).unwrap(), b"old"); + assert!(!marker_path.exists()); + assert!(!old_path.exists()); + assert!(!new_path.exists()); + + recover_interrupted_transaction(&dir_file, dir.path(), OsStr::new("policy.json")) + .expect("recovery is idempotent after cleanup"); + assert_eq!(std::fs::read(dir.path().join("policy.json")).unwrap(), b"old"); + } + + #[test] + fn recovery_discards_incomplete_marker_staging_only_when_final_is_present() { + use std::io::Write as _; + + let dir = temp_dir(); + let final_path = dir.path().join("policy.json"); + let staging_path = dir.path().join("marker.prepare"); + let mut final_file = match create_secure_transaction_file(&final_path) { + Ok(file) => file, + Err(_) => return, + }; + let original = valid_committed_policy_bytes(); + final_file.write_all(&original).unwrap(); + final_file.sync_all().unwrap(); + drop(final_file); + let staging = open_deletable_test_file(&staging_path, b"partial marker"); + + recover_marker_staging(&final_path, staging, None).unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), original); + assert!(!staging_path.exists()); + } + + #[test] + fn recovery_retains_incomplete_marker_staging_when_final_is_absent() { + let dir = temp_dir(); + let final_path = dir.path().join("policy.json"); + let staging_path = dir.path().join("marker.prepare"); + let staging = open_deletable_test_file(&staging_path, b"partial marker"); + + recover_marker_staging(&final_path, staging, None).unwrap_err(); + + assert_eq!(std::fs::read(&staging_path).unwrap(), b"partial marker"); + assert!(staging_path.exists()); + } + + #[test] + fn recovery_retires_pre_marker_replacement_when_original_is_safe() { + use std::io::Write as _; + + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let paths = TransactionPaths::new(dir.path(), &final_path).unwrap(); + let original = valid_committed_policy_bytes(); + let mut final_file = match create_secure_transaction_file(&final_path) { + Ok(file) => file, + Err(_) => return, + }; + final_file.write_all(&original).unwrap(); + final_file.sync_all().unwrap(); + drop(final_file); + let mut replacement = create_secure_transaction_file(&paths.new).unwrap(); + replacement.write_all(b"partial replacement").unwrap(); + replacement.sync_all().unwrap(); + drop(replacement); + + recover_interrupted_transaction(&dir_file, dir.path(), final_path.file_name().unwrap()).unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), original); + assert!(!paths.new.exists()); + } + + #[test] + fn recovery_retires_marker_staging_and_replacement_when_original_is_safe() { + use std::io::Write as _; + + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let paths = TransactionPaths::new(dir.path(), &final_path).unwrap(); + let original = valid_committed_policy_bytes(); + let mut final_file = match create_secure_transaction_file(&final_path) { + Ok(file) => file, + Err(_) => return, + }; + final_file.write_all(&original).unwrap(); + final_file.sync_all().unwrap(); + drop(final_file); + let marker_staging = create_secure_transaction_file(&paths.marker_staging).unwrap(); + drop(marker_staging); + let replacement = create_secure_transaction_file(&paths.new).unwrap(); + drop(replacement); + + recover_interrupted_transaction(&dir_file, dir.path(), final_path.file_name().unwrap()).unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), original); + assert!(!paths.marker_staging.exists()); + assert!(!paths.new.exists()); + } + + #[test] + fn recovery_preserves_untrusted_pre_marker_collision() { + use std::io::Write as _; + + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let paths = TransactionPaths::new(dir.path(), &final_path).unwrap(); + let mut final_file = match create_secure_transaction_file(&final_path) { + Ok(file) => file, + Err(_) => return, + }; + final_file.write_all(&valid_committed_policy_bytes()).unwrap(); + final_file.sync_all().unwrap(); + drop(final_file); + std::fs::write(&paths.new, b"external collision").unwrap(); + + recover_interrupted_transaction(&dir_file, dir.path(), final_path.file_name().unwrap()).unwrap_err(); + + assert_eq!(std::fs::read(&paths.new).unwrap(), b"external collision"); + assert!(final_path.exists()); + } + + #[test] + fn recovery_preserves_original_before_tombstoning() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let marker_path = dir.path().join("marker"); + let new_path = dir.path().join("new"); + std::fs::write(&final_path, b"original").unwrap(); + let marker = open_deletable_test_file(&marker_path, b"marker"); + let new = open_deletable_test_file(&new_path, b"new"); + + recover_verified_transaction( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + marker, + None, + Some(new), + ) + .unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), b"original"); + assert!(!marker_path.exists()); + assert!(!new_path.exists()); + } + + #[test] + fn recovery_preserves_final_created_after_tombstoning() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let marker_path = dir.path().join("marker"); + let old_path = dir.path().join("old"); + let new_path = dir.path().join("new"); + std::fs::write(&final_path, b"external").unwrap(); + let marker = open_deletable_test_file(&marker_path, b"marker"); + let old = open_deletable_test_file(&old_path, b"old"); + let new = open_deletable_test_file(&new_path, b"new"); + + recover_verified_transaction( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + marker, + Some(old), + Some(new), + ) + .unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), b"external"); + assert!(!marker_path.exists()); + assert!(!old_path.exists()); + assert!(!new_path.exists()); + } + + #[test] + fn recovery_preserves_verified_tombstone_when_raced_final_is_unsafe() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let marker_path = dir.path().join("marker"); + let old_path = dir.path().join("old"); + std::fs::write(&final_path, b"malicious").unwrap(); + let marker_file = open_deletable_test_file(&marker_path, b"marker"); + let old_file = open_deletable_test_file(&old_path, b"verified-old"); + let marker = TransactionMarker { + id: uuid::Uuid::new_v4(), + final_leaf: "policy.json".to_owned(), + old_identity: policy_security::file_identity(&old_file).unwrap(), + old_content_digest: sha256_digest(b"verified-old"), + old_security_digest: policy_security::security_state_digest(&old_file).unwrap(), + new_identity: test_identity(99), + new_content_digest: sha256_digest(b"intended-new"), + new_security_digest: test_security_digest(99), + }; + + recover_verified_transaction_with_evidence( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + &marker, + marker_file, + Some(old_file), + None, + ) + .unwrap_err(); + + assert_eq!(std::fs::read(&old_path).unwrap(), b"verified-old"); + assert_eq!(std::fs::read(&final_path).unwrap(), b"malicious"); + assert!(marker_path.exists(), "failed recovery must preserve its marker"); + } + + #[test] + fn recovery_preserves_all_evidence_for_distinct_same_byte_final_and_new() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let marker_path = dir.path().join("marker"); + let old_path = dir.path().join("old"); + let new_path = dir.path().join("new"); + std::fs::write(&final_path, b"intended-new").unwrap(); + let marker_file = open_deletable_test_file(&marker_path, b"marker"); + let old_file = open_deletable_test_file(&old_path, b"verified-old"); + let new_file = open_deletable_test_file(&new_path, b"intended-new"); + let marker = TransactionMarker { + id: uuid::Uuid::new_v4(), + final_leaf: "policy.json".to_owned(), + old_identity: policy_security::file_identity(&old_file).unwrap(), + old_content_digest: sha256_digest(b"verified-old"), + old_security_digest: policy_security::security_state_digest(&old_file).unwrap(), + new_identity: policy_security::file_identity(&new_file).unwrap(), + new_content_digest: sha256_digest(b"intended-new"), + new_security_digest: policy_security::security_state_digest(&new_file).unwrap(), + }; + + recover_verified_transaction_with_evidence( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + &marker, + marker_file, + Some(old_file), + Some(new_file), + ) + .unwrap_err(); + + assert_eq!(std::fs::read(&old_path).unwrap(), b"verified-old"); + assert_eq!(std::fs::read(&new_path).unwrap(), b"intended-new"); + assert_eq!(std::fs::read(&final_path).unwrap(), b"intended-new"); + assert!(marker_path.exists()); + } + + #[test] + fn recovery_rejects_same_byte_substitute_when_prepared_file_is_absent() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let marker_path = dir.path().join("marker"); + let old_path = dir.path().join("old"); + let prepared_path = dir.path().join("prepared"); + let prepared = open_deletable_test_file(&prepared_path, b"intended-new"); + let marker = TransactionMarker { + id: uuid::Uuid::new_v4(), + final_leaf: "policy.json".to_owned(), + old_identity: test_identity(1), + old_content_digest: sha256_digest(b"verified-old"), + old_security_digest: test_security_digest(1), + new_identity: policy_security::file_identity(&prepared).unwrap(), + new_content_digest: sha256_digest(b"intended-new"), + new_security_digest: policy_security::security_state_digest(&prepared).unwrap(), + }; + drop(prepared); + std::fs::remove_file(prepared_path).unwrap(); + std::fs::write(&final_path, b"intended-new").unwrap(); + let marker_file = open_deletable_test_file(&marker_path, b"marker"); + let old_file = open_deletable_test_file(&old_path, b"verified-old"); + + recover_verified_transaction_with_evidence( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + &marker, + marker_file, + Some(old_file), + None, + ) + .unwrap_err(); + + assert_eq!(std::fs::read(&old_path).unwrap(), b"verified-old"); + assert_eq!(std::fs::read(&final_path).unwrap(), b"intended-new"); + assert!(marker_path.exists()); + } + + #[test] + fn recovery_accepts_the_exact_prepared_file_after_genuine_rename() { + use std::io::Write as _; + + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let prepared_path = dir.path().join("prepared"); + let marker_path = dir.path().join("marker"); + let old_path = dir.path().join("old"); + let mut prepared = match create_secure_transaction_file(&prepared_path) { + Ok(file) => file, + Err(_) => return, + }; + let bytes = valid_committed_policy_bytes(); + prepared.write_all(&bytes).unwrap(); + prepared.sync_all().unwrap(); + let marker = TransactionMarker { + id: uuid::Uuid::new_v4(), + final_leaf: "policy.json".to_owned(), + old_identity: test_identity(1), + old_content_digest: sha256_digest(b"verified-old"), + old_security_digest: test_security_digest(1), + new_identity: policy_security::file_identity(&prepared).unwrap(), + new_content_digest: sha256_digest(&bytes), + new_security_digest: policy_security::security_state_digest(&prepared).unwrap(), + }; + rename_file_handle(&prepared, &dir_file, final_path.file_name().unwrap()).unwrap(); + let marker_file = open_deletable_test_file(&marker_path, b"marker"); + let old_file = open_deletable_test_file(&old_path, b"verified-old"); + + recover_verified_transaction_with_evidence( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + &marker, + marker_file, + Some(old_file), + None, + ) + .unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), bytes); + assert!(!old_path.exists()); + assert!(!marker_path.exists()); + } + + #[test] + fn recovery_accepts_exact_warning_bearing_replacement() { + use std::io::Write as _; + + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let prepared_path = dir.path().join("prepared"); + let marker_path = dir.path().join("marker"); + let old_path = dir.path().join("old"); + let mut prepared = match create_secure_transaction_file(&prepared_path) { + Ok(file) => file, + Err(_) => return, + }; + let bytes = committed_policy_bytes("Allow"); + let policy: PolicyDocument = serde_json::from_slice(&bytes).unwrap(); + let validation = validation::validate_committed_policy(&policy); + assert!(validation.is_valid); + assert!( + !validation.findings.is_empty(), + "test policy must exercise warning recovery" + ); + prepared.write_all(&bytes).unwrap(); + prepared.sync_all().unwrap(); + let marker = TransactionMarker { + id: uuid::Uuid::new_v4(), + final_leaf: "policy.json".to_owned(), + old_identity: test_identity(1), + old_content_digest: sha256_digest(b"verified-old"), + old_security_digest: test_security_digest(1), + new_identity: policy_security::file_identity(&prepared).unwrap(), + new_content_digest: sha256_digest(&bytes), + new_security_digest: policy_security::security_state_digest(&prepared).unwrap(), + }; + rename_file_handle(&prepared, &dir_file, final_path.file_name().unwrap()).unwrap(); + let marker_file = open_deletable_test_file(&marker_path, b"marker"); + let old_file = open_deletable_test_file(&old_path, b"verified-old"); + + recover_verified_transaction_with_evidence( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + &marker, + marker_file, + Some(old_file), + None, + ) + .unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), bytes); + assert!(!old_path.exists()); + assert!(!marker_path.exists()); + } + + #[test] + fn recovery_failure_never_discards_the_only_tombstone() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let marker_path = dir.path().join("marker"); + let old_path = dir.path().join("old"); + let marker = open_deletable_test_file(&marker_path, b"marker"); + let old = open_deletable_test_file(&old_path, b"old"); + + recover_verified_transaction( + &dir_file, + dir.path(), + OsStr::new(r"missing\policy.json"), + marker, + Some(old), + None, + ) + .unwrap_err(); + + assert_eq!(std::fs::read(&old_path).unwrap(), b"old"); + assert!(marker_path.exists()); + assert!(old_path.exists()); + } + + #[test] + fn recovery_rejects_multiple_or_untrusted_remnants() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let id_a = uuid::Uuid::new_v4(); + let id_b = uuid::Uuid::new_v4(); + std::fs::write(dir.path().join(format!(".policy.json.txn-{id_a}.marker")), b"untrusted").unwrap(); + std::fs::write(dir.path().join(format!(".policy.json.txn-{id_b}.marker")), b"untrusted").unwrap(); + assert!( + recover_interrupted_transaction(&dir_file, dir.path(), OsStr::new("policy.json")) + .unwrap_err() + .to_string() + .contains("multiple") + ); + + std::fs::remove_file(dir.path().join(format!(".policy.json.txn-{id_b}.marker"))).unwrap(); + assert!(recover_interrupted_transaction(&dir_file, dir.path(), OsStr::new("policy.json")).is_err()); + } + + #[test] + fn create_recovery_rejects_malformed_or_untrusted_remnants() { + let dir = temp_dir(); + let malformed = dir.path().join(format!(".{POLICY_FILE_NAME}.tmp-not-a-uuid")); + std::fs::write(&malformed, b"partial").unwrap(); + assert!(recover_create_temporary_files(dir.path()).is_err()); + + std::fs::remove_file(&malformed).unwrap(); + let untrusted = dir + .path() + .join(format!(".{POLICY_FILE_NAME}.tmp-{}", uuid::Uuid::new_v4())); + std::fs::write(&untrusted, b"partial").unwrap(); + assert!(recover_create_temporary_files(dir.path()).is_err()); + } + + #[test] + fn transaction_marker_round_trips_exact_state() { + let expected = DiskFingerprint::test_active(b"old", 2, 3, 4, 5); + let dir = temp_dir(); + let new_file = open_deletable_test_file(&dir.path().join("new"), b"new"); + let marker = TransactionMarker::from_observation( + uuid::Uuid::new_v4(), + Path::new(r"C:\policy.json"), + &expected, + &new_file, + b"new", + ) + .unwrap(); + + let decoded = TransactionMarker::from_bytes(&marker.to_bytes()).unwrap(); + + assert_eq!(decoded.id, marker.id); + assert_eq!(decoded.final_leaf, marker.final_leaf); + assert_eq!(decoded.old_identity, marker.old_identity); + assert_eq!(decoded.old_content_digest, marker.old_content_digest); + assert_eq!(decoded.old_security_digest, marker.old_security_digest); + assert_eq!(decoded.new_identity, marker.new_identity); + assert_eq!(decoded.new_content_digest, marker.new_content_digest); + assert_eq!(decoded.new_security_digest, marker.new_security_digest); + + let mut legacy: serde_json::Value = serde_json::from_slice(&marker.to_bytes()).unwrap(); + legacy["Version"] = 1.into(); + assert!(TransactionMarker::from_bytes(&serde_json::to_vec(&legacy).unwrap()).is_err()); + + legacy["Version"] = 2.into(); + assert!(TransactionMarker::from_bytes(&serde_json::to_vec(&legacy).unwrap()).is_err()); + + let mut missing_identity = legacy; + missing_identity["Version"] = 3.into(); + missing_identity.as_object_mut().unwrap().remove("NewFileId"); + assert!(TransactionMarker::from_bytes(&serde_json::to_vec(&missing_identity).unwrap()).is_err()); + } + + // ─── probe_write_capability / volume_filesystem_name ────────────────────── + // + // No elevation required: these never touch `admin_only_security_attributes`. + + #[test] + fn volume_filesystem_name_reports_a_known_filesystem_for_a_temp_directory() { + let dir = temp_dir(); + let filesystem = volume_filesystem_name(dir.path()).expect("query temp directory filesystem"); + assert!(!filesystem.is_empty()); + } + + #[test] + fn probe_write_capability_succeeds_on_an_ordinary_writable_temp_directory() { + let dir = temp_dir(); + probe_write_capability(dir.path()) + .expect("an ordinary user-writable NTFS temp directory must probe as capable"); + + // Nondestructive: the probe must never leave stray files behind. + let leftover: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|entry| entry.ok()) + .collect(); + assert!(leftover.is_empty(), "probe left files behind: {leftover:?}"); + } + + #[test] + fn occupied_no_replace_probe_preserves_both_retained_files() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let source_path = dir.path().join("source.tmp"); + let target_path = dir.path().join("target.tmp"); + let source = create_probe_file(&source_path, b"source", false).unwrap(); + let target = create_probe_file(&target_path, b"target", true).unwrap(); + let source_identity = policy_security::file_identity(&source).unwrap(); + let target_identity = policy_security::file_identity(&target).unwrap(); + + verify_no_replace_collision(&source, &target, &dir_file, &source_path, &target_path).unwrap(); + + assert_eq!(policy_security::file_identity(&source).unwrap(), source_identity); + assert_eq!(policy_security::file_identity(&target).unwrap(), target_identity); + assert_eq!(std::fs::read(&source_path).unwrap(), b"source"); + assert_eq!(std::fs::read(&target_path).unwrap(), b"target"); + cleanup_probe_file(source, &source_path, "source").unwrap(); + cleanup_probe_file(target, &target_path, "target").unwrap(); + } + + #[test] + fn occupied_no_replace_probe_accepts_verbatim_path_representation() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let source_path = dir.path().join("source.tmp"); + let target_path = dir.path().join("target.tmp"); + let source = create_probe_file(&source_path, b"source", false).unwrap(); + let target = create_probe_file(&target_path, b"target", true).unwrap(); + let verbatim = |path: &Path| { + let mut wide: Vec = r"\\?\".encode_utf16().collect(); + wide.extend(path.as_os_str().encode_wide()); + PathBuf::from(OsString::from_wide(&wide)) + }; + + verify_no_replace_collision( + &source, + &target, + &dir_file, + &verbatim(&source_path), + &verbatim(&target_path), + ) + .unwrap(); + + assert_eq!(std::fs::read(&source_path).unwrap(), b"source"); + assert_eq!(std::fs::read(&target_path).unwrap(), b"target"); + cleanup_probe_file(source, &source_path, "source").unwrap(); + cleanup_probe_file(target, &target_path, "target").unwrap(); + } + + #[test] + fn probe_directory_entry_rejects_a_different_identity() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let source_path = dir.path().join("source.tmp"); + let other_path = dir.path().join("other.tmp"); + let source = create_probe_file(&source_path, b"source", false).unwrap(); + let other = create_probe_file(&other_path, b"other", false).unwrap(); + let other_identity = policy_security::file_identity(&other).unwrap(); + + let error = verify_probe_directory_entry(&dir_file, source_path.file_name().unwrap(), other_identity) + .expect_err("a directory entry retargeted to a different file must be rejected"); + assert!(format!("{error:#}").contains("no longer names the retained file")); + + cleanup_probe_file(source, &source_path, "source").unwrap(); + cleanup_probe_file(other, &other_path, "other").unwrap(); + } + + fn windows_io_error(code: WIN32_ERROR) -> std::io::Error { + windows::core::Error::from_hresult(code.to_hresult()).into() + } + + #[test] + fn preferred_collision_is_accepted_without_native_fallback() { + for code in [ERROR_FILE_EXISTS, ERROR_ALREADY_EXISTS] { + let fallback_called = std::cell::Cell::new(false); + let error = rename_with_fallback( + || Err(windows_io_error(code)), + || { + fallback_called.set(true); + Ok(()) + }, + ) + .unwrap_err(); + + assert!(error.is_collision()); + assert!(!fallback_called.get()); + } + } + + #[test] + fn rename_status_classification_rejects_permission_and_unsupported_failures() { + for code in [ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION] { + let error = rename_with_fallback( + || Err(windows_io_error(code)), + || panic!("permission failures must not invoke the native fallback"), + ) + .unwrap_err(); + assert!(error.is_permission_failure()); + assert!(!error.is_collision()); + } + + for code in [ERROR_INVALID_FUNCTION, ERROR_NOT_SUPPORTED] { + let fallback_called = std::cell::Cell::new(false); + let unsupported = rename_with_fallback( + || Err(windows_io_error(code)), + || { + fallback_called.set(true); + Err(NTSTATUS(RenameFailure::STATUS_NOT_SUPPORTED.cast_signed())) + }, + ) + .unwrap_err(); + assert!(unsupported.is_unsupported()); + assert!(!unsupported.is_collision()); + assert!(fallback_called.get()); + } + + let native_collision = rename_with_fallback( + || Err(windows_io_error(ERROR_INVALID_FUNCTION)), + || Err(NTSTATUS(RenameFailure::STATUS_OBJECT_NAME_COLLISION.cast_signed())), + ) + .unwrap_err(); + assert!(native_collision.is_collision()); + } + + #[test] + fn failed_atomicity_probe_is_cached_until_directory_state_changes() { + let dir = temp_dir(); + let missing = dir.path().join("missing"); + let cache = AtomicityProbeCache::new(); + + let first = cache.get_or_probe(&missing, test_identity(1), test_security_digest(1)); + assert!(first.is_err()); + std::fs::create_dir(&missing).unwrap(); + + let cached = cache.get_or_probe(&missing, test_identity(1), test_security_digest(1)); + assert!(cached.is_err(), "unchanged directory state must reuse the failed probe"); + cache + .get_or_probe(&missing, test_identity(2), test_security_digest(1)) + .expect("changed directory identity must trigger a fresh probe"); + } + + #[test] + fn cleared_probe_collision_retries_after_bounded_delay() { + let dir = temp_dir(); + let collision = dir.path().join(".package-broker-write-probe-a.tmp"); + std::fs::write(&collision, b"external").unwrap(); + let cache = AtomicityProbeCache::new(); + let now = std::time::Instant::now(); + let identity = test_identity(1); + let security = test_security_digest(1); + + assert!(cache.get_or_probe_at(dir.path(), identity, security, now).is_err()); + std::fs::remove_file(collision).unwrap(); + assert!( + cache + .get_or_probe_at( + dir.path(), + identity, + security, + now + AtomicityProbeCache::FAILURE_RETRY_INTERVAL / 2, + ) + .is_err(), + "failure must remain cached before the retry deadline" + ); + cache + .get_or_probe_at( + dir.path(), + identity, + security, + now + AtomicityProbeCache::FAILURE_RETRY_INTERVAL, + ) + .expect("cleared collision must recover without restart"); + } + + #[test] + fn unsupported_filesystem_uses_unsupported_capability() { + assert_eq!( + probe_failure_capability(PolicyReadOnlyReason::UnsupportedFileSystem), + PolicyWriteCapability::Unsupported + ); + assert_eq!( + probe_failure_capability(PolicyReadOnlyReason::InsufficientPermissions), + PolicyWriteCapability::ReadOnly + ); + } + + // ─── DiskFingerprint rotation/stability semantics ───────────────────────── + + #[test] + fn active_fingerprint_is_stable_for_identical_inputs() { + let a = DiskFingerprint::test_active(b"same bytes", 1, 1, 1, 1); + let b = DiskFingerprint::test_active(b"same bytes", 1, 1, 1, 1); + assert_eq!(a, b); + } + + #[test] + fn active_fingerprint_rotates_on_same_byte_target_replacement() { + // Same content, but a different target generation (the file object itself was + // replaced, e.g. deleted and recreated with identical bytes). + let before = DiskFingerprint::test_active(b"same bytes", 1, 1, 1, 1); + let after = DiskFingerprint::test_active(b"same bytes", 2, 1, 1, 1); + assert_ne!(before, after); + } + + #[test] + fn active_fingerprint_rotates_on_acl_change() { + let before = DiskFingerprint::test_active(b"same bytes", 1, 1, 1, 1); + let after = DiskFingerprint::test_active(b"same bytes", 1, 1, 2, 1); + assert_ne!(before, after); + } + + #[test] + fn active_fingerprint_rotates_on_parent_replacement() { + let before = DiskFingerprint::test_active(b"same bytes", 1, 1, 1, 1); + let after = DiskFingerprint::test_active(b"same bytes", 1, 2, 1, 1); + assert_ne!(before, after); + } + + #[test] + fn active_fingerprint_rotates_on_same_acl_ancestor_replacement() { + let security = test_security_digest(1); + let before_ancestor = policy_security::test_ancestor_digest(test_identity(1), security); + let after_ancestor = policy_security::test_ancestor_digest(test_identity(2), security); + let fingerprint = |ancestor_security_digest| DiskFingerprint::Active { + parent: test_identity(10), + target: test_identity(11), + content_digest: sha256_digest(b"same bytes"), + security_digest: security, + dir_security_digest: security, + ancestor_security_digest, + }; + + assert_ne!(fingerprint(before_ancestor), fingerprint(after_ancestor)); + } + + #[test] + fn active_fingerprint_rotates_on_hosting_directory_acl_change() { + let before = DiskFingerprint::test_active(b"same bytes", 1, 1, 1, 1); + let after = DiskFingerprint::test_active(b"same bytes", 1, 1, 1, 2); + assert_ne!(before, after); + } + + #[test] + fn missing_fingerprints_differ_for_different_parents() { + let a = DiskFingerprint::test_missing(1, 1); + let b = DiskFingerprint::test_missing(2, 1); + assert_ne!(a, b); + } + + #[test] + fn missing_fingerprint_is_stable_for_the_same_parent() { + let a = DiskFingerprint::test_missing(7, 1); + let b = DiskFingerprint::test_missing(7, 1); + assert_eq!(a, b); + } + + // ─── Real, privilege-sensitive Windows behavior ─────────────────────────── + // + // The Agent service runs as LocalSystem in production, so setting a newly created + // object's owner to SYSTEM is unprivileged there; a non-elevated developer/CI shell + // cannot assign an owner it does not itself hold an enabling privilege for. Mirrors the + // existing `winget_app_exec_alias_passes_elevated_verification` pattern: attempt the + // real operation, and require the failure (when one occurs) to be exactly the + // anticipated privilege limitation rather than silently skipping the test. + #[test] + fn missing_component_is_created_secured_or_fails_on_the_expected_privilege_limitation() { + let dir = temp_dir(); + let parent = open_directory_no_reparse(dir.path()).unwrap(); + let security_attributes = policy_security::admin_only_security_attributes(true).unwrap(); + + match ensure_secure_directory_component( + &parent, + OsStr::new("PackageBroker"), + &security_attributes, + DirectorySecurityRole::DedicatedPolicy, + |_| Ok(()), + ) { + Ok(handle) => { + // Elevated/SYSTEM test host: verify the directory really is admin-only and + // that a *second* call (existing-directory path) does not need to (and does + // not) fail. + policy_security::verify_policy_directory_security(&handle) + .expect("freshly created directory must already be admin-only secured"); + drop(handle); + ensure_secure_directory_component( + &parent, + OsStr::new("PackageBroker"), + &security_attributes, + DirectorySecurityRole::DedicatedPolicy, + |_| Ok(()), + ) + .expect("re-verifying an already-secured directory succeeds"); + } + Err(error) => { + let message = format!("{error:#}"); + assert!( + message.contains("owner") || message.contains("privilege") || message.contains("Owner"), + "unexpected error creating the default directory: {message}" + ); + } + } + } + + // ─── validate_configured_path_shape (item 18/22) ────────────────────────── + + #[test] + fn relative_path_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"relative\policy.json")).unwrap_err(); + assert!(error.to_string().contains("absolute"), "{error}"); + } + + #[test] + fn trailing_separator_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\")).unwrap_err(); + assert!(error.to_string().contains("separator"), "{error}"); + } + + #[test] + fn dot_component_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\.\policy.json")).unwrap_err(); + assert!(error.to_string().contains("'.'"), "{error}"); + } + + #[test] + fn dotdot_component_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\..\policy.json")).unwrap_err(); + assert!(error.to_string().contains("'..'"), "{error}"); + } + + #[test] + fn yaml_extension_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\policy.yaml")).unwrap_err(); + assert!(error.to_string().contains(".json"), "{error}"); + } + + #[test] + fn yml_extension_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\policy.yml")).unwrap_err(); + assert!(error.to_string().contains(".json"), "{error}"); + } + + #[test] + fn extensionless_path_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\policy")).unwrap_err(); + assert!(error.to_string().contains(".json"), "{error}"); + } + + #[test] + fn other_extension_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\policy.txt")).unwrap_err(); + assert!(error.to_string().contains(".json"), "{error}"); + } + + #[test] + fn uppercase_json_extension_is_accepted() { + validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\policy.JSON")) + .expect("extension check is case-insensitive"); + } + + #[test] + fn well_formed_absolute_json_path_is_accepted() { + validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\PackageBroker\policy.json")) + .expect("well-formed absolute .json path must be accepted"); + } + + /// End-to-end (item 18/31): a configured path with an unsupported extension must be + /// reported through the *real* `observe` with the shared contract's dedicated + /// [`PolicyReadOnlyReason::UnsupportedFormat`], and the file must never even be + /// opened, whatever it (if anything) actually contains at that path. + fn assert_unsupported_format_is_reported_invalid_end_to_end(file_name: &str) { + let dir = temp_dir(); + let path = dir.path().join(file_name); + // If shape validation were ever skipped, this well-formed JSON content would + // make the file parse as Active; its presence proves the rejection is really + // about the extension, not a coincidentally-unreadable/absent file. + std::fs::write(&path, br#"{"not": "even close to a policy, but that's not the point"}"#).unwrap(); + + let probe_cache = AtomicityProbeCache::new(); + let observation = observe(PolicyConfigurationSource::ConfiguredPath, &path, &probe_cache); + + assert_eq!(observation.state, PolicyManagementState::Invalid); + assert_eq!(observation.write_capability, PolicyWriteCapability::Unsupported); + assert_eq!( + observation.read_only_reason, + Some(PolicyReadOnlyReason::UnsupportedFormat) + ); + assert!(observation.policy.is_none()); + } + + #[test] + fn yaml_extension_is_reported_invalid_end_to_end() { + assert_unsupported_format_is_reported_invalid_end_to_end("policy.yaml"); + } + + #[test] + fn yml_extension_is_reported_invalid_end_to_end() { + assert_unsupported_format_is_reported_invalid_end_to_end("policy.yml"); + } + + #[test] + fn extensionless_path_is_reported_invalid_end_to_end() { + assert_unsupported_format_is_reported_invalid_end_to_end("policy"); + } + + #[test] + fn other_extension_is_reported_invalid_end_to_end() { + assert_unsupported_format_is_reported_invalid_end_to_end("policy.txt"); + } + + // ─── Strict policy ancestor walk: reparse rejection (item 16) ───────────── + // + // Directory junctions (unlike symlinks) require no special privilege to create, so + // this exercises the real reparse-point rejection without needing an elevated shell. + + #[test] + fn junction_standing_in_for_an_ancestor_is_rejected() { + let root = temp_dir(); + let real_ancestor = root.path().join("real-ancestor"); + std::fs::create_dir(&real_ancestor).unwrap(); + let junction = root.path().join("junction-ancestor"); + create_directory_junction(&junction, &real_ancestor); + + let candidate_dir = junction.join("policy-dir"); + std::fs::create_dir(&candidate_dir).unwrap(); + + let error = + policy_security::retain_policy_no_reparse_directory_chain(&candidate_dir, "policy directory").unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("reparse point"), "unexpected error: {message}"); + } + + #[test] + fn default_directory_creation_rejects_junction_before_side_effects() { + let root = temp_dir(); + let attacker_target = root.path().join("attacker-target"); + std::fs::create_dir(&attacker_target).unwrap(); + let junction = root.path().join("Devolutions"); + create_directory_junction(&junction, &attacker_target); + let security_attributes = policy_security::admin_only_security_attributes(true).unwrap(); + let parent = open_directory_no_reparse(root.path()).unwrap(); + + let error = ensure_secure_directory_component( + &parent, + OsStr::new("Devolutions"), + &security_attributes, + DirectorySecurityRole::SharedAncestor, + |_| Ok(()), + ) + .unwrap_err(); + + assert!(format!("{error:#}").contains("reparse point")); + assert!( + !attacker_target.join("PackageBroker").exists(), + "rejected junction must not receive a privileged directory" + ); + } + + #[test] + fn hostile_component_creation_races_are_reopened_and_rejected() { + let root = temp_dir(); + let security_attributes = policy_security::admin_only_security_attributes(true).unwrap(); + let attacker_target = root.path().join("attacker-target"); + std::fs::create_dir(&attacker_target).unwrap(); + let parent = open_directory_no_reparse(root.path()).unwrap(); + + let error = ensure_secure_directory_component( + &parent, + OsStr::new("raced-junction"), + &security_attributes, + DirectorySecurityRole::SharedAncestor, + |path| { + create_directory_junction(path, &attacker_target); + Ok(()) + }, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("reparse point")); + + let error = ensure_secure_directory_component( + &parent, + OsStr::new("raced-insecure"), + &security_attributes, + DirectorySecurityRole::DedicatedPolicy, + |path| { + std::fs::create_dir(path)?; + Ok(()) + }, + ) + .unwrap_err(); + assert!( + format!("{error:#}").contains("required directory security"), + "unexpected error: {error:#}" + ); + } + + #[test] + fn preacquired_delete_handle_blocks_default_tree_creation_without_side_effects() { + let root = temp_dir(); + let attacker_handle = OpenOptions::new() + .access_mode(DELETE.0 | FILE_READ_ATTRIBUTES.0) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) + .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(root.path()) + .unwrap(); + let candidate = root.path().join("Devolutions").join("PackageBroker"); + + ensure_default_directory_secured(&candidate).unwrap_err(); + + assert!(!root.path().join("Devolutions").exists()); + drop(attacker_handle); + } + + /// Create a directory junction (`mklink /J`) without requiring elevation. + fn create_directory_junction(link: &Path, target: &Path) { + let status = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(link) + .arg(target) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .expect("spawn mklink"); + assert!( + status.success(), + "failed to create junction {} -> {}", + link.display(), + target.display() + ); + } + + // ─── Hard-link alias rejection for the leaf file (item 22) ──────────────── + // + // Hard links (unlike symlinks) require no special privilege to create on the same + // volume, so this exercises the real alias-rejection path directly. + + #[test] + fn policy_leaf_with_multiple_hard_links_is_rejected() { + let dir = temp_dir(); + let real_file = dir.path().join("real-policy.json"); + std::fs::write(&real_file, b"{}").unwrap(); + let alias = dir.path().join("alias-policy.json"); + std::fs::hard_link(&real_file, &alias).expect("create hard link"); + + let handle = OpenOptions::new() + .read(true) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(&alias) + .unwrap(); + assert_eq!(policy_security::file_link_count(&handle).unwrap(), 2); + } + + #[test] + fn dangling_reparse_leaf_is_unsafe_not_missing() { + let dir = temp_dir(); + let link = dir.path().join("policy.json"); + if std::os::windows::fs::symlink_file(dir.path().join("missing.json"), &link).is_err() { + return; + } + + let error = verify_policy_leaf_type_if_present(&link).unwrap_err(); + + assert!(format!("{error:#}").contains("reparse point")); + } + + #[test] + fn unverifiable_directory_classifies_dangling_reparse_as_invalid() { + let dir = temp_dir(); + let link = dir.path().join("policy.json"); + create_directory_junction(&link, &dir.path().join("missing-target")); + + let observation = observe_leaf_under_unverifiable_directory( + &link, + PolicyWriteCapability::ReadOnly, + PolicyReadOnlyReason::UnsafePath, + ); + + assert_eq!(observation.state, PolicyManagementState::Invalid); + assert_eq!(observation.write_capability, PolicyWriteCapability::ReadOnly); + assert_eq!(observation.read_only_reason, Some(PolicyReadOnlyReason::UnsafePath)); + } + + #[test] + fn unverifiable_directory_classifies_existing_file_as_invalid() { + let dir = temp_dir(); + let path = dir.path().join("policy.json"); + std::fs::write(&path, b"untrusted").unwrap(); + + let observation = observe_leaf_under_unverifiable_directory( + &path, + PolicyWriteCapability::ReadOnly, + PolicyReadOnlyReason::UnsafePath, + ); + + assert_eq!(observation.state, PolicyManagementState::Invalid); + assert_eq!(observation.write_capability, PolicyWriteCapability::ReadOnly); + assert_eq!(observation.read_only_reason, Some(PolicyReadOnlyReason::UnsafePath)); + } + + #[test] + fn unverifiable_directory_classifies_true_absence_as_missing_but_read_only() { + let dir = temp_dir(); + let path = dir.path().join("missing-policy.json"); + + let observation = observe_leaf_under_unverifiable_directory( + &path, + PolicyWriteCapability::ReadOnly, + PolicyReadOnlyReason::UnsafePath, + ); + + assert_eq!(observation.state, PolicyManagementState::Missing); + assert_eq!(observation.write_capability, PolicyWriteCapability::ReadOnly); + assert_eq!(observation.read_only_reason, Some(PolicyReadOnlyReason::UnsafePath)); + assert!(observation.hosting_dir.is_none()); + } + + #[test] + fn directory_leaf_is_unsafe_not_missing() { + let dir = temp_dir(); + let leaf = dir.path().join("policy.json"); + std::fs::create_dir(&leaf).unwrap(); + + let error = verify_policy_leaf_type_if_present(&leaf).unwrap_err(); + + assert!(format!("{error:#}").contains("directory")); + } + + #[test] + fn resolved_parent_alias_and_leaf_casing_are_compared_independently() { + let configured = Path::new(r"C:\RUNNER~1\AppData\Local\Temp\policy.json"); + let resolved_parent = Path::new(r"C:\actions\runneradmin\AppData\Local\Temp"); + let resolved_file = resolved_parent.join("Policy.JSON"); + + assert!(resolved_policy_path_matches( + &resolved_file, + resolved_parent, + configured.file_name().unwrap() + )); + assert!(!resolved_policy_path_matches( + &resolved_parent.join("other.json"), + resolved_parent, + configured.file_name().unwrap() + )); + } + + #[test] + fn resolved_policy_path_accepts_windows_unicode_case_mapping() { + let configured = Path::new(r"C:\DÉVOLUTIONS\PackageBroker\policé.json"); + let resolved_parent = Path::new(r"c:\dévolutions\packagebroker"); + let resolved_file = resolved_parent.join("POLICÉ.JSON"); + + assert!(resolved_policy_path_matches( + &resolved_file, + resolved_parent, + configured.file_name().unwrap() + )); + assert!(!resolved_policy_path_matches( + &resolved_file, + Path::new(r"c:\dévolutions\other"), + configured.file_name().unwrap() + )); + assert!(!resolved_policy_path_matches( + &resolved_parent.join("different.json"), + resolved_parent, + configured.file_name().unwrap() + )); + } + + // ─── DiskFingerprint::Invalid enrichment (item 15) ──────────────────────── + + fn invalid_fingerprint_for_path(path: &str, reason: validation::DiskFailureReason) -> DiskFingerprint { + DiskFingerprint::Invalid { + path: PathBuf::from(path), + parent: None, + dir_security_digest: None, + ancestor_security_digest: None, + target: None, + content_digest: None, + security_digest: None, + reason: format!("{reason:?}"), + } + } + + #[test] + fn invalid_fingerprints_for_distinct_paths_never_collide() { + // Two different configured paths that both fail identically (e.g. neither + // parent could even be opened, so no identity is available to distinguish them) + // must still never be mistaken for each other. + let a = invalid_fingerprint_for_path(r"C:\a\policy.json", validation::DiskFailureReason::Unreadable); + let b = invalid_fingerprint_for_path(r"C:\b\policy.json", validation::DiskFailureReason::Unreadable); + assert_ne!(a, b); + } + + #[test] + fn invalid_fingerprint_is_stable_for_the_same_path_and_reason() { + let a = invalid_fingerprint_for_path(r"C:\a\policy.json", validation::DiskFailureReason::Unreadable); + let b = invalid_fingerprint_for_path(r"C:\a\policy.json", validation::DiskFailureReason::Unreadable); + assert_eq!(a, b); + } + + /// Build a fully-populated `DiskFingerprint::Invalid` for the rotation/stability + /// tests below, so each test only has to vary the one field it is proving rotates + /// (or, for the "unchanged" test, none at all). + fn full_invalid_fingerprint( + path: &str, + parent_generation: u32, + ancestor_marker: &[u8], + target_generation: u32, + content: &[u8], + security_marker: &[u8], + reason: validation::DiskFailureReason, + ) -> DiskFingerprint { + DiskFingerprint::Invalid { + path: PathBuf::from(path), + parent: Some(test_identity(parent_generation)), + dir_security_digest: Some(sha256_digest(b"directory-security")), + ancestor_security_digest: Some(sha256_digest(ancestor_marker)), + target: Some(test_identity(target_generation)), + content_digest: Some(sha256_digest(content)), + security_digest: Some(sha256_digest(security_marker)), + reason: format!("{reason:?}"), + } + } + + #[test] + fn invalid_fingerprint_rotates_on_parent_replacement() { + let before = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + let after = full_invalid_fingerprint( + r"C:\a\policy.json", + 2, // only the parent generation differs + b"ancestors", + 1, + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + assert_ne!(before, after); + } + + #[test] + fn invalid_fingerprint_rotates_on_same_content_target_replacement() { + // Same path and same byte-for-byte content digest, but a different target + // identity (the invalid file object itself was replaced, e.g. deleted and + // recreated with identical bytes): must still rotate. + let before = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + let after = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 2, // only the target generation differs + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + assert_ne!(before, after); + } + + #[test] + fn invalid_fingerprint_rotates_on_acl_change() { + let before = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security-a", + validation::DiskFailureReason::MalformedContent, + ); + let after = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security-b", // only the security digest marker differs + validation::DiskFailureReason::MalformedContent, + ); + assert_ne!(before, after); + } + + #[test] + fn invalid_fingerprint_is_stable_when_truly_unchanged() { + let a = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + let b = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + assert_eq!(a, b); + } + + #[test] + fn probe_file_creation_never_truncates_an_existing_path() { + let dir = temp_dir(); + let path = dir.path().join("probe.tmp"); + std::fs::write(&path, b"external").unwrap(); + + let error = create_probe_file(&path, b"probe", false).unwrap_err(); + assert!(!format!("{error:#}").is_empty()); + assert_eq!(std::fs::read(&path).unwrap(), b"external"); + } +} diff --git a/crates/now-package-broker/src/policy_watcher.rs b/crates/now-package-broker/src/policy_watcher.rs index 04d83a128..5f94dc5e5 100644 --- a/crates/now-package-broker/src/policy_watcher.rs +++ b/crates/now-package-broker/src/policy_watcher.rs @@ -2,7 +2,7 @@ //! //! Watches the policy file for changes and reloads it when modified. -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -20,6 +20,119 @@ fn affects_policy(event: ¬ify::Event, path: &Path) -> bool { .any(|event_path| crate::policy_security::windows_paths_equal(event_path, path)) } +fn affects_watched_paths(event: ¬ify::Event, paths: &[PathBuf]) -> bool { + if paths.len() == 1 { + return affects_policy(event, &paths[0]); + } + if !(event.kind.is_create() || event.kind.is_modify() || event.kind.is_remove()) { + return false; + } + + let managed = &paths[0]; + let legacy = &paths[1]; + let managed_dir = managed.parent().expect("managed default policy has a parent"); + let legacy_dir = legacy.parent().expect("legacy default policy has a parent"); + event.paths.iter().any(|event_path| { + if event_path + .parent() + .is_some_and(|parent| crate::policy_security::windows_paths_equal(parent, managed_dir)) + && event_path.file_name().is_some_and(|name| { + name.to_string_lossy() + .to_ascii_lowercase() + .starts_with(".package-broker-write-probe-") + }) + { + return false; + } + crate::policy_security::windows_paths_equal(event_path, managed) + || crate::policy_security::windows_paths_equal(event_path, legacy) + || crate::policy_security::windows_paths_equal(event_path, managed_dir) + || crate::policy_security::windows_paths_equal(event_path, legacy_dir) + || event_path + .parent() + .is_some_and(|parent| crate::policy_security::windows_paths_equal(parent, managed_dir)) + || managed_dir + .ancestors() + .any(|ancestor| crate::policy_security::windows_paths_equal(event_path, ancestor)) + }) +} + +fn nearest_existing_ancestor(path: &Path) -> PathBuf { + path.ancestors() + .find(|candidate| candidate.is_dir()) + .unwrap_or(path) + .to_owned() +} + +fn watch_directories(paths: &[PathBuf]) -> Vec { + if paths.len() == 1 { + return vec![paths[0].parent().unwrap_or_else(|| Path::new(".")).to_owned()]; + } + + let managed_parent = paths[0].parent().expect("managed default policy has a parent"); + let common_parent = managed_parent.parent().unwrap_or_else(|| Path::new(".")); + let mut directories = vec![nearest_existing_ancestor(common_parent)]; + for path in paths { + let parent = path.parent().expect("default policy path has a parent"); + if parent.is_dir() + && !directories + .iter() + .any(|existing| crate::policy_security::windows_paths_equal(existing, parent)) + { + directories.push(parent.to_owned()); + } + } + directories +} + +enum WatcherCommand { + Refresh(Arc<[PathBuf]>), + Stop, +} + +fn create_watcher( + dir: &Path, + paths: Arc<[PathBuf]>, + changes: tokio::sync::mpsc::Sender, + failures: tokio::sync::mpsc::UnboundedSender, +) -> Result { + let mut watcher = notify::recommended_watcher(move |result: notify::Result| match result { + Ok(event) if affects_watched_paths(&event, &paths) => { + _ = changes.try_send(tokio::time::Instant::now()); + } + Ok(_) => {} + Err(_) => _ = failures.send(WatcherFailure::Notification), + }) + .map_err(|error| (WatcherFailure::Creation, error))?; + watcher + .watch(dir, RecursiveMode::NonRecursive) + .map_err(|error| (WatcherFailure::Registration, error))?; + Ok(watcher) +} + +struct WatcherSet { + _watchers: Vec, +} + +fn build_watcher_set( + paths: &Arc<[PathBuf]>, + changes: &tokio::sync::mpsc::Sender, + failures: &tokio::sync::mpsc::UnboundedSender, +) -> Result { + let mut set = WatcherSet { _watchers: Vec::new() }; + for dir in watch_directories(paths) { + let watcher = create_watcher(&dir, Arc::clone(paths), changes.clone(), failures.clone()) + .map_err(|(failure, error)| (failure, dir.clone(), error))?; + set._watchers.push(watcher); + } + Ok(set) +} + +fn replace_watcher_set(active: &mut T, replacement: Result) -> Result<(), E> { + *active = replacement?; + Ok(()) +} + async fn debounce_change( changes: &mut tokio::sync::mpsc::Receiver, failures: &mut tokio::sync::mpsc::UnboundedReceiver, @@ -59,8 +172,9 @@ impl PolicyWatcher { /// Start watching the policy file for changes. /// - /// This spawns a background task that watches the policy file's parent directory - /// and reloads the policy when the file is modified, created, or removed. + /// Configured paths watch their parent directory non-recursively. + /// Default-path transition uses separate non-recursive watches and dynamically registers directories as they appear. + /// Relevant modifications, creations, and removals reload the policy. /// The task runs until the shutdown notify is triggered. pub(crate) async fn watch( self, @@ -68,48 +182,55 @@ impl PolicyWatcher { ready: tokio::sync::oneshot::Sender>, ) { let store = self.0; - let path = store.configured_path(); - let dir = path.parent().unwrap_or_else(|| Path::new(".")).to_owned(); + let initial_paths: Arc<[PathBuf]> = store.watched_paths().into(); let (change_tx, mut changes) = tokio::sync::mpsc::channel(1); let (failure_tx, mut failures) = tokio::sync::mpsc::unbounded_channel(); - let (watcher_stop_tx, watcher_stop_rx) = std::sync::mpsc::channel::<()>(); + let (watcher_command_tx, watcher_command_rx) = std::sync::mpsc::channel(); let _watcher_handle = tokio::task::spawn_blocking(move || { - let mut watcher: RecommendedWatcher = - match notify::recommended_watcher(move |result: notify::Result| match result { - Ok(event) if affects_policy(&event, &path) => _ = change_tx.try_send(tokio::time::Instant::now()), - Ok(_) => {} - Err(_) => _ = failure_tx.send(WatcherFailure::Notification), - }) { - Ok(watcher) => watcher, - Err(error) => { - error!(%error, "Failed to create policy file watcher"); - let _ = ready.send(Err(WatcherFailure::Creation)); - return; + let mut watchers = match build_watcher_set(&initial_paths, &change_tx, &failure_tx) { + Ok(watchers) => watchers, + Err((failure, dir, error)) => { + error!(%error, path = %dir.display(), "Failed to watch policy directory"); + let _ = ready.send(Err(failure)); + return; + } + }; + let _ = ready.send(Ok(())); + while let Ok(command) = watcher_command_rx.recv() { + match command { + WatcherCommand::Refresh(paths) => { + let replacement = build_watcher_set(&paths, &change_tx, &failure_tx); + if let Err((_failure, dir, error)) = replace_watcher_set(&mut watchers, replacement) { + error!(%error, path = %dir.display(), "Failed to extend policy directory monitoring"); + } } - }; - - if let Err(error) = watcher.watch(&dir, RecursiveMode::NonRecursive) { - error!(%error, path = %dir.display(), "Failed to watch policy directory"); - let _ = ready.send(Err(WatcherFailure::Registration)); - return; + WatcherCommand::Stop => return, + } } - - let _ = ready.send(Ok(())); - let _ = watcher_stop_rx.recv(); }); let debounce = Duration::from_millis(500); + let mut fallback_poll = tokio::time::interval(Duration::from_secs(30)); + fallback_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + fallback_poll.tick().await; let failure = loop { tokio::select! { biased; _ = shutdown.cancelled() => break None, failure = failures.recv() => break Some(failure.unwrap_or(WatcherFailure::ChannelClosed)), + _ = fallback_poll.tick() => { + _ = store.reload_from_disk(ReloadCause::ExternalChange).await; + let _ = watcher_command_tx.send(WatcherCommand::Refresh(store.watched_paths().into())); + } Some(changed_at) = changes.recv() => { match debounce_change(&mut changes, &mut failures, &shutdown, changed_at + debounce).await { - Ok(true) => _ = store.reload_from_disk(ReloadCause::ExternalChange).await, + Ok(true) => { + _ = store.reload_from_disk(ReloadCause::ExternalChange).await; + let _ = watcher_command_tx.send(WatcherCommand::Refresh(store.watched_paths().into())); + } Ok(false) => break None, Err(failure) => break Some(failure), } @@ -120,7 +241,7 @@ impl PolicyWatcher { Some(failure) => fail_closed(&store, failure).await, None => info!("Policy watcher shutting down"), } - let _ = watcher_stop_tx.send(()); + let _ = watcher_command_tx.send(WatcherCommand::Stop); } } @@ -183,6 +304,95 @@ mod tests { )); } + #[test] + fn custom_event_filter_uses_the_verified_canonical_path() { + let configured = PathBuf::from(r"C:\RUNNER~1\AppData\Local\Temp\policy.json"); + let canonical = PathBuf::from(r"C:\actions\runneradmin\AppData\Local\Temp\policy.json"); + let paths = vec![canonical.clone()]; + let event = notify::Event::new(EventKind::Modify(ModifyKind::Any)).add_path(canonical); + + assert!(affects_watched_paths(&event, &paths)); + assert!(!affects_watched_paths(&event, &[configured])); + } + + #[test] + fn default_transition_filter_tracks_both_policies_and_managed_state() { + let managed = PathBuf::from(r"C:\ProgramData\Devolutions\PackageBroker\package-broker-policy.json"); + let legacy = PathBuf::from(r"C:\ProgramData\Devolutions\Agent\package-broker-policy.json"); + let paths = vec![managed.clone(), legacy.clone()]; + let event = |path| notify::Event::new(EventKind::Modify(ModifyKind::Any)).add_path(path); + + assert!(affects_watched_paths(&event(managed.clone()), &paths)); + assert!(affects_watched_paths(&event(legacy), &paths)); + assert!(affects_watched_paths( + &event(managed.with_file_name(".package-broker-policy.json.txn-id.marker")), + &paths + )); + assert!(affects_watched_paths( + &event(managed.with_file_name(".package-broker-managed-authority.v1")), + &paths + )); + assert!(!affects_watched_paths( + &event(managed.with_file_name(".package-broker-write-probe-a.tmp")), + &paths + )); + assert!(affects_watched_paths( + &event(PathBuf::from(r"C:\ProgramData\Devolutions\PackageBroker")), + &paths + )); + assert!(affects_watched_paths( + &event(PathBuf::from(r"C:\ProgramData\Devolutions\Agent")), + &paths + )); + assert!(!affects_watched_paths( + &event(PathBuf::from(r"C:\ProgramData\Devolutions\Agent\unrelated.json")), + &paths + )); + } + + #[test] + fn default_transition_watch_root_uses_the_nearest_existing_ancestor() { + let dir = tempfile::tempdir().expect("create temp directory"); + let missing = dir.path().join("Devolutions").join("PackageBroker"); + + assert_eq!(nearest_existing_ancestor(&missing), dir.path()); + } + + #[test] + fn default_transition_uses_independent_non_recursive_directories() { + let dir = tempfile::tempdir().expect("create temp directory"); + let common = dir.path().join("Devolutions"); + let managed_dir = common.join("PackageBroker"); + let legacy_dir = common.join("Agent"); + std::fs::create_dir_all(&managed_dir).expect("create managed directory"); + std::fs::create_dir(&legacy_dir).expect("create legacy directory"); + let paths = vec![ + managed_dir.join("package-broker-policy.json"), + legacy_dir.join("package-broker-policy.json"), + ]; + + let directories = watch_directories(&paths); + + assert_eq!(directories.len(), 3); + assert!(directories.iter().any(|path| path == &common)); + assert!(directories.iter().any(|path| path == &managed_dir)); + assert!(directories.iter().any(|path| path == &legacy_dir)); + } + + #[test] + fn failed_refresh_keeps_the_active_watcher_set() { + let mut active = vec!["common", "legacy", "managed"]; + + let result = replace_watcher_set(&mut active, Err::, _>("registration failed")); + + assert_eq!(result, Err("registration failed")); + assert_eq!(active, ["common", "legacy", "managed"]); + + replace_watcher_set(&mut active, Ok::<_, &str>(vec!["replacement"])) + .expect("complete replacement set swaps successfully"); + assert_eq!(active, ["replacement"]); + } + #[tokio::test] async fn watcher_task_exit_fails_closed_but_shutdown_does_not() { let store = PolicyStore::for_tests(None);