From 2358e9cc01761a62ef12458efa0465d54a5300b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 7 Sep 2026 21:01:48 -0400 Subject: [PATCH 01/20] fix(agent): harden policy storage Preserve external policy changes by binding writes to retained file and directory identities and publishing with handle-relative no-replace renames. Recover interrupted replacements from durable verified markers and create the default policy tree under retained, reparse-safe ancestors with strict ACL and filesystem capability checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../now-package-broker/src/policy_security.rs | 357 +- .../src/policy_store/mod.rs | 876 ++-- .../src/policy_store/windows.rs | 3611 +++++++++++++++++ 3 files changed, 4287 insertions(+), 557 deletions(-) create mode 100644 crates/now-package-broker/src/policy_store/windows.rs diff --git a/crates/now-package-broker/src/policy_security.rs b/crates/now-package-broker/src/policy_security.rs index d4a68cf38..c68d1c0a5 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,16 +31,19 @@ //! 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, }; @@ -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,28 @@ 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, - TrustedWriters::AdminOnly, - TrustedWriters::PolicyAncestor, - true, +/// 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 +227,95 @@ 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 every policy-directory ancestor without following reparses and summarize its security state. +pub(crate) fn verify_policy_ancestor_chain(dir: &Path, subject: &str) -> anyhow::Result<[u8; 32]> { + let mut hasher = Sha256::new(); + let mut current = dir.parent(); + + while let Some(ancestor) = current { + let dir_subject = format!("{subject} ancestor directory '{}'", ancestor.display()); + let handle = 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 | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(ancestor) + .with_context(|| format!("failed to open {dir_subject}"))?; + + 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"); + } + + let resolved = final_path_from_handle(&handle).with_context(|| format!("failed to resolve {dir_subject}"))?; + if !windows_paths_equal(&resolved, ancestor) { + bail!( + "{dir_subject} resolved to an unexpected location '{}'; refusing to trust a retargeted ancestor", + resolved.display() + ); + } + verify_policy_ancestor_directory_security(&handle, &dir_subject)?; + let security_digest = + security_state_digest(&handle).with_context(|| format!("failed to digest {dir_subject} security"))?; + hasher.update(resolved.as_os_str().to_string_lossy().to_lowercase().as_bytes()); + hasher.update(b"\0"); + hasher.update(security_digest); + current = ancestor.parent(); + } + + Ok(hasher.finalize().into()) +} + +/// 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 | 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()); @@ -269,12 +361,114 @@ 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) }); + update_ace_digest(&mut hasher, unsafe { + std::slice::from_raw_parts(ace.cast::(), size) + }); } } Ok(hasher.finalize().into()) } +fn update_ace_digest(hasher: &mut Sha256, ace_bytes: &[u8]) { + hasher.update(ace_bytes.len().to_le_bytes()); + hasher.update(ace_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. /// /// The held file handle was opened without write or delete sharing, so the verified file @@ -401,7 +595,6 @@ pub(crate) fn verify_elevated_executable_security( &subject, TrustedWriters::AdminOrTrustedInstaller, TrustedWriters::AdminOrTrustedInstaller, - false, )?; Ok(Some(VerifiedExecutable { @@ -635,7 +828,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 +843,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 +870,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 +1081,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 +1093,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 +1180,32 @@ mod tests { assert!(windows_paths_equal(&resolved, &executable)); } + #[test] + fn ace_digest_includes_callback_application_data() { + let mut left = Sha256::new(); + let mut right = Sha256::new(); + update_ace_digest(&mut left, &[1, 2, 3, 4]); + update_ace_digest(&mut right, &[1, 2, 3, 5]); + + assert_ne!(left.finalize(), right.finalize()); + } + + #[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 +1273,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 +1322,16 @@ 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 local_service_write_ace_is_rejected_for_executables() { // LOCAL SERVICE is a low-privilege shared service identity; a LOCAL @@ -1411,7 +1652,7 @@ mod tests { "policy ancestor", sd.owner, sd.dacl, - TrustedWriters::PolicyAncestor, + TrustedWriters::AdminOrTrustedInstaller, DIRECTORY_TAMPER_MASK, ) } @@ -1546,10 +1787,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..79afdc3ed 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,49 +13,26 @@ 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, configured_path: &Path, @@ -72,16 +42,30 @@ trait PolicyStorage: Send + Sync { fn replace( &self, 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( @@ -90,16 +74,60 @@ impl PolicyStorage for FilePolicyStorage { 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.canonical_path, bytes)?; + self.authoritative_reobserve(configured_path, bytes) } fn replace( &self, 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, + )?; + 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, + }) } } @@ -139,7 +167,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( @@ -149,11 +181,7 @@ impl PolicyStore { ) -> 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, - ), + None => (windows::default_policy_path(), PolicyConfigurationSource::DefaultPath), }; let observation = storage.observe(source, &configured_path); let snapshot = Arc::new(snapshot_from_observation(observation, random_store_token())); @@ -232,8 +260,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 +278,7 @@ impl PolicyStore { )); } let previous = self.snapshot(); - let observation = self.storage.observe(self.source, &self.configured_path); + let mut observation = self.storage.observe_for_write(self.source, &self.configured_path); let fresh_token = token_for(&previous, &observation.fingerprint); // Both conflict modes require this exact token. @@ -322,7 +352,7 @@ impl PolicyStore { let persisted = if request.operation == PolicyReplacementOperation::Create { self.storage.create(&self.configured_path, &observation, &bytes) } else { - self.storage.replace(&self.configured_path, &observation, &bytes) + self.storage.replace(&self.configured_path, &mut observation, &bytes) }; let persisted = match persisted { Ok(persisted) => persisted, @@ -342,6 +372,25 @@ 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.storage.observe(self.source, &self.configured_path); + 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:#}"), @@ -357,16 +406,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: observation.write_capability, + read_only_reason: observation.read_only_reason, + configured_path: observation.canonical_path, + store_token: token, + fingerprint: persisted.fingerprint, + }); *self.snapshot.write().expect("policy store snapshot lock poisoned") = snapshot; Ok(ReplaceSuccess { @@ -400,9 +450,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 +506,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 +535,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 +574,48 @@ 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>, } #[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), } } 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), + } } 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,6 +625,18 @@ 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, @@ -1013,9 +649,16 @@ impl PolicyStorage for TestStorage { fn replace( &self, _configured_path: &Path, - observation: &Observation, + observation: &mut Observation, bytes: &[u8], ) -> Result { + 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 +671,75 @@ 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); *self.observation.lock() = clone_observation(&next); Ok(PersistedPolicy { policy, - observation: next, + fingerprint: next.fingerprint, }) } } +#[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 +748,138 @@ 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}; + + 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 + ); } } 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..f3f702053 --- /dev/null +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -0,0 +1,3611 @@ +//! 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 windows::Win32::Foundation::{GENERIC_READ, GENERIC_WRITE, HANDLE, NTSTATUS}; +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 FILE_SYNCHRONIZE: u32 = 0x0010_0000; +const FILE_RENAME_INFORMATION_EX_CLASS: i32 = 65; + +#[repr(C)] +struct IoStatusBlock { + status_or_pointer: usize, + information: usize, +} + +#[link(name = "ntdll")] +unsafe extern "system" { + fn NtSetInformationFile( + file_handle: HANDLE, + io_status_block: *mut IoStatusBlock, + file_information: *const core::ffi::c_void, + length: u32, + file_information_class: i32, + ) -> NTSTATUS; +} + +/// Default dedicated directory hosting the policy file: `%PROGRAMDATA%\Devolutions\PackageBroker`. +/// +/// Deliberately a top-level sibling of `%PROGRAMDATA%\Devolutions\Agent`, not a +/// subdirectory of it: `Agent` is shared with unrelated Agent features and its own +/// ancestor-security check must tolerate whatever grants those features require there, +/// which can never be proven as strict as the dedicated policy directory itself needs +/// its *own* ancestor chain to be (see [`policy_security::verify_policy_ancestor_chain`]). +/// A directory nested under `Agent` would inherit `Agent` as an ancestor and could never +/// honestly advertise [`PolicyWriteCapability::Writable`]. This dedicated root is created +/// and secured by this crate alone (both by the Agent installer at install time and, as +/// a fallback/self-heal, by this function's own caller at runtime), so it never has to +/// depend on -- or touch -- the shared `Agent` directory's ACL at all. +pub(super) fn default_policy_dir() -> PathBuf { + let program_data = std::env::var_os("PROGRAMDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")); + program_data.join("Devolutions").join("PackageBroker") +} + +/// Default policy file path inside [`default_policy_dir`]. +pub(super) fn default_policy_path() -> PathBuf { + default_policy_dir().join(POLICY_FILE_NAME) +} + +/// 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. +pub(super) fn validate_configured_path_shape(path: &Path) -> Result<(), String> { + if !path.is_absolute() { + return Err(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(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(format!( + "configured policy path must not contain a '.' component: {}", + path.display() + )); + } + if segment == ".." { + return Err(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(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(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)>; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct AtomicityProbeKey { + identity: FileIdentity, + security_digest: [u8; 32], +} + +/// Caches successful filesystem atomic-replace probes by directory identity and security digest. +/// Failed probes are retried on every observation. +pub(super) struct AtomicityProbeCache { + cached_success: std::sync::Mutex>, +} + +impl AtomicityProbeCache { + pub(super) fn new() -> Self { + Self { + cached_success: 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 { + let key = AtomicityProbeKey { + identity: dir_identity, + security_digest: dir_security_digest, + }; + let mut cached_success = self.cached_success.lock().expect("atomicity probe cache lock poisoned"); + + if cached_success.as_ref() == Some(&key) { + return Ok(()); + } + + let result = probe_write_capability(dir).map_err(|error| { + let reason = if error.downcast_ref::().is_some() { + PolicyReadOnlyReason::UnsupportedFileSystem + } else { + PolicyReadOnlyReason::InsufficientPermissions + }; + (reason, format!("{error:#}")) + }); + if result.is_ok() { + *cached_success = Some(key); + } else { + *cached_success = None; + } + 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 the ancestor chain with +/// [`policy_security::verify_policy_ancestor_chain`], 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)?; + + let attributes = handle + .metadata() + .with_context(|| format!("failed to query metadata for {}", path.display()))? + .file_attributes(); + + if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { + bail!( + "{} is a reparse point (symlink/junction); the policy directory must be a real directory", + path.display() + ); + } + if attributes & FILE_ATTRIBUTE_DIRECTORY.0 == 0 { + bail!("{} is not a directory", path.display()); + } + + let final_path = policy_security::final_path_from_handle(&handle) + .with_context(|| format!("failed to resolve {}", path.display()))?; + + Ok((handle, final_path)) +} + +/// 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 `FakePolicyStorage` + /// (`crate::policy_store::tests::FakePolicyStorage`), which models the hosting + /// directory purely with generation counters and has no real Windows directory + /// handle to hold. + #[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) + } +} + +/// 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")?; + policy_security::verify_policy_ancestor_chain(&canonical_program_data, "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, + |_| Ok(()), + )?; + 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::verify_policy_ancestor_chain(&final_path, "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::verify_policy_ancestor_chain(&final_path, "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 {} + +/// 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 of the exact rename primitives against uniquely named disposable files. +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 probe_id = uuid::Uuid::new_v4(); + let source_path = dir.join(format!(".package-broker-write-probe-{probe_id}-a.tmp")); + let target_path = dir.join(format!(".package-broker-write-probe-{probe_id}-b.tmp")); + let tombstone_path = dir.join(format!(".package-broker-write-probe-{probe_id}-old.tmp")); + + let probe_result = (|| -> anyhow::Result<()> { + std::fs::write(&source_path, b"probe-source").context("create write-capability probe source file")?; + std::fs::write(&target_path, b"probe-target").context("create write-capability probe target file")?; + let dir_handle = open_directory_no_reparse(dir)?; + let source = open_transaction_file(&source_path)?; + let target = open_transaction_file(&target_path)?; + rename_file_handle( + &target, + &dir_handle, + tombstone_path.file_name().expect("probe path has leaf"), + ) + .context("probe target-to-tombstone handle rename")?; + rename_file_handle( + &source, + &dir_handle, + target_path.file_name().expect("probe path has leaf"), + ) + .context("probe create-new handle publication")?; + 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")?; + drop(target); + ensure!( + !tombstone_path.exists(), + "POSIX tombstone unlink did not remove the directory entry" + ); + drop(source); + Ok(()) + })(); + + // Cleanup is mandatory, not best-effort (item 28): both paths are always attempted + // regardless of the probe's own outcome or each other, and any leftover probe file + // -- other than one that was never actually created (tolerated only as `NotFound`) + // -- itself disqualifies this directory from `Writable`, aggregated into the overall + // result rather than silently logged and ignored. A probe that leaves a stray file + // behind in the configured policy directory is not actually side-effect-free, + // whatever its rename result reported. + let source_cleanup = cleanup_probe_file(&source_path); + let target_cleanup = cleanup_probe_file(&target_path); + let tombstone_cleanup = cleanup_probe_file(&tombstone_path); + + probe_result + .and(source_cleanup) + .and(target_cleanup) + .and(tombstone_cleanup) +} + +/// Remove a write-capability probe file, tolerating only the file already being absent +/// (the expected outcome for `source_path` after a successful replace, which consumes +/// it). Any other failure (permission denied, sharing violation, ...) means a stray +/// probe file was left behind in the configured policy directory, which must itself +/// disqualify the directory from `Writable` (item 28): the specific OS error is only +/// ever traced, and the returned error is a single sanitized, aggregated message never +/// exposed through the management API. +fn cleanup_probe_file(path: &Path) -> anyhow::Result<()> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => { + tracing::warn!(path = %path.display(), %error, "Failed to remove write-capability probe file"); + bail!("failed to remove a write-capability probe file left behind in the policy directory") + } + } +} + +/// 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, + } + } +} + +#[cfg(test)] +impl DiskFingerprint { + /// Build a synthetic fingerprint for the in-memory `FakePolicyStorage` 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 resolved path (parent resolved from a verified handle, joined with the + /// exact configured `.json` leaf name; see item 22), or the best-effort literal + /// configured path when it could not be canonicalized at all (an unsupported shape, + /// or a directory/ancestor chain that failed verification before any handle could be + /// resolved). `PolicyStore` stores/displays/uses only this value from here on -- + /// never re-deriving it from the original configuration string -- for observation, + /// the watcher, the store token, audit, and writes. + 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 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 | FILE_SHARE_WRITE | FILE_SHARE_DELETE).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" + ); + return invalid_observation( + configured_path, + validation::DiskFailureReason::UnsupportedFormat, + InvalidContext::default(), + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsupportedFormat), + ); + } + + 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 secured = match source { + PolicyConfigurationSource::DefaultPath => 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) + } + }; + // An insecure/unverifiable directory must never be trusted to host a policy + // -- but that alone does not mean there *is* a policy to distrust. If no + // leaf exists there at all, the correct state is Missing (nothing to + // activate or reject), not Invalid (which implies some untrusted content is + // actually present); capability is ReadOnly/Unsupported either way, since + // Create/Repair both still require a directory that passes verification. + // This is a best-effort existence probe only (on the literal configured + // path, since the directory itself could not be canonically verified): it + // never trusts, reads, or reports the leaf's content. + return match std::fs::metadata(configured_path) { + Err(io_error) if io_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, + }, + _ => invalid_observation( + configured_path, + validation::DiskFailureReason::Unreadable, + InvalidContext::default(), + write_capability, + Some(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), + ); + } + }; + + if let Err(error) = policy_security::verify_policy_directory_security(&dir_handle) { + 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::verify_policy_ancestor_chain(&canonical_dir, "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 recovery = 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. + let (base_write_capability, base_read_only_reason) = + 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" + ); + (PolicyWriteCapability::ReadOnly, Some(reason)) + } + }; + + let hosting_dir = (base_write_capability == PolicyWriteCapability::Writable).then_some(VerifiedHostingDirectory { + handle: Some(dir_handle), + _ancestor_handles: 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() + }; + + let opened = match open_policy_file(&canonical_path, retain_target) { + Ok(opened) => opened, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + 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 + }; + + if let Err(security_error) = policy_security::verify_managed_policy_file_security(&file) { + // 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)); + + 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, + ) +} + +/// 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, + } +} + +/// 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, +} + +/// 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<()> { + hosting_dir + .verify_unchanged() + .context("hosting directory changed after policy observation")?; + observed_target + .verify_matches(expected_fingerprint) + .context("observed policy changed before conditional publication")?; + let expected_ancestor_security = match expected_fingerprint { + DiskFingerprint::Active { + ancestor_security_digest, + .. + } + | DiskFingerprint::Invalid { + ancestor_security_digest: Some(ancestor_security_digest), + .. + } => *ancestor_security_digest, + _ => { + return Err(anyhow::anyhow!( + "replacement observation has no ancestor security fingerprint" + )); + } + }; + let current_ancestor_security = + policy_security::verify_policy_ancestor_chain(hosting_dir.canonical_path(), "policy directory") + .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_content_digest: [u8; 32], +} + +impl TransactionMarker { + fn from_observation( + id: uuid::Uuid, + final_path: &Path, + expected: &DiskFingerprint, + 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_content_digest: sha256_digest(new_bytes), + }) + } + + fn to_bytes(&self) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "Version": 1, + "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), + "NewContentDigest": hex::encode(self.new_content_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() == 8, "transaction marker contains unexpected fields"); + ensure!( + object.get("Version").and_then(serde_json::Value::as_u64) == Some(1), + "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 mut file_id = [0u8; 16]; + hex::decode_to_slice(text("OldFileId")?, &mut file_id).context("transaction marker OldFileId is invalid")?; + 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, + }, + old_content_digest: decode("OldContentDigest")?, + old_security_digest: decode("OldSecurityDigest")?, + new_content_digest: decode("NewContentDigest")?, + }) + } +} + +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 marker = TransactionMarker::from_observation(paths.id, final_path, expected_fingerprint, bytes) + .map_err(WriteFailure::ConcurrentChange)?; + + let mut marker_file = + create_secure_transaction_file(&paths.marker_staging).map_err(WriteFailure::PrePublication)?; + if let Err(error) = marker_file + .write_all(&marker.to_bytes()) + .and_then(|()| marker_file.sync_all()) + .context("failed to persist policy transaction marker") + { + return Err(prepublication_failure_after_cleanup( + &marker_file, + error, + "incomplete transaction marker cleanup also failed", + )); + } + if let Err(error) = rename_file_handle( + &marker_file, + dir_handle, + paths.marker.file_name().expect("transaction marker path has leaf"), + ) { + return Err(prepublication_failure_after_cleanup( + &marker_file, + anyhow::Error::new(error).context("failed to publish completed transaction marker"), + "transaction marker staging cleanup also failed", + )); + } + + let mut temp_file = match create_secure_transaction_file(&paths.new) { + Ok(file) => file, + Err(error) => { + return Err(prepublication_failure_after_cleanup( + &marker_file, + error, + "transaction marker cleanup also failed", + )); + } + }; + 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") + }) + { + if let Err(cleanup_error) = delete_file_handle(&temp_file) { + return Err(WriteFailure::PrePublication(error.context(format!( + "temporary replacement cleanup failed; transaction marker retained for recovery: {cleanup_error:#}" + )))); + } + return Err(prepublication_failure_after_cleanup( + &marker_file, + error, + "transaction marker cleanup also failed", + )); + } + + 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) +} + +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())) +} + +fn rename_file_handle(file: &File, root: &File, new_name: &OsStr) -> std::io::Result<()> { + let information = RenameInformation::new(HANDLE(root.as_raw_handle()), new_name); + match set_file_rename_information(file, &information) { + Ok(()) => Ok(()), + Err(win32_error) => nt_set_file_rename_information(file, &information).map_err(|nt_error| { + std::io::Error::other(format!( + "handle-relative rename failed through Win32 ({win32_error}) and NT ({nt_error})" + )) + }), + } +} + +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) -> std::io::Result<()> { + let mut io_status = IoStatusBlock { + status_or_pointer: 0, + information: 0, + }; + + // SAFETY: `information` contains a valid variable-length FILE_RENAME_INFORMATION_EX buffer. + unsafe { + NtSetInformationFile( + HANDLE(file.as_raw_handle()), + &mut io_status, + information.as_ptr(), + information.length, + FILE_RENAME_INFORMATION_EX_CLASS, + ) + .ok() + .map_err(std::io::Error::from) + } +} + +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() && new_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); + return recover_marker_staging(&final_path, marker_staging); + } + 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)?; + let new_content_matches = if let Some(new_file) = &new_file { + verify_transaction_file_path(new_file, &paths.new)?; + policy_security::verify_managed_policy_file_security(new_file) + .context("transaction replacement security is invalid")?; + sha256_digest(&read_file_from_start(new_file)?) == marker.new_content_digest + } else { + true + }; + if old_file.is_some() { + ensure!( + new_content_matches, + "transaction replacement content changed after tombstoning" + ); + } + + recover_verified_transaction(dir, dir_path, OsStr::new(final_leaf), marker_file, old_file, new_file) +} + +fn recover_marker_staging(final_path: &Path, marker_staging: File) -> anyhow::Result<()> { + let final_guard = open_optional_final_guard(final_path)? + .context("incomplete marker staging exists but the original policy is absent")?; + delete_file_handle(&marker_staging).context("failed to retire incomplete transaction marker staging")?; + drop(marker_staging); + drop(final_guard); + Ok(()) +} + +fn recover_verified_transaction( + dir: &File, + dir_path: &Path, + final_leaf: &OsStr, + 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_guard(&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_guard(&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 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(()) +} + +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, + final_path: &Path, + bytes: &[u8], +) -> Result { + use std::io::Write as _; + + hosting_dir + .verify_unchanged() + .context("hosting directory changed after policy observation") + .map_err(WriteFailure::PrePublication)?; + 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(error) = rename_file_handle( + &temp_file, + dir_handle, + final_path.file_name().expect("policy path has leaf"), + ) { + return Err(prepublication_failure_after_cleanup( + &temp_file, + anyhow::Error::new(error).context("failed to atomically create policy file"), + "temporary policy cleanup also failed", + )); + } + verify_persisted_handle(hosting_dir, &temp_file, final_path, bytes).map_err(WriteFailure::PostPublication) +} + +/// 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 = + policy_security::verify_policy_ancestor_chain(hosting_dir.canonical_path(), "policy directory") + .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, + }, + }) +} + +#[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 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 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 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 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() { + let dir = temp_dir(); + let final_path = dir.path().join("policy.json"); + let staging_path = dir.path().join("marker.prepare"); + std::fs::write(&final_path, b"original").unwrap(); + let staging = open_deletable_test_file(&staging_path, b"partial marker"); + + recover_marker_staging(&final_path, staging).unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), b"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).unwrap_err(); + + assert_eq!(std::fs::read(&staging_path).unwrap(), b"partial marker"); + assert!(staging_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_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 marker = + TransactionMarker::from_observation(uuid::Uuid::new_v4(), Path::new(r"C:\policy.json"), &expected, 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_content_digest, marker.new_content_digest); + } + + // ─── 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 failed_atomicity_probe_is_not_cached() { + let dir = temp_dir(); + let missing = dir.path().join("missing"); + let cache = AtomicityProbeCache::new(); + + let result = cache.get_or_probe(&missing, test_identity(1), test_security_digest(1)); + + assert!(result.is_err()); + assert!(cache.cached_success.lock().unwrap().is_none()); + } + + // ─── 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_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 a 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.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.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.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.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.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.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.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.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_and_read_only_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::ReadOnly); + assert_eq!( + observation.read_only_reason, + Some(PolicyReadOnlyReason::UnsupportedFormat) + ); + assert!(observation.policy.is_none()); + } + + #[test] + fn yaml_extension_is_reported_invalid_and_read_only_end_to_end() { + assert_unsupported_format_is_reported_invalid_and_read_only_end_to_end("policy.yaml"); + } + + #[test] + fn yml_extension_is_reported_invalid_and_read_only_end_to_end() { + assert_unsupported_format_is_reported_invalid_and_read_only_end_to_end("policy.yml"); + } + + #[test] + fn extensionless_path_is_reported_invalid_and_read_only_end_to_end() { + assert_unsupported_format_is_reported_invalid_and_read_only_end_to_end("policy"); + } + + #[test] + fn other_extension_is_reported_invalid_and_read_only_end_to_end() { + assert_unsupported_format_is_reported_invalid_and_read_only_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::verify_policy_ancestor_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 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); + } + + // ─── Mandatory probe cleanup (item 28) ───────────────────────────────────── + + #[test] + fn cleanup_probe_file_tolerates_an_already_absent_file() { + let dir = temp_dir(); + let path = dir.path().join("never-created.tmp"); + cleanup_probe_file(&path).expect("removing an already-absent file must be tolerated"); + } + + #[test] + fn cleanup_probe_file_fails_when_removal_is_blocked() { + let dir = temp_dir(); + let path = dir.path().join("locked.tmp"); + std::fs::write(&path, b"content").unwrap(); + + // Hold the file open without FILE_SHARE_DELETE so the removal attempt below + // fails with something other than NotFound. + let _locked = OpenOptions::new() + .read(true) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE).0) + .open(&path) + .unwrap(); + + let error = cleanup_probe_file(&path).unwrap_err(); + assert!(!format!("{error:#}").is_empty()); + assert!(path.exists(), "the file must still be present after a failed cleanup"); + } +} From b28ef33eb02e8ac8834de331085c1d38cf5eff4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Mon, 7 Sep 2026 21:35:23 -0400 Subject: [PATCH 02/20] fix(agent): preserve policy path diagnostics Keep unsafe path shapes distinct from unsupported file formats after replacing the path-based storage backend. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/windows.rs | 94 ++++++++++++------- 1 file changed, 62 insertions(+), 32 deletions(-) diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index f3f702053..f1641014e 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -97,17 +97,34 @@ pub(super) fn default_policy_path() -> PathBuf { /// 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. -pub(super) fn validate_configured_path_shape(path: &Path) -> Result<(), String> { +#[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(format!("configured policy path must be absolute: {}", path.display())); + 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(format!( + 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 @@ -116,31 +133,34 @@ pub(super) fn validate_configured_path_shape(path: &Path) -> Result<(), String> // `Component::CurDir` at all, so a components-based check would never catch it. for segment in raw.split(['\\', '/']) { if segment == "." { - return Err(format!( + return Err(ConfiguredPathError::UnsafeShape(format!( "configured policy path must not contain a '.' component: {}", path.display() - )); + ))); } if segment == ".." { - return Err(format!( + 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(format!("configured policy path must name a file: {}", path.display())); + 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(format!( + 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(()) @@ -997,12 +1017,22 @@ fn observe_impl( 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, - validation::DiskFailureReason::UnsupportedFormat, + failure, InvalidContext::default(), - PolicyWriteCapability::ReadOnly, - Some(PolicyReadOnlyReason::UnsupportedFormat), + PolicyWriteCapability::Unsupported, + Some(read_only_reason), ); } @@ -3146,49 +3176,49 @@ mod tests { #[test] fn relative_path_is_rejected() { let error = validate_configured_path_shape(Path::new(r"relative\policy.json")).unwrap_err(); - assert!(error.contains("absolute"), "{error}"); + 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.contains("separator"), "{error}"); + 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.contains("'.'"), "{error}"); + 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.contains("'..'"), "{error}"); + 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.contains(".json"), "{error}"); + 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.contains(".json"), "{error}"); + 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.contains(".json"), "{error}"); + 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.contains(".json"), "{error}"); + assert!(error.to_string().contains(".json"), "{error}"); } #[test] @@ -3207,7 +3237,7 @@ mod tests { /// 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_and_read_only_end_to_end(file_name: &str) { + 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 @@ -3219,7 +3249,7 @@ mod tests { let observation = observe(PolicyConfigurationSource::ConfiguredPath, &path, &probe_cache); assert_eq!(observation.state, PolicyManagementState::Invalid); - assert_eq!(observation.write_capability, PolicyWriteCapability::ReadOnly); + assert_eq!(observation.write_capability, PolicyWriteCapability::Unsupported); assert_eq!( observation.read_only_reason, Some(PolicyReadOnlyReason::UnsupportedFormat) @@ -3228,23 +3258,23 @@ mod tests { } #[test] - fn yaml_extension_is_reported_invalid_and_read_only_end_to_end() { - assert_unsupported_format_is_reported_invalid_and_read_only_end_to_end("policy.yaml"); + 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_and_read_only_end_to_end() { - assert_unsupported_format_is_reported_invalid_and_read_only_end_to_end("policy.yml"); + 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_and_read_only_end_to_end() { - assert_unsupported_format_is_reported_invalid_and_read_only_end_to_end("policy"); + 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_and_read_only_end_to_end() { - assert_unsupported_format_is_reported_invalid_and_read_only_end_to_end("policy.txt"); + 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) ───────────── From 7f016c1d2ec6d2be1edeac4847dd78de28f3641e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 00:09:22 -0400 Subject: [PATCH 03/20] fix(agent): address policy storage review Preserve legacy policy continuity while making managed-store selection sticky and observable across startup and watcher races. Harden default-directory bootstrap, capability probing, and unsupported-filesystem reporting without weakening retained-handle publication or recovery. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../now-package-broker/src/policy_security.rs | 25 ++ .../src/policy_store/mod.rs | 268 ++++++++++++- .../src/policy_store/windows.rs | 371 +++++++++++++----- .../now-package-broker/src/policy_watcher.rs | 202 ++++++++-- 4 files changed, 741 insertions(+), 125 deletions(-) diff --git a/crates/now-package-broker/src/policy_security.rs b/crates/now-package-broker/src/policy_security.rs index c68d1c0a5..949efa3fa 100644 --- a/crates/now-package-broker/src/policy_security.rs +++ b/crates/now-package-broker/src/policy_security.rs @@ -200,6 +200,16 @@ pub(crate) fn verify_policy_directory_security(directory: &File) -> anyhow::Resu ) } +/// 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, + 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( @@ -1332,6 +1342,21 @@ mod tests { ); } + #[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 diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 79afdc3ed..c913f0a06 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -158,6 +158,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, @@ -179,14 +181,36 @@ impl PolicyStore { storage: Arc, monitoring: Monitoring, ) -> Arc { - let (configured_path, source) = match configured_path { - Some(path) => (path, PolicyConfigurationSource::ConfiguredPath), - None => (windows::default_policy_path(), 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), @@ -208,8 +232,58 @@ 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) -> 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 observation; + }; + if self.default_managed_selected.load(std::sync::atomic::Ordering::Acquire) + || crate::policy_security::windows_paths_equal(&path, managed) + { + return 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 { + self.storage.observe_for_write(self.source, &final_path) + } else { + self.storage.observe(self.source, &final_path) + } + } else { + observation + } + } + + pub(crate) fn watched_paths(&self) -> Vec { + match &self.default_paths { + Some(paths) => paths.to_vec(), + None => vec![self.configured_path.clone()], + } } pub fn validate_draft(&self, raw: &serde_json::Value) -> PolicyValidationResult { @@ -229,7 +303,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 @@ -240,7 +314,7 @@ impl PolicyStore { if *monitoring != Monitoring::Initializing { return self.management_snapshot(); } - let management = self.publish_observation(self.storage.observe(self.source, &self.configured_path)); + let management = self.publish_observation(self.observe_storage(false)); *monitoring = Monitoring::Available; management } @@ -278,7 +352,7 @@ impl PolicyStore { )); } let previous = self.snapshot(); - let mut observation = self.storage.observe_for_write(self.source, &self.configured_path); + let mut observation = self.observe_storage(true); let fresh_token = token_for(&previous, &observation.fingerprint); // Both conflict modes require this exact token. @@ -358,7 +432,7 @@ impl PolicyStore { 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( @@ -377,7 +451,7 @@ impl PolicyStore { error = format!("{error:#}"), "Conditional policy publication observed a concurrent storage change" ); - let current = self.storage.observe(self.source, &self.configured_path); + let current = self.observe_storage(false); if current.fingerprint == observation.fingerprint { return Err(error_response( ErrorCode::PolicyPersistenceFailed, @@ -396,7 +470,7 @@ impl PolicyStore { 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, @@ -764,6 +838,62 @@ 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, _observation: &Observation, _bytes: &[u8]) -> Result { + unreachable!("default transition tests do not write") + } + + fn replace(&self, _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 { @@ -882,4 +1012,118 @@ mod storage_tests { 1 ); } + + #[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 index f1641014e..91f7d5843 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -82,9 +82,100 @@ pub(super) fn default_policy_dir() -> PathBuf { program_data.join("Devolutions").join("PackageBroker") } -/// Default policy file path inside [`default_policy_dir`]. -pub(super) fn default_policy_path() -> PathBuf { - default_policy_dir().join(POLICY_FILE_NAME) +fn legacy_default_policy_path() -> PathBuf { + let program_data = std::env::var_os("PROGRAMDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")); + program_data.join("Devolutions").join("Agent").join(POLICY_FILE_NAME) +} + +/// Return the managed and legacy default paths in arbitration order. +pub(super) fn default_policy_paths() -> [PathBuf; 2] { + [ + default_policy_dir().join(POLICY_FILE_NAME), + legacy_default_policy_path(), + ] +} + +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; + }; + 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 } /// Validate the *shape* of a configured policy path before ever touching disk: it must @@ -170,6 +261,13 @@ fn validate_configured_path_shape(path: &Path) -> Result<(), ConfiguredPathError /// 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, @@ -245,26 +343,27 @@ fn open_directory_no_reparse(path: &Path) -> anyhow::Result { /// 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 {}", path.display()))? + .with_context(|| format!("failed to query metadata for {subject}"))? .file_attributes(); if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { - bail!( - "{} is a reparse point (symlink/junction); the policy directory must be a real directory", - path.display() - ); + bail!("{subject} is a reparse point (symlink/junction); the policy directory must be a real directory"); } if attributes & FILE_ATTRIBUTE_DIRECTORY.0 == 0 { - bail!("{} is not a directory", path.display()); + bail!("{subject} is not a directory"); } - let final_path = policy_security::final_path_from_handle(&handle) - .with_context(|| format!("failed to resolve {}", path.display()))?; - - Ok((handle, final_path)) + Ok(()) } /// Holds the verified hosting directory and its lexical ancestors through publication. @@ -387,7 +486,11 @@ fn ensure_default_directory_secured(dir: &Path) -> anyhow::Result<(File, PathBuf leaf_name, &security_attributes, DirectorySecurityRole::DedicatedPolicy, - |_| Ok(()), + |_| { + 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); @@ -480,6 +583,19 @@ fn verify_custom_directory_secure(dir: &Path) -> anyhow::Result<(File, PathBuf, 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::verify_policy_ancestor_chain(&final_path, "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). @@ -515,79 +631,107 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> { return Err(UnsupportedFilesystem(filesystem).into()); } + let dir_handle = open_directory_no_reparse(dir)?; let probe_id = uuid::Uuid::new_v4(); let source_path = dir.join(format!(".package-broker-write-probe-{probe_id}-a.tmp")); let target_path = dir.join(format!(".package-broker-write-probe-{probe_id}-b.tmp")); let tombstone_path = dir.join(format!(".package-broker-write-probe-{probe_id}-old.tmp")); - + let source = create_probe_file(&source_path, b"probe-source")?; + let target = match create_probe_file(&target_path, b"probe-target") { + 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<()> { - std::fs::write(&source_path, b"probe-source").context("create write-capability probe source file")?; - std::fs::write(&target_path, b"probe-target").context("create write-capability probe target file")?; - let dir_handle = open_directory_no_reparse(dir)?; - let source = open_transaction_file(&source_path)?; - let target = open_transaction_file(&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")?; - drop(target); - ensure!( - !tombstone_path.exists(), - "POSIX tombstone unlink did not remove the directory entry" - ); - drop(source); + target_deleted = true; Ok(()) })(); - // Cleanup is mandatory, not best-effort (item 28): both paths are always attempted - // regardless of the probe's own outcome or each other, and any leftover probe file - // -- other than one that was never actually created (tolerated only as `NotFound`) - // -- itself disqualifies this directory from `Writable`, aggregated into the overall - // result rather than silently logged and ignored. A probe that leaves a stray file - // behind in the configured policy directory is not actually side-effect-free, - // whatever its rename result reported. - let source_cleanup = cleanup_probe_file(&source_path); - let target_cleanup = cleanup_probe_file(&target_path); - let tombstone_cleanup = cleanup_probe_file(&tombstone_path); - - probe_result - .and(source_cleanup) - .and(target_cleanup) - .and(tombstone_cleanup) -} - -/// Remove a write-capability probe file, tolerating only the file already being absent -/// (the expected outcome for `source_path` after a successful replace, which consumes -/// it). Any other failure (permission denied, sharing violation, ...) means a stray -/// probe file was left behind in the configured policy directory, which must itself -/// disqualify the directory from `Writable` (item 28): the specific OS error is only -/// ever traced, and the returned error is a single sanitized, aggregated message never -/// exposed through the management API. -fn cleanup_probe_file(path: &Path) -> anyhow::Result<()> { - match std::fs::remove_file(path) { - Ok(()) => 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 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(()), - Err(error) => { - tracing::warn!(path = %path.display(), %error, "Failed to remove write-capability probe file"); - bail!("failed to remove a write-capability probe file left behind in the policy directory") - } + 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]) -> 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(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")?; @@ -1040,10 +1184,13 @@ fn observe_impl( let leaf_name = configured_path .file_name() .expect("shape validation already required a named leaf file"); + let legacy_default = matches!(source, PolicyConfigurationSource::DefaultPath) + && policy_security::windows_paths_equal(configured_path, &legacy_default_policy_path()); - let secured = match source { - PolicyConfigurationSource::DefaultPath => ensure_default_directory_secured(dir), - PolicyConfigurationSource::ConfiguredPath => verify_custom_directory_secure(dir), + 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 { @@ -1116,7 +1263,12 @@ fn observe_impl( } }; - if let Err(error) = policy_security::verify_policy_directory_security(&dir_handle) { + 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" @@ -1173,8 +1325,12 @@ fn observe_impl( } }; - let recovery = recover_create_temporary_files(&canonical_dir) - .and_then(|()| recover_interrupted_transaction(&dir_handle, &canonical_dir, leaf_name)); + 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(), @@ -1197,7 +1353,12 @@ fn observe_impl( // The one-time, side-effecting atomic-replace capability probe (item 20): cached per // verified directory identity and security digest, never repeated on every observation. - let (base_write_capability, base_read_only_reason) = + 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)) => { @@ -1205,9 +1366,10 @@ fn observe_impl( path = %canonical_dir.display(), %diagnostic, "Policy directory is not writable through the management API" ); - (PolicyWriteCapability::ReadOnly, Some(reason)) + (probe_failure_capability(reason), Some(reason)) } - }; + } + }; let hosting_dir = (base_write_capability == PolicyWriteCapability::Writable).then_some(VerifiedHostingDirectory { handle: Some(dir_handle), @@ -1224,7 +1386,7 @@ fn observe_impl( ..Default::default() }; - let opened = match open_policy_file(&canonical_path, retain_target) { + let opened = match open_policy_file(&canonical_path, retain_target && !legacy_default) { Ok(opened) => opened, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return DiskObservation { @@ -1394,7 +1556,12 @@ fn observe_impl( ..invalid_ctx }; - if let Err(security_error) = policy_security::verify_managed_policy_file_security(&file) { + 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 @@ -2551,6 +2718,35 @@ mod tests { tempfile::tempdir().expect("create temp dir") } + #[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); + + std::fs::create_dir(managed.parent().unwrap()).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); + } + fn open_deletable_test_file(path: &Path, content: &[u8]) -> File { std::fs::write(path, content).unwrap(); OpenOptions::new() @@ -3071,6 +3267,18 @@ mod tests { assert!(cache.cached_success.lock().unwrap().is_none()); } + #[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] @@ -3611,31 +3819,14 @@ mod tests { assert_eq!(a, b); } - // ─── Mandatory probe cleanup (item 28) ───────────────────────────────────── - #[test] - fn cleanup_probe_file_tolerates_an_already_absent_file() { + fn probe_file_creation_never_truncates_an_existing_path() { let dir = temp_dir(); - let path = dir.path().join("never-created.tmp"); - cleanup_probe_file(&path).expect("removing an already-absent file must be tolerated"); - } - - #[test] - fn cleanup_probe_file_fails_when_removal_is_blocked() { - let dir = temp_dir(); - let path = dir.path().join("locked.tmp"); - std::fs::write(&path, b"content").unwrap(); - - // Hold the file open without FILE_SHARE_DELETE so the removal attempt below - // fails with something other than NotFound. - let _locked = OpenOptions::new() - .read(true) - .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE).0) - .open(&path) - .unwrap(); + let path = dir.path().join("probe.tmp"); + std::fs::write(&path, b"external").unwrap(); - let error = cleanup_probe_file(&path).unwrap_err(); + let error = create_probe_file(&path, b"probe").unwrap_err(); assert!(!format!("{error:#}").is_empty()); - assert!(path.exists(), "the file must still be present after a failed cleanup"); + 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..c4553449d 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,83 @@ 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"); + event.paths.iter().any(|event_path| { + 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) + || 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, + 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) +} + async fn debounce_change( changes: &mut tokio::sync::mpsc::Receiver, failures: &mut tokio::sync::mpsc::UnboundedReceiver, @@ -59,8 +136,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 +146,74 @@ 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 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 = Vec::new(); + let mut watched_directories = Vec::::new(); + let register = |watchers: &mut Vec, watched_directories: &mut Vec| { + for dir in watch_directories(&paths) { + if watched_directories + .iter() + .any(|watched| crate::policy_security::windows_paths_equal(watched, &dir)) + { + continue; } - }; + let watcher = create_watcher(&dir, Arc::clone(&paths), change_tx.clone(), failure_tx.clone()) + .map_err(|(failure, error)| (failure, dir.clone(), error))?; + watched_directories.push(dir); + watchers.push(watcher); + } + Ok::<(), (WatcherFailure, PathBuf, notify::Error)>(()) + }; - if let Err(error) = watcher.watch(&dir, RecursiveMode::NonRecursive) { + if let Err((failure, dir, error)) = register(&mut watchers, &mut watched_directories) { error!(%error, path = %dir.display(), "Failed to watch policy directory"); - let _ = ready.send(Err(WatcherFailure::Registration)); + let _ = ready.send(Err(failure)); return; } let _ = ready.send(Ok(())); - let _ = watcher_stop_rx.recv(); + while let Ok(command) = watcher_command_rx.recv() { + match command { + WatcherCommand::Refresh => { + watchers.clear(); + watched_directories.clear(); + if let Err((failure, dir, error)) = register(&mut watchers, &mut watched_directories) { + error!(%error, path = %dir.display(), "Failed to extend policy directory monitoring"); + let _ = failure_tx.send(failure); + return; + } + } + WatcherCommand::Stop => return, + } + } }); 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); + } 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); + } Ok(false) => break None, Err(failure) => break Some(failure), } @@ -120,7 +224,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 +287,58 @@ mod tests { )); } + #[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(PathBuf::from(r"C:\ProgramData\Devolutions\PackageBroker")), + &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)); + } + #[tokio::test] async fn watcher_task_exit_fails_closed_but_shutdown_does_not() { let store = PolicyStore::for_tests(None); From 8b4df22bfe7f74a35d3977de71b9d447a62dcfeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 00:14:41 -0400 Subject: [PATCH 04/20] fix(agent): preserve configured policy identity Thread the original configured path through hardened writes and use it for authoritative post-write observation so self-generated watcher events keep stable store tokens. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/mod.rs | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index c913f0a06..85555752d 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -249,7 +249,7 @@ impl PolicyStore { } } - fn observe_storage(&self, retain_for_write: bool) -> Observation { + 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) @@ -257,12 +257,12 @@ impl PolicyStore { self.storage.observe(self.source, &path) }; let Some([managed, legacy]) = &self.default_paths else { - return observation; + return (path, observation); }; if self.default_managed_selected.load(std::sync::atomic::Ordering::Acquire) || crate::policy_security::windows_paths_equal(&path, managed) { - return observation; + return (path, observation); } let final_path = windows::select_default_policy_path(managed.clone(), legacy.clone()); @@ -270,12 +270,15 @@ impl PolicyStore { self.default_managed_selected .store(true, std::sync::atomic::Ordering::Release); if retain_for_write { - self.storage.observe_for_write(self.source, &final_path) + ( + final_path.clone(), + self.storage.observe_for_write(self.source, &final_path), + ) } else { - self.storage.observe(self.source, &final_path) + (final_path.clone(), self.storage.observe(self.source, &final_path)) } } else { - observation + (path, observation) } } @@ -303,7 +306,7 @@ impl PolicyStore { if *monitoring != Monitoring::Available { return self.management_snapshot(); } - let observation = self.observe_storage(false); + let (_, observation) = self.observe_storage(false); let management = self.publish_observation(observation); tracing::info!(?cause, state = ?management.state, "Reloaded package broker policy"); management @@ -314,7 +317,8 @@ impl PolicyStore { if *monitoring != Monitoring::Initializing { return self.management_snapshot(); } - let management = self.publish_observation(self.observe_storage(false)); + let (_, observation) = self.observe_storage(false); + let management = self.publish_observation(observation); *monitoring = Monitoring::Available; management } @@ -352,7 +356,7 @@ impl PolicyStore { )); } let previous = self.snapshot(); - let mut observation = self.observe_storage(true); + 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. @@ -424,15 +428,15 @@ 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(&write_configured_path, &observation, &bytes) } else { - self.storage.replace(&self.configured_path, &mut observation, &bytes) + self.storage.replace(&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.observe_storage(false); + let (_, current) = self.observe_storage(false); if current.fingerprint != observation.fingerprint { let management = self.publish_observation(current); return Err(error_with_management( @@ -451,7 +455,7 @@ impl PolicyStore { error = format!("{error:#}"), "Conditional policy publication observed a concurrent storage change" ); - let current = self.observe_storage(false); + let (_, current) = self.observe_storage(false); if current.fingerprint == observation.fingerprint { return Err(error_response( ErrorCode::PolicyPersistenceFailed, @@ -470,7 +474,7 @@ impl PolicyStore { error = format!("{error:#}"), "Published policy failed authoritative reload" ); - let current = self.observe_storage(false); + let (_, current) = self.observe_storage(false); let management = self.publish_observation(current); return Err(error_with_management( ErrorCode::PolicyActivationFailed, @@ -869,11 +873,21 @@ mod storage_tests { observation } - fn create(&self, _observation: &Observation, _bytes: &[u8]) -> Result { + fn create( + &self, + _configured_path: &Path, + _observation: &Observation, + _bytes: &[u8], + ) -> Result { unreachable!("default transition tests do not write") } - fn replace(&self, _observation: &mut Observation, _bytes: &[u8]) -> Result { + fn replace( + &self, + _configured_path: &Path, + _observation: &mut Observation, + _bytes: &[u8], + ) -> Result { unreachable!("default transition tests do not write") } } From 64b49a13f1e9c4a0cd71002ee9217b8f28a48e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 00:31:33 -0400 Subject: [PATCH 05/20] fix(agent): enforce policy read barrier Deny write and delete sharing during ordinary policy observations so pre-existing mutable handles cannot alter content after ACL verification. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/windows.rs | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index 91f7d5843..d48b12641 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -1082,7 +1082,7 @@ fn open_policy_file(path: &Path, retain_for_write: bool) -> std::io::Result Date: Tue, 8 Sep 2026 01:06:30 -0400 Subject: [PATCH 06/20] fix(agent): resolve policy storage review Clarify managed and legacy default paths, bound failed capability probes, suppress probe watcher churn, and preserve the installer-owned migration boundary. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../now-package-broker/src/policy_loader.rs | 85 +++++++++++-- .../src/policy_store/windows.rs | 115 ++++++++---------- .../now-package-broker/src/policy_watcher.rs | 15 +++ 3 files changed, 139 insertions(+), 76 deletions(-) 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_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index d48b12641..ce5ee64d6 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -31,12 +31,12 @@ use windows::Win32::Storage::FileSystem::{ 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, + FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_RENAME_INFO_0, 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 windows::Win32::Storage::FileSystem::{FILE_SHARE_DELETE, MOVEFILE_REPLACE_EXISTING, MoveFileExW}; use crate::policy_security::{self, FileIdentity}; use crate::policy_store::validation; @@ -63,38 +63,9 @@ unsafe extern "system" { ) -> NTSTATUS; } -/// Default dedicated directory hosting the policy file: `%PROGRAMDATA%\Devolutions\PackageBroker`. -/// -/// Deliberately a top-level sibling of `%PROGRAMDATA%\Devolutions\Agent`, not a -/// subdirectory of it: `Agent` is shared with unrelated Agent features and its own -/// ancestor-security check must tolerate whatever grants those features require there, -/// which can never be proven as strict as the dedicated policy directory itself needs -/// its *own* ancestor chain to be (see [`policy_security::verify_policy_ancestor_chain`]). -/// A directory nested under `Agent` would inherit `Agent` as an ancestor and could never -/// honestly advertise [`PolicyWriteCapability::Writable`]. This dedicated root is created -/// and secured by this crate alone (both by the Agent installer at install time and, as -/// a fallback/self-heal, by this function's own caller at runtime), so it never has to -/// depend on -- or touch -- the shared `Agent` directory's ACL at all. -pub(super) fn default_policy_dir() -> PathBuf { - let program_data = std::env::var_os("PROGRAMDATA") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")); - program_data.join("Devolutions").join("PackageBroker") -} - -fn legacy_default_policy_path() -> PathBuf { - let program_data = std::env::var_os("PROGRAMDATA") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")); - program_data.join("Devolutions").join("Agent").join(POLICY_FILE_NAME) -} - /// Return the managed and legacy default paths in arbitration order. pub(super) fn default_policy_paths() -> [PathBuf; 2] { - [ - default_policy_dir().join(POLICY_FILE_NAME), - legacy_default_policy_path(), - ] + crate::policy_loader::default_policy_candidates() } pub(super) fn select_default_policy_path(managed: PathBuf, legacy: PathBuf) -> PathBuf { @@ -274,16 +245,22 @@ struct AtomicityProbeKey { security_digest: [u8; 32], } -/// Caches successful filesystem atomic-replace probes by directory identity and security digest. -/// Failed probes are retried on every observation. +#[derive(Clone)] +struct CachedAtomicityProbe { + key: AtomicityProbeKey, + result: ProbeResult, +} + +/// Caches filesystem atomic-replace probes by directory identity and security digest. +/// Directory replacement or ACL changes invalidate both successful and failed results. pub(super) struct AtomicityProbeCache { - cached_success: std::sync::Mutex>, + cached: std::sync::Mutex>, } impl AtomicityProbeCache { pub(super) fn new() -> Self { Self { - cached_success: std::sync::Mutex::new(None), + cached: std::sync::Mutex::new(None), } } @@ -296,10 +273,12 @@ impl AtomicityProbeCache { identity: dir_identity, security_digest: dir_security_digest, }; - let mut cached_success = self.cached_success.lock().expect("atomicity probe cache lock poisoned"); + let mut cached = self.cached.lock().expect("atomicity probe cache lock poisoned"); - if cached_success.as_ref() == Some(&key) { - return Ok(()); + if let Some(cached) = cached.as_ref() + && cached.key == key + { + return cached.result.clone(); } let result = probe_write_capability(dir).map_err(|error| { @@ -310,11 +289,10 @@ impl AtomicityProbeCache { }; (reason, format!("{error:#}")) }); - if result.is_ok() { - *cached_success = Some(key); - } else { - *cached_success = None; - } + *cached = Some(CachedAtomicityProbe { + key, + result: result.clone(), + }); result } } @@ -389,10 +367,8 @@ impl VerifiedHostingDirectory { self.identity } - /// Build a synthetic instance for `FakePolicyStorage` - /// (`crate::policy_store::tests::FakePolicyStorage`), which models the hosting - /// directory purely with generation counters and has no real Windows directory - /// handle to hold. + /// 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 { @@ -621,7 +597,7 @@ 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 of the exact rename primitives against uniquely named disposable files. +/// 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 @@ -632,10 +608,9 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> { } let dir_handle = open_directory_no_reparse(dir)?; - let probe_id = uuid::Uuid::new_v4(); - let source_path = dir.join(format!(".package-broker-write-probe-{probe_id}-a.tmp")); - let target_path = dir.join(format!(".package-broker-write-probe-{probe_id}-b.tmp")); - let tombstone_path = dir.join(format!(".package-broker-write-probe-{probe_id}-old.tmp")); + 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")?; let target = match create_probe_file(&target_path, b"probe-target") { Ok(target) => target, @@ -843,7 +818,7 @@ impl DiskFingerprint { #[cfg(test)] impl DiskFingerprint { - /// Build a synthetic fingerprint for the in-memory `FakePolicyStorage` test double, + /// 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 @@ -1184,8 +1159,9 @@ fn observe_impl( 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_policy_path()); + && policy_security::windows_paths_equal(configured_path, &legacy_default_path); let secured = match (source, legacy_default) { (PolicyConfigurationSource::DefaultPath, true) => verify_legacy_default_directory_secure(dir), @@ -1353,6 +1329,8 @@ fn observe_impl( // 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, @@ -2730,7 +2708,13 @@ mod tests { std::fs::write(&legacy, b"legacy").unwrap(); assert_eq!(select_default_policy_path(managed.clone(), legacy.clone()), legacy); - std::fs::create_dir(managed.parent().unwrap()).unwrap(); + 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() @@ -3282,15 +3266,20 @@ mod tests { } #[test] - fn failed_atomicity_probe_is_not_cached() { + 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 result = cache.get_or_probe(&missing, test_identity(1), test_security_digest(1)); + let first = cache.get_or_probe(&missing, test_identity(1), test_security_digest(1)); + assert!(first.is_err()); + std::fs::create_dir(&missing).unwrap(); - assert!(result.is_err()); - assert!(cache.cached_success.lock().unwrap().is_none()); + 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] @@ -3362,7 +3351,7 @@ mod tests { // // 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 a enabling privilege for. Mirrors the + // 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. diff --git a/crates/now-package-broker/src/policy_watcher.rs b/crates/now-package-broker/src/policy_watcher.rs index c4553449d..26f861132 100644 --- a/crates/now-package-broker/src/policy_watcher.rs +++ b/crates/now-package-broker/src/policy_watcher.rs @@ -32,6 +32,17 @@ fn affects_watched_paths(event: ¬ify::Event, paths: &[PathBuf]) -> bool { let legacy = &paths[1]; let managed_dir = managed.parent().expect("managed 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) @@ -300,6 +311,10 @@ mod tests { &event(managed.with_file_name(".package-broker-policy.json.txn-id.marker")), &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 From 784d14a3aab3b9d968d813926bd66f459dac2da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 01:27:55 -0400 Subject: [PATCH 07/20] fix(agent): close policy storage review gaps Revalidate ancestor evidence before Create publication, retain traversal rights on directory handles, and publish authoritative post-write capabilities. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../now-package-broker/src/policy_security.rs | 8 +- .../src/policy_store/mod.rs | 52 ++++++- .../src/policy_store/windows.rs | 145 ++++++++++++++---- 3 files changed, 165 insertions(+), 40 deletions(-) diff --git a/crates/now-package-broker/src/policy_security.rs b/crates/now-package-broker/src/policy_security.rs index 949efa3fa..2289fd0e1 100644 --- a/crates/now-package-broker/src/policy_security.rs +++ b/crates/now-package-broker/src/policy_security.rs @@ -57,9 +57,9 @@ use windows::Win32::Security::{ 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; @@ -304,7 +304,7 @@ pub(crate) fn retain_policy_no_reparse_directory_chain(dir: &Path, subject: &str for component in components { let component_subject = format!("{subject} component '{}'", component.display()); let handle = OpenOptions::new() - .access_mode((FILE_READ_ATTRIBUTES | READ_CONTROL).0) + .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) diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 85555752d..293858fca 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -78,7 +78,12 @@ impl PolicyStorage for FilePolicyStorage { .hosting_dir .as_ref() .expect("writable observations retain the verified hosting directory"); - windows::atomic_create(hosting_dir, &observation.canonical_path, bytes)?; + windows::atomic_create( + hosting_dir, + &observation.fingerprint, + &observation.canonical_path, + bytes, + )?; self.authoritative_reobserve(configured_path, bytes) } @@ -127,6 +132,9 @@ impl FilePolicyStorage { Ok(PersistedPolicy { policy, fingerprint: observation.fingerprint, + write_capability: observation.write_capability, + read_only_reason: observation.read_only_reason, + canonical_path: observation.canonical_path, }) } } @@ -489,9 +497,9 @@ impl PolicyStore { state: PolicyManagementState::Active, policy: Some(Arc::new(persisted.policy.clone())), invalid_diagnostics: None, - write_capability: observation.write_capability, - read_only_reason: observation.read_only_reason, - configured_path: observation.canonical_path, + write_capability: persisted.write_capability, + read_only_reason: persisted.read_only_reason, + configured_path: persisted.canonical_path, store_token: token, fingerprint: persisted.fingerprint, }); @@ -664,6 +672,7 @@ struct TestStorage { 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)>>, } #[cfg(test)] @@ -675,6 +684,7 @@ impl TestStorage { 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), } } @@ -685,6 +695,7 @@ impl TestStorage { 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), } } @@ -770,10 +781,18 @@ impl TestStorage { next.policy = Some(policy.clone()); next.invalid_diagnostics = None; 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, fingerprint: next.fingerprint, + write_capability: next.write_capability, + read_only_reason: next.read_only_reason, + canonical_path: next.canonical_path, }) } } @@ -1027,6 +1046,31 @@ mod storage_tests { ); } + #[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 default_store_switches_from_legacy_when_managed_policy_appears() { let dir = tempfile::tempdir().expect("create temp directory"); diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index ce5ee64d6..c28f367d7 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -814,6 +814,24 @@ impl DiskFingerprint { _ => 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)] @@ -1770,6 +1788,9 @@ pub(super) fn random_store_token() -> PolicyStoreToken { 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. @@ -1812,28 +1833,23 @@ 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")?; - observed_target - .verify_matches(expected_fingerprint) - .context("observed policy changed before conditional publication")?; - let expected_ancestor_security = match expected_fingerprint { - DiskFingerprint::Active { - ancestor_security_digest, - .. - } - | DiskFingerprint::Invalid { - ancestor_security_digest: Some(ancestor_security_digest), - .. - } => *ancestor_security_digest, - _ => { - return Err(anyhow::anyhow!( - "replacement observation has no ancestor security fingerprint" - )); - } - }; + let expected_ancestor_security = expected_fingerprint + .ancestor_security_digest() + .context("write observation has no ancestor security fingerprint")?; let current_ancestor_security = policy_security::verify_policy_ancestor_chain(hosting_dir.canonical_path(), "policy directory") .context("policy directory ancestor security changed after token validation")?; @@ -2568,15 +2584,13 @@ fn read_file_from_start(file: &File) -> anyhow::Result> { /// 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 _; - hosting_dir - .verify_unchanged() - .context("hosting directory changed after policy observation") - .map_err(WriteFailure::PrePublication)?; + verify_directory_evidence(hosting_dir, expected_fingerprint).map_err(WriteFailure::ConcurrentChange)?; let dir_handle = hosting_dir .handle .as_ref() @@ -2600,20 +2614,45 @@ pub(super) fn atomic_create( "temporary policy cleanup also failed", )); } - if let Err(error) = rename_file_handle( - &temp_file, - dir_handle, - final_path.file_name().expect("policy path has leaf"), - ) { - return Err(prepublication_failure_after_cleanup( - &temp_file, - anyhow::Error::new(error).context("failed to atomically create policy file"), - "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. @@ -2674,6 +2713,9 @@ fn verify_persisted_handle( dir_security_digest, ancestor_security_digest, }, + write_capability: PolicyWriteCapability::Writable, + read_only_reason: None, + canonical_path: final_path.to_owned(), }) } @@ -2755,6 +2797,23 @@ mod tests { 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(); @@ -2769,6 +2828,28 @@ mod tests { 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(); From 2da3f216f2258ff18ab24fb84427e7df56e22038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 09:23:28 -0400 Subject: [PATCH 08/20] fix(agent): bind policy recovery identity Persist prepared replacement identity and security in durable markers before tombstoning, and retry bounded probe collisions without restart. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/windows.rs | 531 +++++++++++++++--- 1 file changed, 459 insertions(+), 72 deletions(-) diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index c28f367d7..a5db0d0d4 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -249,15 +249,19 @@ struct AtomicityProbeKey { 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 both successful and failed results. +/// 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), @@ -269,6 +273,16 @@ impl AtomicityProbeCache { /// 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, @@ -277,6 +291,7 @@ impl AtomicityProbeCache { 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(); } @@ -292,6 +307,7 @@ impl AtomicityProbeCache { *cached = Some(CachedAtomicityProbe { key, result: result.clone(), + retry_at: result.is_err().then_some(now + Self::FAILURE_RETRY_INTERVAL), }); result } @@ -1895,7 +1911,9 @@ struct TransactionMarker { 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 { @@ -1903,6 +1921,7 @@ impl TransactionMarker { 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 @@ -1918,20 +1937,27 @@ impl TransactionMarker { 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": 1, + "Version": 2, "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") } @@ -1939,9 +1965,9 @@ impl TransactionMarker { 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() == 8, "transaction marker contains unexpected fields"); + ensure!(object.len() == 11, "transaction marker contains unexpected fields"); ensure!( - object.get("Version").and_then(serde_json::Value::as_u64) == Some(1), + object.get("Version").and_then(serde_json::Value::as_u64) == Some(2), "unsupported transaction marker" ); let text = |name: &str| -> anyhow::Result<&str> { @@ -1956,8 +1982,12 @@ impl TransactionMarker { .with_context(|| format!("transaction marker {name} is invalid"))?; Ok(output) }; - let mut file_id = [0u8; 16]; - hex::decode_to_slice(text("OldFileId")?, &mut file_id).context("transaction marker OldFileId is invalid")?; + 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(), @@ -1966,11 +1996,19 @@ impl TransactionMarker { .get("OldVolumeSerial") .and_then(serde_json::Value::as_u64) .context("transaction marker OldVolumeSerial is missing or invalid")?, - file_id, + 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")?, }) } } @@ -1990,63 +2028,66 @@ fn conditional_replace( .expect("real storage always retains the directory handle"); let paths = TransactionPaths::new(hosting_dir.canonical_path(), final_path).map_err(WriteFailure::PrePublication)?; - let marker = TransactionMarker::from_observation(paths.id, final_path, expected_fingerprint, bytes) - .map_err(WriteFailure::ConcurrentChange)?; - let mut marker_file = - create_secure_transaction_file(&paths.marker_staging).map_err(WriteFailure::PrePublication)?; + let mut temp_file = create_secure_transaction_file(&paths.new).map_err(WriteFailure::PrePublication)?; + 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") + }) + { + return Err(prepublication_failure_after_cleanup( + &temp_file, + error, + "temporary replacement cleanup also failed", + )); + } + 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")]); + return Err(WriteFailure::ConcurrentChange(error)); + } + }; + + let mut marker_file = match create_secure_transaction_file(&paths.marker_staging) { + Ok(file) => file, + Err(error) => { + let error = cleanup_transaction_files(error, &[(&temp_file, "replacement policy cleanup also failed")]); + return Err(WriteFailure::PrePublication(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") { - return Err(prepublication_failure_after_cleanup( - &marker_file, + let error = cleanup_transaction_files( error, - "incomplete transaction marker cleanup also failed", - )); + &[ + (&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"), ) { - return Err(prepublication_failure_after_cleanup( - &marker_file, + let error = cleanup_transaction_files( anyhow::Error::new(error).context("failed to publish completed transaction marker"), - "transaction marker staging cleanup also failed", - )); - } - - let mut temp_file = match create_secure_transaction_file(&paths.new) { - Ok(file) => file, - Err(error) => { - return Err(prepublication_failure_after_cleanup( - &marker_file, - error, - "transaction marker cleanup also failed", - )); - } - }; - 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") - }) - { - if let Err(cleanup_error) = delete_file_handle(&temp_file) { - return Err(WriteFailure::PrePublication(error.context(format!( - "temporary replacement cleanup failed; transaction marker retained for recovery: {cleanup_error:#}" - )))); - } - return Err(prepublication_failure_after_cleanup( - &marker_file, - error, - "transaction marker cleanup also failed", - )); + &[ + (&marker_file, "transaction marker staging cleanup also failed"), + (&temp_file, "replacement policy cleanup also failed"), + ], + ); + return Err(WriteFailure::PrePublication(error)); } let prepared = PreparedTransaction { @@ -2072,6 +2113,15 @@ fn conditional_replace( 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, @@ -2434,22 +2484,26 @@ fn recover_interrupted_transaction(dir: &File, dir_path: &Path, final_leaf: &OsS } let new_file = open_optional_transaction_file(&paths.new)?; - let new_content_matches = if let Some(new_file) = &new_file { + if let Some(new_file) = &new_file { verify_transaction_file_path(new_file, &paths.new)?; - policy_security::verify_managed_policy_file_security(new_file) - .context("transaction replacement security is invalid")?; - sha256_digest(&read_file_from_start(new_file)?) == marker.new_content_digest - } else { - true - }; - if old_file.is_some() { - ensure!( - new_content_matches, - "transaction replacement content changed after tombstoning" - ); + 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(dir, dir_path, OsStr::new(final_leaf), marker_file, old_file, new_file) + 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) -> anyhow::Result<()> { @@ -2461,16 +2515,17 @@ fn recover_marker_staging(final_path: &Path, marker_staging: File) -> anyhow::Re Ok(()) } -fn recover_verified_transaction( +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_guard(&final_path)?; + let mut final_guard = open_optional_final_policy(&final_path)?; let mut restored = false; if final_guard.is_none() { let old_file = old_file @@ -2479,7 +2534,7 @@ fn recover_verified_transaction( match rename_file_handle(old_file, dir, final_leaf) { Ok(()) => restored = true, Err(error) => { - final_guard = open_optional_final_guard(&final_path)?; + final_guard = open_optional_final_policy(&final_path)?; if final_guard.is_none() { return Err(error).context("failed to restore interrupted policy transaction"); } @@ -2488,9 +2543,18 @@ fn recover_verified_transaction( } } - if !restored && let Some(old_file) = old_file.take() { - delete_file_handle(&old_file).context("failed to retire policy transaction tombstone")?; - drop(old_file); + 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")?; @@ -2502,6 +2566,99 @@ fn recover_verified_transaction( 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 && validation.findings.is_empty(), + "transaction final replacement failed strict 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) @@ -2738,6 +2895,20 @@ mod tests { tempfile::tempdir().expect("create temp dir") } + fn valid_committed_policy_bytes() -> 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": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Rules": [] + })) + .unwrap(); + let policy = draft.into_policy_document(1, chrono::Utc::now()).unwrap(); + serde_json::to_vec(&policy).unwrap() + } + #[test] fn default_path_prefers_managed_then_legacy_then_new_location() { let dir = temp_dir(); @@ -3246,6 +3417,171 @@ mod tests { 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_failure_never_discards_the_only_tombstone() { let dir = temp_dir(); @@ -3307,9 +3643,16 @@ mod tests { #[test] fn transaction_marker_round_trips_exact_state() { let expected = DiskFingerprint::test_active(b"old", 2, 3, 4, 5); - let marker = - TransactionMarker::from_observation(uuid::Uuid::new_v4(), Path::new(r"C:\policy.json"), &expected, b"new") - .unwrap(); + 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(); @@ -3318,7 +3661,18 @@ mod tests { 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()); + + let mut missing_identity = legacy; + missing_identity["Version"] = 2.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 ────────────────────── @@ -3363,6 +3717,39 @@ mod tests { .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!( From 21e5e3828aa04153ef89e4ed77094d6599ad8aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 09:50:41 -0400 Subject: [PATCH 09/20] fix(agent): complete policy crash recovery Recover verified pre-marker states, bind published replacements to durable identity evidence, bound probe retries, and refresh watchers without monitoring gaps. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/windows.rs | 200 ++++++++++++++++-- .../now-package-broker/src/policy_watcher.rs | 82 ++++--- 2 files changed, 235 insertions(+), 47 deletions(-) diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index a5db0d0d4..a110186a7 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -2029,7 +2029,15 @@ fn conditional_replace( let paths = TransactionPaths::new(hosting_dir.canonical_path(), final_path).map_err(WriteFailure::PrePublication)?; - let mut temp_file = create_secure_transaction_file(&paths.new).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()) @@ -2039,28 +2047,30 @@ fn conditional_replace( .context("replacement policy temporary file failed security verification") }) { - return Err(prepublication_failure_after_cleanup( - &temp_file, + let error = cleanup_transaction_files( error, - "temporary replacement cleanup also failed", - )); + &[ + (&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")]); + 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)); } }; - let mut marker_file = match create_secure_transaction_file(&paths.marker_staging) { - Ok(file) => file, - Err(error) => { - let error = cleanup_transaction_files(error, &[(&temp_file, "replacement policy cleanup also failed")]); - return Err(WriteFailure::PrePublication(error)); - } - }; if let Err(error) = marker_file .write_all(&marker.to_bytes()) .and_then(|()| marker_file.sync_all()) @@ -2441,7 +2451,7 @@ fn recover_interrupted_transaction(dir: &File, dir_path: &Path, final_leaf: &OsS }; if let Some(marker_staging_path) = marker_staging_path { ensure!( - marker_path.is_none() && old_path.is_none() && new_path.is_none(), + 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)?; @@ -2449,7 +2459,25 @@ fn recover_interrupted_transaction(dir: &File, dir_path: &Path, final_leaf: &OsS policy_security::verify_managed_policy_file_security(&marker_staging) .context("transaction marker staging security is invalid")?; let final_path = dir_path.join(final_leaf); - return recover_marker_staging(&final_path, marker_staging); + 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 { @@ -2506,15 +2534,58 @@ fn recover_interrupted_transaction(dir: &File, dir_path: &Path, final_leaf: &OsS ) } -fn recover_marker_staging(final_path: &Path, marker_staging: File) -> anyhow::Result<()> { - let final_guard = open_optional_final_guard(final_path)? +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, @@ -3337,15 +3408,24 @@ mod tests { #[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"); - std::fs::write(&final_path, b"original").unwrap(); + 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).unwrap(); + recover_marker_staging(&final_path, staging, None).unwrap(); - assert_eq!(std::fs::read(&final_path).unwrap(), b"original"); + assert_eq!(std::fs::read(&final_path).unwrap(), original); assert!(!staging_path.exists()); } @@ -3356,12 +3436,90 @@ mod tests { 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).unwrap_err(); + 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(); diff --git a/crates/now-package-broker/src/policy_watcher.rs b/crates/now-package-broker/src/policy_watcher.rs index 26f861132..38bed7385 100644 --- a/crates/now-package-broker/src/policy_watcher.rs +++ b/crates/now-package-broker/src/policy_watcher.rs @@ -31,6 +31,7 @@ fn affects_watched_paths(event: ¬ify::Event, paths: &[PathBuf]) -> bool { 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() @@ -46,6 +47,7 @@ fn affects_watched_paths(event: ¬ify::Event, paths: &[PathBuf]) -> bool { 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)) @@ -108,6 +110,34 @@ fn create_watcher( Ok(watcher) } +struct WatcherSet { + watchers: Vec, + directories: 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(), + directories: 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.directories.push(dir); + 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, @@ -164,40 +194,22 @@ impl PolicyWatcher { let (watcher_command_tx, watcher_command_rx) = std::sync::mpsc::channel(); let _watcher_handle = tokio::task::spawn_blocking(move || { - let mut watchers = Vec::new(); - let mut watched_directories = Vec::::new(); - let register = |watchers: &mut Vec, watched_directories: &mut Vec| { - for dir in watch_directories(&paths) { - if watched_directories - .iter() - .any(|watched| crate::policy_security::windows_paths_equal(watched, &dir)) - { - continue; - } - let watcher = create_watcher(&dir, Arc::clone(&paths), change_tx.clone(), failure_tx.clone()) - .map_err(|(failure, error)| (failure, dir.clone(), error))?; - watched_directories.push(dir); - watchers.push(watcher); + let mut watchers = match build_watcher_set(&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; } - Ok::<(), (WatcherFailure, PathBuf, notify::Error)>(()) }; - if let Err((failure, dir, error)) = register(&mut watchers, &mut watched_directories) { - 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 => { - watchers.clear(); - watched_directories.clear(); - if let Err((failure, dir, error)) = register(&mut watchers, &mut watched_directories) { + 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"); - let _ = failure_tx.send(failure); - return; } } WatcherCommand::Stop => return, @@ -319,6 +331,10 @@ mod tests { &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 @@ -354,6 +370,20 @@ mod tests { 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); From 76d017dae99d5763fd64e56b825ddfbb58b964d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 10:24:56 -0400 Subject: [PATCH 10/20] fix(agent): stabilize policy security digests Use architecture-independent security descriptor encoding and reject older transaction marker digest semantics during recovery. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../now-package-broker/src/policy_security.rs | 103 ++++++++++++++---- .../src/policy_store/windows.rs | 9 +- 2 files changed, 85 insertions(+), 27 deletions(-) diff --git a/crates/now-package-broker/src/policy_security.rs b/crates/now-package-broker/src/policy_security.rs index 2289fd0e1..d1870c48c 100644 --- a/crates/now-package-broker/src/policy_security.rs +++ b/crates/now-package-broker/src/policy_security.rs @@ -50,9 +50,9 @@ use windows::Win32::Foundation::{ 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, @@ -350,20 +350,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. @@ -371,17 +371,47 @@ 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. - update_ace_digest(&mut hasher, 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]), + } + 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]), } - Ok(hasher.finalize().into()) } -fn update_ace_digest(hasher: &mut Sha256, ace_bytes: &[u8]) { - hasher.update(ace_bytes.len().to_le_bytes()); - hasher.update(ace_bytes); +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 { @@ -1192,12 +1222,37 @@ mod tests { #[test] fn ace_digest_includes_callback_application_data() { - let mut left = Sha256::new(); - let mut right = Sha256::new(); - update_ace_digest(&mut left, &[1, 2, 3, 4]); - update_ace_digest(&mut right, &[1, 2, 3, 5]); + 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_ne!(left.finalize(), right.finalize()); + 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] diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index a110186a7..d7247aac8 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -1947,7 +1947,7 @@ impl TransactionMarker { fn to_bytes(&self) -> Vec { serde_json::to_vec(&serde_json::json!({ - "Version": 2, + "Version": 3, "TransactionId": self.id.to_string(), "FinalLeaf": self.final_leaf, "OldVolumeSerial": self.old_identity.volume_serial, @@ -1967,7 +1967,7 @@ impl TransactionMarker { 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(2), + object.get("Version").and_then(serde_json::Value::as_u64) == Some(3), "unsupported transaction marker" ); let text = |name: &str| -> anyhow::Result<&str> { @@ -3827,8 +3827,11 @@ mod tests { 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"] = 2.into(); + 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()); } From c3b7a8087e666630a7aed90c3265681f06c0d4b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 11:17:27 -0400 Subject: [PATCH 11/20] fix(agent): align policy recovery validation Retain watcher handles explicitly and recover exact warning-bearing policies using the same validity criterion as normal activation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/windows.rs | 66 +++++++++++++++++-- .../now-package-broker/src/policy_watcher.rs | 11 +--- 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index d7247aac8..8a4089e16 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -2711,8 +2711,8 @@ fn verify_recovery_final( .context("transaction final replacement is not a policy document")?; let validation = validation::validate_committed_policy(&policy); ensure!( - validation.is_valid && validation.findings.is_empty(), - "transaction final replacement failed strict committed-policy validation" + validation.is_valid, + "transaction final replacement failed committed-policy validation" ); Ok(RecoveryFinalState::PublishedReplacement) } @@ -2966,13 +2966,13 @@ mod tests { tempfile::tempdir().expect("create temp dir") } - fn valid_committed_policy_bytes() -> Vec { + 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": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Enforcement": { "DefaultDecision": default_decision, "RulePrecedence": "PriorityThenDeny" }, "Rules": [] })) .unwrap(); @@ -2980,6 +2980,10 @@ mod tests { 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(); @@ -3740,6 +3744,60 @@ mod tests { 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(); diff --git a/crates/now-package-broker/src/policy_watcher.rs b/crates/now-package-broker/src/policy_watcher.rs index 38bed7385..7940ac5db 100644 --- a/crates/now-package-broker/src/policy_watcher.rs +++ b/crates/now-package-broker/src/policy_watcher.rs @@ -111,8 +111,7 @@ fn create_watcher( } struct WatcherSet { - watchers: Vec, - directories: Vec, + _watchers: Vec, } fn build_watcher_set( @@ -120,15 +119,11 @@ fn build_watcher_set( changes: &tokio::sync::mpsc::Sender, failures: &tokio::sync::mpsc::UnboundedSender, ) -> Result { - let mut set = WatcherSet { - watchers: Vec::new(), - directories: Vec::new(), - }; + 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.directories.push(dir); - set.watchers.push(watcher); + set._watchers.push(watcher); } Ok(set) } From 389885992c7067be384c55a60e705642e452b3d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 12:02:04 -0400 Subject: [PATCH 12/20] fix(agent): bind policy ancestor identity Digest the retained root-to-leaf ancestor identity sequence and reject dangling reparse or directory leaves before Missing-state classification. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../now-package-broker/src/policy_security.rs | 145 ++++++++++++++---- .../src/policy_store/windows.rs | 132 ++++++++++++++-- 2 files changed, 230 insertions(+), 47 deletions(-) diff --git a/crates/now-package-broker/src/policy_security.rs b/crates/now-package-broker/src/policy_security.rs index d1870c48c..16a78c72a 100644 --- a/crates/now-package-broker/src/policy_security.rs +++ b/crates/now-package-broker/src/policy_security.rs @@ -251,21 +251,16 @@ pub(crate) fn os_strings_match_case_insensitive(left: &OsStr, right: &OsStr) -> unsafe { CompareStringOrdinal(&left, &right, true) == CSTR_EQUAL } } -/// Verify every policy-directory ancestor without following reparses and summarize its security state. -pub(crate) fn verify_policy_ancestor_chain(dir: &Path, subject: &str) -> anyhow::Result<[u8; 32]> { - let mut hasher = Sha256::new(); - let mut current = dir.parent(); - - while let Some(ancestor) = current { - let dir_subject = format!("{subject} ancestor directory '{}'", ancestor.display()); - let handle = 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 | FILE_FLAG_OPEN_REPARSE_POINT).0) - .open(ancestor) - .with_context(|| format!("failed to open {dir_subject}"))?; - - if is_reparse_point(&handle).with_context(|| format!("failed to inspect {dir_subject}"))? { +/// 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 @@ -275,24 +270,41 @@ pub(crate) fn verify_policy_ancestor_chain(dir: &Path, subject: &str) -> anyhow: if attributes & windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_DIRECTORY.0 == 0 { bail!("{dir_subject} is not a directory"); } - - let resolved = final_path_from_handle(&handle).with_context(|| format!("failed to resolve {dir_subject}"))?; - if !windows_paths_equal(&resolved, ancestor) { - bail!( - "{dir_subject} resolved to an unexpected location '{}'; refusing to trust a retargeted ancestor", - resolved.display() - ); - } - verify_policy_ancestor_directory_security(&handle, &dir_subject)?; + verify_policy_ancestor_directory_security(handle, &dir_subject)?; let security_digest = - security_state_digest(&handle).with_context(|| format!("failed to digest {dir_subject} security"))?; - hasher.update(resolved.as_os_str().to_string_lossy().to_lowercase().as_bytes()); - hasher.update(b"\0"); - hasher.update(security_digest); - current = ancestor.parent(); + 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(hasher.finalize().into()) + 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. @@ -1255,6 +1267,79 @@ mod tests { ); } + #[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"); diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index 8a4089e16..16327434b 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -330,8 +330,8 @@ fn open_directory_no_reparse(path: &Path) -> anyhow::Result { /// /// Fails closed on any ambiguity: missing path, wrong object type, or reparse point. /// -/// This only verifies `path` itself; callers additionally verify the ancestor chain with -/// [`policy_security::verify_policy_ancestor_chain`], so an untrusted principal further +/// 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. @@ -365,7 +365,7 @@ fn verify_directory_handle_type(handle: &File, subject: &str) -> anyhow::Result< /// Test storage models the same checks with identity and security generations. pub(super) struct VerifiedHostingDirectory { handle: Option, - _ancestor_handles: Vec, + ancestor_handles: Vec, canonical_path: PathBuf, identity: FileIdentity, security_digest: [u8; 32], @@ -389,7 +389,7 @@ impl VerifiedHostingDirectory { pub(super) fn for_fake_storage(canonical_path: PathBuf, identity: FileIdentity, security_digest: [u8; 32]) -> Self { Self { handle: None, - _ancestor_handles: Vec::new(), + ancestor_handles: Vec::new(), canonical_path, identity, security_digest, @@ -419,6 +419,10 @@ impl VerifiedHostingDirectory { ); Ok(current_security) } + + fn ancestor_digest(&self) -> anyhow::Result<[u8; 32]> { + policy_security::verified_policy_ancestor_digest(&self.ancestor_handles, "policy directory") + } } /// Create the dedicated default policy directory (if it does not already exist) with an @@ -464,7 +468,6 @@ fn ensure_default_directory_secured(dir: &Path) -> anyhow::Result<(File, PathBuf "ProgramData resolved to an unexpected location" ); policy_security::verify_policy_ancestor_directory_security(&program_data_handle, "ProgramData directory")?; - policy_security::verify_policy_ancestor_chain(&canonical_program_data, "ProgramData directory")?; let vendor_handle = ensure_secure_directory_component( &program_data_handle, @@ -487,7 +490,8 @@ fn ensure_default_directory_secured(dir: &Path) -> anyhow::Result<(File, PathBuf 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::verify_policy_ancestor_chain(&final_path, "policy directory")?; + let ancestor_security_digest = + policy_security::verified_policy_ancestor_digest(&ancestor_handles, "policy directory")?; Ok((handle, final_path, ancestor_security_digest, ancestor_handles)) } @@ -571,7 +575,8 @@ fn verify_custom_directory_secure(dir: &Path) -> anyhow::Result<(File, PathBuf, .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::verify_policy_ancestor_chain(&final_path, "policy directory")?; + let ancestor_security_digest = + policy_security::verified_policy_ancestor_digest(&ancestor_handles, "policy directory")?; Ok((handle, final_path, ancestor_security_digest, ancestor_handles)) } @@ -584,7 +589,7 @@ fn verify_legacy_default_directory_secure(dir: &Path) -> anyhow::Result<(File, P let final_path = policy_security::final_path_from_handle(&handle)?; policy_security::verify_legacy_policy_directory_security(&handle)?; let ancestor_security_digest = - policy_security::verify_policy_ancestor_chain(&final_path, "legacy policy directory")?; + policy_security::verified_policy_ancestor_digest(&ancestor_handles, "legacy policy directory")?; Ok((handle, final_path, ancestor_security_digest, ancestor_handles)) } @@ -1064,6 +1069,29 @@ struct OpenedPolicyFile { 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() @@ -1314,7 +1342,7 @@ fn observe_impl( } }; let ancestor_security_digest = - match policy_security::verify_policy_ancestor_chain(&canonical_dir, "policy directory") { + match policy_security::verified_policy_ancestor_digest(&ancestor_handles, "policy directory") { Ok(digest) => digest, Err(error) => { tracing::warn!( @@ -1385,7 +1413,7 @@ fn observe_impl( let hosting_dir = (base_write_capability == PolicyWriteCapability::Writable).then_some(VerifiedHostingDirectory { handle: Some(dir_handle), - _ancestor_handles: ancestor_handles, + ancestor_handles, canonical_path: canonical_dir.clone(), identity: parent, security_digest: dir_security_digest, @@ -1398,9 +1426,37 @@ fn observe_impl( ..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, @@ -1866,9 +1922,9 @@ fn verify_directory_evidence( let expected_ancestor_security = expected_fingerprint .ancestor_security_digest() .context("write observation has no ancestor security fingerprint")?; - let current_ancestor_security = - policy_security::verify_policy_ancestor_chain(hosting_dir.canonical_path(), "policy directory") - .context("policy directory ancestor security changed after token validation")?; + 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" @@ -2895,9 +2951,9 @@ fn verify_persisted_handle( .verify_unchanged() .context("held policy directory changed during replacement")?; let parent = hosting_dir.identity(); - let ancestor_security_digest = - policy_security::verify_policy_ancestor_chain(hosting_dir.canonical_path(), "policy directory") - .context("policy directory ancestor chain failed verification immediately after writing")?; + 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!( @@ -4013,6 +4069,23 @@ mod tests { 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); @@ -4203,7 +4276,8 @@ mod tests { let candidate_dir = junction.join("policy-dir"); std::fs::create_dir(&candidate_dir).unwrap(); - let error = policy_security::verify_policy_ancestor_chain(&candidate_dir, "policy directory").unwrap_err(); + 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}"); } @@ -4329,6 +4403,30 @@ mod tests { 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 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"); From 46346e2c38d229f6d2957252f313d0581ce1be8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 12:38:10 -0400 Subject: [PATCH 13/20] fix(agent): verify no-replace rename semantics Require a definitive destination collision before accepting the atomic rename capability probe. Preserve Win32 HRESULT and native NTSTATUS failure categories so sharing, permission, and unsupported errors cannot certify the filesystem. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/windows.rs | 299 ++++++++++++++++-- 1 file changed, 275 insertions(+), 24 deletions(-) diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index 16327434b..cbcb41003 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -25,18 +25,21 @@ use now_policy_api::{ }; use sha2::{Digest as _, Sha256}; use win_api_wrappers::str::{U16CStrExt as _, U16CString}; -use windows::Win32::Foundation::{GENERIC_READ, GENERIC_WRITE, HANDLE, NTSTATUS}; +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, 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_NONE, FILE_SHARE_READ, - FILE_SHARE_WRITE, FILE_TRAVERSE, FileDispositionInfoEx, FileRenameInfo, FileRenameInfoEx, GetVolumeInformationW, - GetVolumePathNameW, READ_CONTROL, SetFileInformationByHandle, + 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::{FILE_SHARE_DELETE, MOVEFILE_REPLACE_EXISTING, MoveFileExW}; +use windows::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MoveFileExW}; use crate::policy_security::{self, FileIdentity}; use crate::policy_store::validation; @@ -297,7 +300,9 @@ impl AtomicityProbeCache { } let result = probe_write_capability(dir).map_err(|error| { - let reason = if error.downcast_ref::().is_some() { + let reason = if error.downcast_ref::().is_some() + || error.downcast_ref::().is_some() + { PolicyReadOnlyReason::UnsupportedFileSystem } else { PolicyReadOnlyReason::InsufficientPermissions @@ -611,6 +616,17 @@ impl std::fmt::Display for UnsupportedFilesystem { 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"]; @@ -632,8 +648,8 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> { 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")?; - let target = match create_probe_file(&target_path, b"probe-target") { + 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") { @@ -648,6 +664,7 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> { 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, @@ -689,6 +706,59 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> { 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_transaction_file_path(source, source_path)?; + verify_transaction_file_path(target, target_path)?; + 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 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); @@ -703,7 +773,7 @@ fn ensure_path_absent(path: &Path, subject: &str) -> anyhow::Result<()> { } } -fn create_probe_file(path: &Path, bytes: &[u8]) -> anyhow::Result { +fn create_probe_file(path: &Path, bytes: &[u8], allow_delete_share: bool) -> anyhow::Result { use std::io::Write as _; let mut file = OpenOptions::new() @@ -711,7 +781,14 @@ fn create_probe_file(path: &Path, bytes: &[u8]) -> anyhow::Result { .write(true) .create_new(true) .access_mode(GENERIC_READ.0 | GENERIC_WRITE.0 | DELETE.0 | READ_CONTROL.0) - .share_mode(FILE_SHARE_READ.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()))?; @@ -2301,18 +2378,115 @@ fn open_transaction_file(path: &Path) -> anyhow::Result { .with_context(|| format!("failed to open transaction remnant {}", path.display())) } -fn rename_file_handle(file: &File, root: &File, new_name: &OsStr) -> std::io::Result<()> { - let information = RenameInformation::new(HANDLE(root.as_raw_handle()), new_name); - match set_file_rename_information(file, &information) { +#[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(win32_error) => nt_set_file_rename_information(file, &information).map_err(|nt_error| { - std::io::Error::other(format!( - "handle-relative rename failed through Win32 ({win32_error}) and NT ({nt_error})" - )) - }), + 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, @@ -2378,14 +2552,14 @@ fn set_file_rename_information(file: &File, information: &RenameInformation) -> } } -fn nt_set_file_rename_information(file: &File, information: &RenameInformation) -> std::io::Result<()> { +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. - unsafe { + let status = unsafe { NtSetInformationFile( HANDLE(file.as_raw_handle()), &mut io_status, @@ -2393,9 +2567,8 @@ fn nt_set_file_rename_information(file: &File, information: &RenameInformation) information.length, FILE_RENAME_INFORMATION_EX_CLASS, ) - .ok() - .map_err(std::io::Error::from) - } + }; + if status.0 >= 0 { Ok(()) } else { Err(status) } } fn delete_file_handle(file: &File) -> anyhow::Result<()> { @@ -3975,6 +4148,84 @@ mod tests { 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(); + } + + 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(); @@ -4625,7 +4876,7 @@ mod tests { let path = dir.path().join("probe.tmp"); std::fs::write(&path, b"external").unwrap(); - let error = create_probe_file(&path, b"probe").unwrap_err(); + 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"); } From 8ec63125c562e30f8655cffeb9b89ea1b250b5ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 13:14:08 -0400 Subject: [PATCH 14/20] fix(agent): verify probe entries by identity Reopen probe leaves relative to the retained directory and compare file identities after a no-replace collision. This avoids rejecting equivalent Windows path representations while still detecting retargeted entries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/windows.rs | 139 +++++++++++++++++- 1 file changed, 136 insertions(+), 3 deletions(-) diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index cbcb41003..f6ce693bc 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -25,9 +25,11 @@ use now_policy_api::{ }; 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, WIN32_ERROR, + 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, @@ -48,6 +50,9 @@ use crate::policy_store::validation; pub(super) const POLICY_FILE_NAME: &str = "package-broker-policy.json"; 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 { @@ -57,6 +62,15 @@ struct IoStatusBlock { #[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, @@ -732,8 +746,16 @@ fn verify_no_replace_collision( } Ok(()) => bail!("no-replace rename unexpectedly replaced an occupied destination"), } - verify_transaction_file_path(source, source_path)?; - verify_transaction_file_path(target, target_path)?; + 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, @@ -759,6 +781,70 @@ fn verify_no_replace_collision( }) } +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); @@ -4169,6 +4255,53 @@ mod tests { 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() } From 4d8b79a1f51d02c801c631ece4d6db0aa896c108 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 13:32:15 -0400 Subject: [PATCH 15/20] fix(agent): reject unsafe policy leaf states Inspect policy leaves without following reparses when their containing directory cannot be verified. Existing or ambiguous entries remain invalid, while only genuine absence is reported missing and non-writable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/windows.rs | 120 +++++++++++++----- 1 file changed, 86 insertions(+), 34 deletions(-) diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index f6ce693bc..0cdccf957 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -1410,40 +1410,7 @@ fn observe_impl( (PolicyWriteCapability::ReadOnly, PolicyReadOnlyReason::UnsafePath) } }; - // An insecure/unverifiable directory must never be trusted to host a policy - // -- but that alone does not mean there *is* a policy to distrust. If no - // leaf exists there at all, the correct state is Missing (nothing to - // activate or reject), not Invalid (which implies some untrusted content is - // actually present); capability is ReadOnly/Unsupported either way, since - // Create/Repair both still require a directory that passes verification. - // This is a best-effort existence probe only (on the literal configured - // path, since the directory itself could not be canonically verified): it - // never trusts, reads, or reports the leaf's content. - return match std::fs::metadata(configured_path) { - Err(io_error) if io_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, - }, - _ => invalid_observation( - configured_path, - validation::DiskFailureReason::Unreadable, - InvalidContext::default(), - write_capability, - Some(read_only_reason), - ), - }; + return observe_leaf_under_unverifiable_directory(configured_path, write_capability, read_only_reason); } }; let canonical_path = canonical_dir.join(leaf_name); @@ -1976,6 +1943,40 @@ fn observation_from_parts( } } +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`]. @@ -4800,6 +4801,57 @@ mod tests { 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(); From 43a54ca58c904552e7e5f5851a9f8a64c9a2f227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 14:00:22 -0400 Subject: [PATCH 16/20] fix(agent): watch canonical policy paths Watch the verified canonical custom policy path so alternate configured path representations do not delay reloads. Refresh complete watcher sets without gaps while preserving the original configured path for secure writes and authoritative re-observation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/mod.rs | 40 ++++++++++++- .../now-package-broker/src/policy_watcher.rs | 58 +++++++++++++++---- 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 293858fca..ecce33b91 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -293,7 +293,7 @@ impl PolicyStore { pub(crate) fn watched_paths(&self) -> Vec { match &self.default_paths { Some(paths) => paths.to_vec(), - None => vec![self.configured_path.clone()], + None => vec![self.snapshot().configured_path.clone()], } } @@ -673,6 +673,7 @@ struct TestStorage { 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)] @@ -685,6 +686,7 @@ impl TestStorage { 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()), } } @@ -696,6 +698,7 @@ impl TestStorage { 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()), } } @@ -728,19 +731,21 @@ impl PolicyStorage for TestStorage { fn create( &self, - _configured_path: &Path, + 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, + 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() @@ -1071,6 +1076,35 @@ mod storage_tests { ); } + #[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"); diff --git a/crates/now-package-broker/src/policy_watcher.rs b/crates/now-package-broker/src/policy_watcher.rs index 7940ac5db..d681d93c1 100644 --- a/crates/now-package-broker/src/policy_watcher.rs +++ b/crates/now-package-broker/src/policy_watcher.rs @@ -86,7 +86,7 @@ fn watch_directories(paths: &[PathBuf]) -> Vec { } enum WatcherCommand { - Refresh, + Refresh(Arc<[PathBuf]>), Stop, } @@ -128,8 +128,14 @@ fn build_watcher_set( Ok(set) } -fn replace_watcher_set(active: &mut T, replacement: Result) -> Result<(), E> { +fn replace_watcher_set( + active: &mut T, + active_paths: &mut Arc<[PathBuf]>, + replacement_paths: Arc<[PathBuf]>, + replacement: Result, +) -> Result<(), E> { *active = replacement?; + *active_paths = replacement_paths; Ok(()) } @@ -182,14 +188,14 @@ impl PolicyWatcher { ready: tokio::sync::oneshot::Sender>, ) { let store = self.0; - let paths: Arc<[PathBuf]> = store.watched_paths().into(); + 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_command_tx, watcher_command_rx) = std::sync::mpsc::channel(); let _watcher_handle = tokio::task::spawn_blocking(move || { - let mut watchers = match build_watcher_set(&paths, &change_tx, &failure_tx) { + 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"); @@ -197,13 +203,16 @@ impl PolicyWatcher { return; } }; + let mut active_paths = initial_paths; let _ = ready.send(Ok(())); while let Ok(command) = watcher_command_rx.recv() { match command { - WatcherCommand::Refresh => { + 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) { + if let Err((_failure, dir, error)) = + replace_watcher_set(&mut watchers, &mut active_paths, paths, replacement) + { error!(%error, path = %dir.display(), "Failed to extend policy directory monitoring"); } } @@ -224,13 +233,13 @@ impl PolicyWatcher { 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); + 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; - let _ = watcher_command_tx.send(WatcherCommand::Refresh); + let _ = watcher_command_tx.send(WatcherCommand::Refresh(store.watched_paths().into())); } Ok(false) => break None, Err(failure) => break Some(failure), @@ -305,6 +314,17 @@ 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"); @@ -368,15 +388,29 @@ mod tests { #[test] fn failed_refresh_keeps_the_active_watcher_set() { let mut active = vec!["common", "legacy", "managed"]; + let mut active_paths: Arc<[PathBuf]> = vec![PathBuf::from("old")].into(); + let replacement_paths: Arc<[PathBuf]> = vec![PathBuf::from("new")].into(); - let result = replace_watcher_set(&mut active, Err::, _>("registration failed")); + let result = replace_watcher_set( + &mut active, + &mut active_paths, + Arc::clone(&replacement_paths), + 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_paths, &[PathBuf::from("old")]); + + replace_watcher_set( + &mut active, + &mut active_paths, + Arc::clone(&replacement_paths), + Ok::<_, &str>(vec!["replacement"]), + ) + .expect("complete replacement set swaps successfully"); assert_eq!(active, ["replacement"]); + assert_eq!(&*active_paths, &*replacement_paths); } #[tokio::test] From 23e9d6bf28378989ffcb5dff5eeb0e1e92e51f12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 14:18:11 -0400 Subject: [PATCH 17/20] fix(agent): remove stale watcher state Keep only the live watcher handles during canonical path refresh. Replacement sets still register completely before assignment, so failed refreshes preserve the active monitoring set without unused path bookkeeping. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../now-package-broker/src/policy_watcher.rs | 36 ++++--------------- 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/crates/now-package-broker/src/policy_watcher.rs b/crates/now-package-broker/src/policy_watcher.rs index d681d93c1..88c1cedee 100644 --- a/crates/now-package-broker/src/policy_watcher.rs +++ b/crates/now-package-broker/src/policy_watcher.rs @@ -128,14 +128,8 @@ fn build_watcher_set( Ok(set) } -fn replace_watcher_set( - active: &mut T, - active_paths: &mut Arc<[PathBuf]>, - replacement_paths: Arc<[PathBuf]>, - replacement: Result, -) -> Result<(), E> { +fn replace_watcher_set(active: &mut T, replacement: Result) -> Result<(), E> { *active = replacement?; - *active_paths = replacement_paths; Ok(()) } @@ -203,16 +197,12 @@ impl PolicyWatcher { return; } }; - let mut active_paths = initial_paths; - 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, &mut active_paths, paths, replacement) - { + if let Err((_failure, dir, error)) = replace_watcher_set(&mut watchers, replacement) { error!(%error, path = %dir.display(), "Failed to extend policy directory monitoring"); } } @@ -388,29 +378,15 @@ mod tests { #[test] fn failed_refresh_keeps_the_active_watcher_set() { let mut active = vec!["common", "legacy", "managed"]; - let mut active_paths: Arc<[PathBuf]> = vec![PathBuf::from("old")].into(); - let replacement_paths: Arc<[PathBuf]> = vec![PathBuf::from("new")].into(); - let result = replace_watcher_set( - &mut active, - &mut active_paths, - Arc::clone(&replacement_paths), - Err::, _>("registration failed"), - ); + let result = replace_watcher_set(&mut active, Err::, _>("registration failed")); assert_eq!(result, Err("registration failed")); assert_eq!(active, ["common", "legacy", "managed"]); - assert_eq!(&*active_paths, &[PathBuf::from("old")]); - - replace_watcher_set( - &mut active, - &mut active_paths, - Arc::clone(&replacement_paths), - Ok::<_, &str>(vec!["replacement"]), - ) - .expect("complete replacement set swaps successfully"); + + replace_watcher_set(&mut active, Ok::<_, &str>(vec!["replacement"])) + .expect("complete replacement set swaps successfully"); assert_eq!(active, ["replacement"]); - assert_eq!(&*active_paths, &*replacement_paths); } #[tokio::test] From 9560fc7a68d16e5bc9095c1b8bd3477a37afc59e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 14:44:57 -0400 Subject: [PATCH 18/20] fix(agent): persist managed policy authority Create and verify durable protected evidence before managed default policy activation succeeds. Restarts then remain bound to managed storage after its final policy disappears, without treating an untouched empty managed directory as authoritative over legacy policy. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/mod.rs | 18 +- .../src/policy_store/windows.rs | 251 +++++++++++++++++- .../now-package-broker/src/policy_watcher.rs | 4 + 3 files changed, 269 insertions(+), 4 deletions(-) diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index ecce33b91..14253c351 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -35,12 +35,14 @@ trait PolicyStorage: Send + Sync { } fn create( &self, + source: PolicyConfigurationSource, configured_path: &Path, observation: &Observation, bytes: &[u8], ) -> Result; fn replace( &self, + source: PolicyConfigurationSource, configured_path: &Path, observation: &mut Observation, bytes: &[u8], @@ -70,6 +72,7 @@ impl PolicyStorage for FilePolicyStorage { fn create( &self, + source: PolicyConfigurationSource, configured_path: &Path, observation: &Observation, bytes: &[u8], @@ -84,11 +87,14 @@ impl PolicyStorage for FilePolicyStorage { &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: &mut Observation, bytes: &[u8], @@ -104,6 +110,8 @@ impl PolicyStorage for FilePolicyStorage { &observation.canonical_path, bytes, )?; + windows::ensure_published_managed_authority(source, configured_path, hosting_dir) + .map_err(WriteFailure::PostPublication)?; self.authoritative_reobserve(configured_path, bytes) } } @@ -436,9 +444,11 @@ impl PolicyStore { .map_err(|_| error_response(ErrorCode::InternalError, "failed to serialize the committed policy"))?; let persisted = if request.operation == PolicyReplacementOperation::Create { - self.storage.create(&write_configured_path, &observation, &bytes) + self.storage + .create(self.source, &write_configured_path, &observation, &bytes) } else { - self.storage.replace(&write_configured_path, &mut observation, &bytes) + self.storage + .replace(self.source, &write_configured_path, &mut observation, &bytes) }; let persisted = match persisted { Ok(persisted) => persisted, @@ -731,6 +741,7 @@ impl PolicyStorage for TestStorage { fn create( &self, + _source: PolicyConfigurationSource, configured_path: &Path, observation: &Observation, bytes: &[u8], @@ -741,6 +752,7 @@ impl PolicyStorage for TestStorage { fn replace( &self, + _source: PolicyConfigurationSource, configured_path: &Path, observation: &mut Observation, bytes: &[u8], @@ -899,6 +911,7 @@ mod storage_tests { fn create( &self, + _source: PolicyConfigurationSource, _configured_path: &Path, _observation: &Observation, _bytes: &[u8], @@ -908,6 +921,7 @@ mod storage_tests { fn replace( &self, + _source: PolicyConfigurationSource, _configured_path: &Path, _observation: &mut Observation, _bytes: &[u8], diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index 0cdccf957..9a60825a7 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -48,6 +48,8 @@ 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"; +const MANAGED_AUTHORITY_MARKER_CONTENT: &[u8] = b"Devolutions Package Broker managed policy authority v1\n"; const FILE_SYNCHRONIZE: u32 = 0x0010_0000; const FILE_RENAME_INFORMATION_EX_CLASS: i32 = 65; const FILE_NON_DIRECTORY_FILE: u32 = 0x0000_0040; @@ -120,6 +122,11 @@ fn managed_default_has_state(managed: &Path) -> bool { 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; }; @@ -166,6 +173,68 @@ fn managed_default_has_state(managed: &Path) -> bool { false } +fn verify_managed_authority_marker_if_present(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(false), + 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")?; + 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!( + read_file_from_start(&file)? == MANAGED_AUTHORITY_MARKER_CONTENT, + "managed authority marker content is invalid" + ); + Ok(true) +} + +fn ensure_managed_authority_marker(dir: &File, dir_path: &Path) -> anyhow::Result<()> { + if verify_managed_authority_marker_if_present(dir_path)? { + return Ok(()); + } + + let path = dir_path.join(MANAGED_AUTHORITY_MARKER_NAME); + let mut marker = match create_secure_transaction_file(&path) { + Ok(marker) => marker, + Err(create_error) => { + return match verify_managed_authority_marker_if_present(dir_path) { + Ok(true) => Ok(()), + Ok(false) => 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:#}" + )), + }; + } + }; + use std::io::Write as _; + marker + .write_all(MANAGED_AUTHORITY_MARKER_CONTENT) + .and_then(|()| marker.sync_all()) + .context("failed to persist managed authority marker")?; + verify_transaction_file_path(&marker, &path)?; + policy_security::verify_managed_policy_file_security(&marker) + .context("new managed authority marker security is invalid")?; + let identity = + policy_security::file_identity(&marker).context("failed to identify new managed authority marker")?; + _ = policy_security::security_state_digest(&marker) + .context("failed to summarize new managed authority marker security")?; + drop(marker); + verify_probe_directory_entry(dir, OsStr::new(MANAGED_AUTHORITY_MARKER_NAME), identity) + .context("managed authority marker directory entry is invalid")?; + Ok(()) +} + /// 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 @@ -444,6 +513,26 @@ impl VerifiedHostingDirectory { } } +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. /// @@ -1387,6 +1476,7 @@ fn observe_impl( 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), @@ -1492,6 +1582,32 @@ fn observe_impl( ); } }; + let authority_marker_present = if managed_default { + match verify_managed_authority_marker_if_present(&canonical_dir) { + Ok(present) => present, + 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(()) @@ -1541,6 +1657,7 @@ fn observe_impl( } }; + 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, @@ -1818,7 +1935,7 @@ fn observe_impl( // through the directory's own (already resolved) capability -- item 26. let retained_target = opened.retained_for_write.then_some(RetainedPolicyFile::Real(file)); - observation_from_parts( + let observation = observation_from_parts( &canonical_path, &content, VerifiedIdentity { @@ -1832,7 +1949,36 @@ fn observe_impl( 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, @@ -3282,6 +3428,29 @@ mod tests { 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, @@ -3335,6 +3504,84 @@ mod tests { 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()); + } + + #[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()); + } + fn open_deletable_test_file(path: &Path, content: &[u8]) -> File { std::fs::write(path, content).unwrap(); OpenOptions::new() diff --git a/crates/now-package-broker/src/policy_watcher.rs b/crates/now-package-broker/src/policy_watcher.rs index 88c1cedee..beb51d90a 100644 --- a/crates/now-package-broker/src/policy_watcher.rs +++ b/crates/now-package-broker/src/policy_watcher.rs @@ -328,6 +328,10 @@ mod tests { &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")), + &paths + )); assert!(!affects_watched_paths( &event(managed.with_file_name(".package-broker-write-probe-a.tmp")), &paths From 3719c370ca3fbd423af25d9086fd47fd02f29a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 15:02:10 -0400 Subject: [PATCH 19/20] docs(agent): clarify canonical path use Document the boundary between verified canonical paths used for storage and watching and the original configured path retained for authoritative re-observation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/policy_store/windows.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index 9a60825a7..5f9e33b64 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -1286,13 +1286,10 @@ pub(super) struct DiskObservation { pub fingerprint: DiskFingerprint, pub write_capability: PolicyWriteCapability, pub read_only_reason: Option, - /// Canonical resolved path (parent resolved from a verified handle, joined with the - /// exact configured `.json` leaf name; see item 22), or the best-effort literal - /// configured path when it could not be canonicalized at all (an unsupported shape, - /// or a directory/ancestor chain that failed verification before any handle could be - /// resolved). `PolicyStore` stores/displays/uses only this value from here on -- - /// never re-deriving it from the original configuration string -- for observation, - /// the watcher, the store token, audit, and writes. + /// 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. From bfdefdc0f9d1f16e61fae93bb16320f6af5945eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 15:33:08 -0400 Subject: [PATCH 20/20] fix(agent): atomically mark managed authority Use a versioned zero-length marker whose protected CREATE_NEW operation is already semantically complete. Existing valid markers remain authoritative, while invalid collisions are preserved and fail closed without staging or cleanup assumptions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/windows.rs | 86 ++++++++++++------- .../now-package-broker/src/policy_watcher.rs | 2 +- 2 files changed, 56 insertions(+), 32 deletions(-) diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index 5f9e33b64..dc3af059b 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -48,8 +48,7 @@ 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"; -const MANAGED_AUTHORITY_MARKER_CONTENT: &[u8] = b"Devolutions Package Broker managed policy authority v1\n"; +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; @@ -173,66 +172,66 @@ fn managed_default_has_state(managed: &Path) -> bool { false } -fn verify_managed_authority_marker_if_present(dir_path: &Path) -> anyhow::Result { +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(false), + 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")?; - policy_security::verify_policy_file_path(&file, &path).context("managed authority marker path is invalid")?; + 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, + policy_security::file_link_count(file)? == 1, "managed authority marker has multiple hard links" ); - policy_security::verify_managed_policy_file_security(&file) + 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) + _ = 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!( - read_file_from_start(&file)? == MANAGED_AUTHORITY_MARKER_CONTENT, - "managed authority marker content is invalid" - ); - Ok(true) + 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 verify_managed_authority_marker_if_present(dir_path)? { + if open_verified_managed_authority_marker(dir_path)?.is_some() { return Ok(()); } let path = dir_path.join(MANAGED_AUTHORITY_MARKER_NAME); - let mut marker = match create_secure_transaction_file(&path) { + let marker = match create_secure_transaction_file(&path) { Ok(marker) => marker, Err(create_error) => { - return match verify_managed_authority_marker_if_present(dir_path) { - Ok(true) => Ok(()), - Ok(false) => Err(create_error).context("failed to create managed authority marker"), + 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:#}" )), }; } }; - use std::io::Write as _; marker - .write_all(MANAGED_AUTHORITY_MARKER_CONTENT) - .and_then(|()| marker.sync_all()) + .sync_all() .context("failed to persist managed authority marker")?; - verify_transaction_file_path(&marker, &path)?; - policy_security::verify_managed_policy_file_security(&marker) - .context("new managed authority marker security is invalid")?; + verify_managed_authority_file(&marker, &path)?; let identity = policy_security::file_identity(&marker).context("failed to identify new managed authority marker")?; - _ = policy_security::security_state_digest(&marker) - .context("failed to summarize new managed authority marker security")?; drop(marker); verify_probe_directory_entry(dir, OsStr::new(MANAGED_AUTHORITY_MARKER_NAME), identity) - .context("managed authority marker directory entry is invalid")?; - Ok(()) + .context("managed authority marker directory entry is invalid") } /// Validate the *shape* of a configured policy path before ever touching disk: it must @@ -1580,8 +1579,8 @@ fn observe_impl( } }; let authority_marker_present = if managed_default { - match verify_managed_authority_marker_if_present(&canonical_dir) { - Ok(present) => present, + match open_verified_managed_authority_marker(&canonical_dir) { + Ok(marker) => marker.is_some(), Err(error) => { tracing::error!( path = %canonical_dir.display(), @@ -3533,6 +3532,13 @@ mod tests { 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] @@ -3579,6 +3585,24 @@ mod tests { 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() diff --git a/crates/now-package-broker/src/policy_watcher.rs b/crates/now-package-broker/src/policy_watcher.rs index beb51d90a..5f94dc5e5 100644 --- a/crates/now-package-broker/src/policy_watcher.rs +++ b/crates/now-package-broker/src/policy_watcher.rs @@ -329,7 +329,7 @@ mod tests { &paths )); assert!(affects_watched_paths( - &event(managed.with_file_name(".package-broker-managed-authority")), + &event(managed.with_file_name(".package-broker-managed-authority.v1")), &paths )); assert!(!affects_watched_paths(