diff --git a/Cargo.lock b/Cargo.lock index 84692d4bd..8e668cbb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4812,8 +4812,10 @@ dependencies = [ "devolutions-gateway-task", "hex", "hmac 0.12.1", + "http-body-util", "hyper 1.10.1", "hyper-util", + "mime", "notify 7.0.0", "now-policy", "now-policy-api", @@ -4837,9 +4839,9 @@ dependencies = [ [[package]] name = "now-policy" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27cd2de3c428b3b8570e7c399c85d6b9f0899930564b6f36c326116a7cd32068" +checksum = "14bf619b405ffb47839ee24baebe1866009d1aea49b112afe0da1465fda7e700" dependencies = [ "chrono", "schemars 0.9.0", @@ -4852,9 +4854,9 @@ dependencies = [ [[package]] name = "now-policy-api" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b9efe2ceede4cde6ce6d1da8e027513c075a0ea969a9e1a49a1178a456b295" +checksum = "8b61d66fd334d2dac6150d1ab83f3831ec4b0ee20272fb3386fbe5b5e31c6663" dependencies = [ "chrono", "derive_more", @@ -4869,9 +4871,9 @@ dependencies = [ [[package]] name = "now-policy-server-template" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5df8a2a4381c1f4223ffef311de1952eb78a00b30fd67625d3da89c5b58a74dc" +checksum = "fee165964d3b2dddfa2c6283b820d5cad337277d51365cf77e6b1376668f529d" dependencies = [ "aide 0.15.1", "async-trait", diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index b10896f51..26ac51f30 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -203,9 +203,8 @@ fn empty_policy() -> Value { fn policy_draft(id: &str, publisher: &str) -> Value { json!({ "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": id, "Publisher": publisher }, - "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Enforcement": { "DefaultDecision": "Deny" }, "Rules": [] }) } diff --git a/crates/now-package-broker/Cargo.toml b/crates/now-package-broker/Cargo.toml index daf063a88..662cbc510 100644 --- a/crates/now-package-broker/Cargo.toml +++ b/crates/now-package-broker/Cargo.toml @@ -31,12 +31,15 @@ hmac = "0.12" hyper = { version = "1", features = ["http1", "server"] } hyper-util = { version = "0.1", features = ["tokio", "server", "server-auto", "service"] } notify = { version = "7", default-features = false } -now-policy = "0.4" -now-policy-api = "0.5" -now-policy-server-template = "0.5" +http-body-util = "0.1" +mime = "0.3" +now-policy = "=0.5.0" +now-policy-api = "=0.6.0" +now-policy-server-template = "=0.6.0" parking_lot = "0.12" regex = "1" semver = "1" +serde = "1" serde_json = "1" sha2 = "0.10" tokio = { version = "1.52", features = ["net", "io-util", "rt", "macros", "parking_lot", "fs", "sync", "time"] } diff --git a/crates/now-package-broker/src/assets/samples/corporate-allowlist.policy.json b/crates/now-package-broker/src/assets/samples/corporate-allowlist.policy.json index e0da84422..b620e8ba9 100644 --- a/crates/now-package-broker/src/assets/samples/corporate-allowlist.policy.json +++ b/crates/now-package-broker/src/assets/samples/corporate-allowlist.policy.json @@ -1,6 +1,5 @@ { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.desktop.standard-allowlist", "Publisher": "Contoso IT", @@ -9,8 +8,7 @@ "Description": "Fail-closed policy for standard workstation package installs." }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "DefaultDecision": "Deny" }, "Rules": [ { @@ -24,9 +22,7 @@ "Install", "Update" ], - "SkipHashCheck": [ - true - ] + "SkipHashCheck": true } }, { @@ -36,9 +32,7 @@ "Decision": "Deny", "Reason": "Custom package-manager parameters are not allowed in the workstation allow list.", "Match": { - "HasCustomParameters": [ - true - ] + "HasCustomParameters": true } }, { @@ -48,9 +42,7 @@ "Decision": "Deny", "Reason": "Pre and post operation commands are not allowed in the workstation allow list.", "Match": { - "HasPrePostCommands": [ - true - ] + "HasPrePostCommands": true } }, { @@ -67,12 +59,11 @@ "Managers": [ "Winget" ], - "Sources": [ - "winget" - ], - "PackageIdentifiers": [ - "Microsoft.VisualStudioCode" - ], + "PackageIdentifiers": { + "Exact": [ + "Microsoft.VisualStudioCode" + ] + }, "Scopes": [ "User", "Machine" @@ -80,6 +71,9 @@ "Architectures": [ "X64", "Arm64" + ], + "SourceNames": [ + "winget" ] }, "Constraints": { @@ -105,12 +99,11 @@ "Managers": [ "Winget" ], - "Sources": [ - "winget" - ], - "PackageIdentifiers": [ - "Microsoft.PowerToys" - ], + "PackageIdentifiers": { + "Exact": [ + "Microsoft.PowerToys" + ] + }, "Scopes": [ "User", "Machine" @@ -118,6 +111,9 @@ "Architectures": [ "X64", "Arm64" + ], + "SourceNames": [ + "winget" ] }, "Constraints": { @@ -130,4 +126,4 @@ } } ] -} \ No newline at end of file +} diff --git a/crates/now-package-broker/src/assets/samples/deny-risky-options.policy.json b/crates/now-package-broker/src/assets/samples/deny-risky-options.policy.json index 105eebfe7..d68db9aa9 100644 --- a/crates/now-package-broker/src/assets/samples/deny-risky-options.policy.json +++ b/crates/now-package-broker/src/assets/samples/deny-risky-options.policy.json @@ -1,6 +1,5 @@ { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.desktop.deny-risky-options", "Publisher": "Contoso IT", @@ -9,8 +8,7 @@ "Description": "Default-allow policy that blocks risky broker request options." }, "Enforcement": { - "DefaultDecision": "Allow", - "RulePrecedence": "PriorityThenDeny" + "DefaultDecision": "Allow" }, "Rules": [ { @@ -23,9 +21,7 @@ "Install", "Update" ], - "SkipHashCheck": [ - true - ] + "SkipHashCheck": true } }, { @@ -34,9 +30,7 @@ "Decision": "Deny", "Reason": "Custom package-manager parameters require a dedicated exception policy.", "Match": { - "HasCustomParameters": [ - true - ] + "HasCustomParameters": true } }, { @@ -45,9 +39,7 @@ "Decision": "Deny", "Reason": "Pre and post operation commands are outside the package manager trust boundary.", "Match": { - "HasPrePostCommands": [ - true - ] + "HasPrePostCommands": true } }, { @@ -56,9 +48,7 @@ "Decision": "Deny", "Reason": "Killing processes before a brokered package operation is not allowed by this policy.", "Match": { - "HasKillBeforeOperation": [ - true - ] + "HasKillBeforeOperation": true } }, { @@ -70,11 +60,11 @@ "Managers": [ "Winget" ], - "Sources": [ + "SourceNames": [ "msstore", "winget-fonts" ] } } ] -} \ No newline at end of file +} diff --git a/crates/now-package-broker/src/evaluator/matching.rs b/crates/now-package-broker/src/evaluator/matching.rs index b76566aa3..4ee5301a9 100644 --- a/crates/now-package-broker/src/evaluator/matching.rs +++ b/crates/now-package-broker/src/evaluator/matching.rs @@ -2,12 +2,13 @@ use std::collections::BTreeSet; -use now_policy::{Architecture, Elevation, ManagerName, Operation, PolicyRule, Scope}; +use now_policy::{ + Architecture, Elevation, ManagerName, Operation, PackageIdentifierCondition, PolicyRule, Scope, VersionCondition, +}; use now_policy_api::PackageRequest; use super::RequestFlags; use super::constraints::constraints_pass; -use super::version::version_range_matches; use super::wildcard::wildcard_any; pub(super) fn rule_matches( @@ -20,22 +21,20 @@ pub(super) fn rule_matches( operations_match(request.operation, &m.operations) && managers_match(request.manager, &m.managers) - && wildcard_any(&request.source.name, &m.sources) - && wildcard_any(&request.package.id, &m.package_identifiers) - && m.package_names.is_empty() - && string_in_set(effective_version, &m.versions) - && version_range_matches(effective_version, &m.version_range) + && source_names_match(&request.source.name, &m.source_names) + && package_identifiers_match(&request.package.id, &m.package_identifiers) + && versions_match(effective_version, &m.version) && scopes_match(request.options.scope, &m.scopes) && architectures_match(request.package.architecture, &m.architectures) - && elevation_match(request.client.requested_elevation, &m.elevation) - && bool_in_set(request.options.interactive, &m.interactive) - && bool_in_set(request.options.skip_hash_check, &m.skip_hash_check) - && bool_in_set(request.options.pre_release, &m.pre_release) - && bool_in_set(flags.has_custom_parameters, &m.has_custom_parameters) - && bool_in_set(flags.has_custom_install_location, &m.has_custom_install_location) - && bool_in_set(flags.has_pre_post_commands, &m.has_pre_post_commands) - && bool_in_set(flags.has_kill_before_operation, &m.has_kill_before_operation) - && bool_in_set(flags.has_uninstall_previous, &m.has_uninstall_previous) + && elevation_match(super::effective_execution_elevation(request), &m.execution_elevation) + && optional_bool_matches(request.options.interactive, m.interactive) + && optional_bool_matches(request.options.skip_hash_check, m.skip_hash_check) + && optional_bool_matches(request.options.pre_release, m.pre_release) + && optional_bool_matches(flags.has_custom_parameters, m.has_custom_parameters) + && optional_bool_matches(flags.has_custom_install_location, m.has_custom_install_location) + && optional_bool_matches(flags.has_pre_post_commands, m.has_pre_post_commands) + && optional_bool_matches(flags.has_kill_before_operation, m.has_kill_before_operation) + && optional_bool_matches(flags.has_uninstall_previous, m.has_uninstall_previous) && constraints_pass(&rule.constraints, request, flags) } @@ -130,25 +129,41 @@ fn elevation_match(elevation: now_policy_api::Elevation, allowed: &BTreeSet) -> bool { - set.is_empty() || set.contains(&value) +fn source_names_match(value: &str, allowed: &BTreeSet) -> bool { + allowed.is_empty() || allowed.iter().any(|source| source.as_ref().eq_ignore_ascii_case(value)) } -fn string_in_set>(value: &str, set: &BTreeSet) -> bool { - if set.is_empty() { - return true; +fn package_identifiers_match( + value: &now_policy_api::PackageIdentifier, + condition: &Option, +) -> bool { + match condition { + None => true, + Some(PackageIdentifierCondition::Exact(identifiers)) => { + identifiers.iter().any(|identifier| identifier.as_ref() == value.0) + } + Some(PackageIdentifierCondition::Patterns(patterns)) => wildcard_any(&value.0, patterns), } - if value.is_empty() { - // If no version specified and set requires specific versions, don't match. - return false; +} + +fn versions_match(value: &str, condition: &Option) -> bool { + match condition { + None => true, + Some(VersionCondition::Exact(versions)) => { + !value.is_empty() && versions.iter().any(|version| version.0 == value) + } + Some(VersionCondition::Range(range)) => super::version::version_range_matches(value, range), } - set.iter().any(|item| item.as_ref() == value) +} + +fn optional_bool_matches(value: bool, expected: Option) -> bool { + expected.is_none_or(|expected| expected == value) } #[cfg(test)] mod tests { use chrono::Utc; - use now_policy::{Decision, PolicyMatch, ResourceId, StringPattern}; + use now_policy::{Decision, PackageIdentifierCondition, PolicyMatch, ResourceId, StringPattern}; use now_policy_api as api; use super::*; @@ -225,8 +240,10 @@ mod tests { assert!(matches(PolicyMatch { operations: BTreeSet::from([Operation::Install]), managers: BTreeSet::from([ManagerName::Winget]), - sources: BTreeSet::from([StringPattern("winget".to_owned())]), - package_identifiers: BTreeSet::from([StringPattern("Microsoft.*Code".to_owned())]), + source_names: BTreeSet::from([now_policy::SourceName::parse("winget").expect("valid source")]), + package_identifiers: Some(PackageIdentifierCondition::Patterns(BTreeSet::from([StringPattern( + "Microsoft.*Code".to_owned(), + )]))), ..Default::default() })); @@ -236,6 +253,15 @@ mod tests { })); } + #[test] + fn source_names_are_exact_not_wildcard_patterns() { + assert!(!matches(PolicyMatch { + managers: BTreeSet::from([ManagerName::Winget]), + source_names: BTreeSet::from([now_policy::SourceName::parse("wing*").expect("valid source")]), + ..Default::default() + })); + } + #[test] fn absent_scope_or_architecture_in_request_fails_when_rule_restricts_them() { let mut request = request(); @@ -252,20 +278,40 @@ mod tests { } #[test] - fn package_name_criteria_fail_closed_until_request_contains_display_name() { - assert!(!matches(PolicyMatch { - package_names: BTreeSet::from([StringPattern("Visual Studio Code".to_owned())]), + fn boolean_flags_match_request_options() { + let mut request = request(); + request.options.interactive = true; + let flags = RequestFlags::from_request(&request); + let rule = rule(PolicyMatch { + interactive: Some(true), ..Default::default() - })); + }); + + assert!(rule_matches(&rule, &request, &flags, "1.2.3")); } #[test] - fn boolean_flags_match_request_options() { + fn machine_scope_uses_effective_elevated_execution_privilege() { let mut request = request(); - request.options.interactive = true; + request.client.requested_elevation = api::Elevation::Standard; + request.options.scope = Some(api::Scope::Machine); + let flags = RequestFlags::from_request(&request); + let rule = rule(PolicyMatch { + execution_elevation: BTreeSet::from([Elevation::Elevated]), + ..Default::default() + }); + + assert!(rule_matches(&rule, &request, &flags, "1.2.3")); + } + + #[test] + fn user_scope_without_requested_elevation_uses_standard_execution_privilege() { + let mut request = request(); + request.client.requested_elevation = api::Elevation::Standard; + request.options.scope = Some(api::Scope::User); let flags = RequestFlags::from_request(&request); let rule = rule(PolicyMatch { - interactive: BTreeSet::from([true]), + execution_elevation: BTreeSet::from([Elevation::Standard]), ..Default::default() }); diff --git a/crates/now-package-broker/src/evaluator/mod.rs b/crates/now-package-broker/src/evaluator/mod.rs index 127cf8ffe..d0376caf8 100644 --- a/crates/now-package-broker/src/evaluator/mod.rs +++ b/crates/now-package-broker/src/evaluator/mod.rs @@ -6,7 +6,7 @@ //! 3. Fall back to `enforcement.defaultDecision` use now_policy::{Decision, PolicyDocument}; -use now_policy_api::PackageRequest; +use now_policy_api::{Elevation, PackageRequest, Scope}; mod constraints; mod matching; @@ -112,3 +112,11 @@ pub fn evaluate(policy: &PolicyDocument, request: &PackageRequest) -> PolicyDeci reason: winner.3.to_owned(), } } + +pub(crate) fn effective_execution_elevation(request: &PackageRequest) -> Elevation { + if request.options.scope == Some(Scope::Machine) || request.client.requested_elevation == Elevation::Elevated { + Elevation::Elevated + } else { + Elevation::Standard + } +} diff --git a/crates/now-package-broker/src/evaluator/tests.rs b/crates/now-package-broker/src/evaluator/tests.rs index 9ea53c79f..8b6776a08 100644 --- a/crates/now-package-broker/src/evaluator/tests.rs +++ b/crates/now-package-broker/src/evaluator/tests.rs @@ -4,8 +4,8 @@ use std::collections::BTreeSet; use chrono::Utc; use now_policy::{ - Decision, PackageBrokerPolicy, PolicyDocument, PolicyEnforcement, PolicyFormatVersion, PolicyMatch, PolicyMetadata, - PolicyRule, ResourceId, RulePrecedence, StringPattern, + Decision, PackageIdentifier, PackageIdentifierCondition, PolicyDocument, PolicyEnforcement, PolicyFormatVersion, + PolicyMatch, PolicyMetadata, PolicyRule, ResourceId, }; use now_policy_api::{self as api, PackageRequest}; @@ -14,7 +14,6 @@ use super::evaluate; fn make_policy(default_decision: Decision, rules: Vec) -> PolicyDocument { PolicyDocument { policy_format_version: PolicyFormatVersion::current(), - policy_type: PackageBrokerPolicy, metadata: PolicyMetadata { id: ResourceId::from("test-policy"), publisher: "Test".to_owned(), @@ -27,7 +26,6 @@ fn make_policy(default_decision: Decision, rules: Vec) -> PolicyDocu }, enforcement: PolicyEnforcement { default_decision, - rule_precedence: RulePrecedence::PriorityThenDeny, audit_mode: None, }, rules, @@ -89,7 +87,9 @@ fn allow_matching_package() { decision: Decision::Allow, reason: Some("Firefox is allowed.".to_owned()), match_criteria: PolicyMatch { - package_identifiers: BTreeSet::from([StringPattern("Mozilla.Firefox".to_owned())]), + package_identifiers: Some(PackageIdentifierCondition::Exact(BTreeSet::from([ + PackageIdentifier::parse("Mozilla.Firefox").expect("valid identifier"), + ]))), ..Default::default() }, constraints: None, @@ -113,7 +113,9 @@ fn deny_unmatched_package() { decision: Decision::Allow, reason: None, match_criteria: PolicyMatch { - package_identifiers: BTreeSet::from([StringPattern("Mozilla.Firefox".to_owned())]), + package_identifiers: Some(PackageIdentifierCondition::Exact(BTreeSet::from([ + PackageIdentifier::parse("Mozilla.Firefox").expect("valid identifier"), + ]))), ..Default::default() }, constraints: None, @@ -137,7 +139,9 @@ fn disabled_rules_are_ignored() { decision: Decision::Allow, reason: None, match_criteria: PolicyMatch { - package_identifiers: BTreeSet::from([StringPattern("Some.Package".to_owned())]), + package_identifiers: Some(PackageIdentifierCondition::Exact(BTreeSet::from([ + PackageIdentifier::parse("Some.Package").expect("valid identifier"), + ]))), ..Default::default() }, constraints: None, diff --git a/crates/now-package-broker/src/evaluator/version.rs b/crates/now-package-broker/src/evaluator/version.rs index f0e9185ef..3e652afc2 100644 --- a/crates/now-package-broker/src/evaluator/version.rs +++ b/crates/now-package-broker/src/evaluator/version.rs @@ -11,10 +11,7 @@ pub(super) fn get_effective_version(request: &PackageRequest) -> String { } } -pub(super) fn version_range_matches(version: &str, range: &Option) -> bool { - let Some(range) = range else { - return true; - }; +pub(super) fn version_range_matches(version: &str, range: &VersionRange) -> bool { if version.is_empty() { return false; } @@ -52,18 +49,14 @@ pub(super) fn version_range_matches(version: &str, range: &Option) mod tests { use super::*; - fn range(min: Option<&str>, max: Option<&str>, include_prerelease: bool) -> Option { - Some(VersionRange { - min_version: min.map(ToOwned::to_owned), - max_version: max.map(ToOwned::to_owned), + fn range(min: Option<&str>, max: Option<&str>, include_prerelease: bool) -> VersionRange { + VersionRange { + min_version: min + .map(|version| now_policy::SemanticVersion::parse(version).expect("valid semantic version")), + max_version: max + .map(|version| now_policy::SemanticVersion::parse(version).expect("valid semantic version")), include_prerelease, - }) - } - - #[test] - fn absent_range_accepts_empty_or_present_versions() { - assert!(version_range_matches("", &None)); - assert!(version_range_matches("1.2.3", &None)); + } } #[test] @@ -102,8 +95,7 @@ mod tests { } #[test] - fn invalid_versions_fail_closed_when_range_is_configured() { + fn nonsemantic_requested_versions_fail_closed_when_range_is_configured() { assert!(!version_range_matches("1.2", &range(Some("1.0.0"), None, false))); - assert!(!version_range_matches("1.2.3", &range(Some("1.0"), None, false))); } } diff --git a/crates/now-package-broker/src/policy_loader.rs b/crates/now-package-broker/src/policy_loader.rs index 286df2692..ef7b6f6cc 100644 --- a/crates/now-package-broker/src/policy_loader.rs +++ b/crates/now-package-broker/src/policy_loader.rs @@ -2,105 +2,34 @@ //! //! Loads policy documents from the configured directory. //! Supports JSON (`.json`) policies. -//! Default location: `%PROGRAMDATA%/Devolutions/Agent/` +//! Policies use `%PROGRAMDATA%/Devolutions/PackageBroker/`. -use std::io::Read as _; use std::path::{Path, PathBuf}; -use now_policy::PolicyDocument; -use now_policy::schema::parse_policy_json; -use tracing::info; - -use crate::policy_security; +fn program_data_dir() -> PathBuf { + std::env::var_os("PROGRAMDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")) +} -/// Default policy directory. +/// Directory used by the policy store. 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") - } + program_data_dir().join("Devolutions").join("PackageBroker") } /// Base name for the policy file (without extension). const POLICY_FILE_BASE: &str = "package-broker-policy"; -/// Load a policy document from a file path. -/// -/// The file must use the `.json` extension. -/// -/// Deserialization performs all validation (structure, types, length constraints, patterns). -/// -/// Before trusting the policy, the file's owner and DACL are verified to restrict write -/// access to SYSTEM/Administrators. -/// This function fails when the check does not pass, so the broker pauses (fail-closed). -pub fn load_policy(path: &Path) -> anyhow::Result { - let mut file = std::fs::File::open(path) - .map_err(|e| anyhow::anyhow!("failed to open policy file at {}: {e}", path.display()))?; - - // Verify security on the open handle (not the path), and read from the same handle, - // so the verified security descriptor belongs to the very same file being parsed. - policy_security::verify_policy_file_security(&file) - .map_err(|e| anyhow::anyhow!("policy file at {} failed security validation: {e}", path.display()))?; - - let mut content = String::new(); - file.read_to_string(&mut content) - .map_err(|e| anyhow::anyhow!("failed to read policy file at {}: {e}", path.display()))?; - - let policy = deserialize_policy(&content, path)?; - - info!( - policy_id = %policy.metadata.id, - revision = policy.metadata.revision, - rules_count = policy.rules.len(), - "Loaded policy" - ); - - Ok(policy) +/// Canonical policy path used when no explicit path is configured. +pub fn default_policy_path() -> PathBuf { + default_policy_path_in(&program_data_dir()) } -/// Deserialize JSON policy content. -fn deserialize_policy(content: &str, path: &Path) -> anyhow::Result { - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - - if ext != "json" { - anyhow::bail!( - "unsupported policy file extension at {}; expected .json", - path.display() - ); - } - - parse_policy_json(content).map_err(|e| anyhow::anyhow!("invalid JSON policy at {}: {e}", path.display())) -} - -/// Find the policy file in the default location. -/// -/// Looks for `package-broker-policy.json`. -pub fn find_default_policy() -> anyhow::Result { - let dir = default_policy_dir(); - if let Some(path) = find_default_policy_in(&dir) { - return Ok(path); - } - - anyhow::bail!( - "policy file not found in {}; create package-broker-policy.json to enable the broker", - dir.display() - ) -} - -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. -pub fn default_policy_candidate() -> PathBuf { - default_policy_dir().join(format!("{POLICY_FILE_BASE}.json")) +fn default_policy_path_in(program_data: &Path) -> PathBuf { + program_data + .join("Devolutions") + .join("PackageBroker") + .join(format!("{POLICY_FILE_BASE}.json")) } #[cfg(test)] @@ -108,41 +37,27 @@ mod tests { use super::*; #[test] - fn json_policy_is_supported() { - deserialize_policy( - include_str!("assets/samples/corporate-allowlist.policy.json"), - Path::new("policy.json"), - ) - .expect("deserialize JSON policy"); - } - - #[test] - fn yaml_policy_extension_is_rejected() { - for path in ["policy.yaml", "policy.yml"] { - let error = deserialize_policy("Rules: []", Path::new(path)).expect_err("reject YAML policy extension"); - assert_eq!( - error.to_string(), - format!("unsupported policy file extension at {path}; expected .json") - ); - } - } - - #[test] - fn yaml_content_with_json_extension_is_rejected() { - let error = deserialize_policy("Rules: []", Path::new("policy.json")).expect_err("reject YAML policy content"); - assert!(error.to_string().starts_with("invalid JSON policy at policy.json:")); - } - - #[test] - fn default_discovery_ignores_yaml_policy_files() { - let dir = tempfile::tempdir().expect("create temporary policy directory"); - std::fs::write(dir.path().join("package-broker-policy.yaml"), "Rules: []").expect("write YAML policy"); - std::fs::write(dir.path().join("package-broker-policy.yml"), "Rules: []").expect("write YML policy"); - - assert_eq!(find_default_policy_in(dir.path()), None); - - let json_path = dir.path().join("package-broker-policy.json"); - std::fs::write(&json_path, "{}").expect("write JSON policy"); - assert_eq!(find_default_policy_in(dir.path()), Some(json_path)); + fn default_path_uses_the_canonical_managed_directory() { + let root = tempfile::tempdir().expect("create policy root"); + let other = root + .path() + .join("Devolutions") + .join("Agent") + .join("package-broker-policy.json"); + std::fs::create_dir_all(other.parent().expect("other path has a parent")).expect("create other directory"); + std::fs::write(&other, "{}").expect("write other policy"); + let path = default_policy_path_in(root.path()); + + assert_eq!( + path.file_name().expect("default path has a leaf"), + "package-broker-policy.json" + ); + assert_eq!( + path.parent() + .and_then(Path::file_name) + .expect("default path has a parent"), + "PackageBroker" + ); + assert_ne!(path, other); } } diff --git a/crates/now-package-broker/src/policy_security.rs b/crates/now-package-broker/src/policy_security.rs index d4a68cf38..f0e6140a7 100644 --- a/crates/now-package-broker/src/policy_security.rs +++ b/crates/now-package-broker/src/policy_security.rs @@ -1,7 +1,8 @@ //! 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 three trust boundaries in the package broker: +//! - The 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 +16,9 @@ //! 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 policy store accepts only SYSTEM and built-in Administrators. +//! Executable checks also accept `NT SERVICE\TrustedInstaller` for Windows-protected +//! binaries under locations such as `System32`, `Program Files`, and `WindowsApps`. //! //! For elevated executables the verification additionally defends against //! time-of-check/time-of-use races: the file is opened without write or delete sharing @@ -28,32 +28,36 @@ //! 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::process::Process; +use win_api_wrappers::security::acl::{Acl, InheritableAcl, InheritableAclKind}; +use win_api_wrappers::security::attributes::{SecurityAttributes, SecurityAttributesInit}; use windows::Win32::Foundation::{ ERROR_PATH_NOT_FOUND, ERROR_SUCCESS, GENERIC_ALL, GENERIC_WRITE, HANDLE, HLOCAL, LocalFree, }; use windows::Win32::Globalization::{CSTR_EQUAL, CompareStringOrdinal}; use windows::Win32::Security::Authorization::{ConvertSidToStringSidW, GetSecurityInfo, SE_FILE_OBJECT}; use windows::Win32::Security::{ - ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, DACL_SECURITY_INFORMATION, GetAce, INHERIT_ONLY_ACE, IsWellKnownSid, - OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, WinBuiltinAdministratorsSid, WinLocalServiceSid, + ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, DACL_SECURITY_INFORMATION, GetAce, GetLengthSid, INHERIT_ONLY_ACE, + IsWellKnownSid, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, TOKEN_QUERY, WinBuiltinAdministratorsSid, WinLocalSystemSid, }; use windows::Win32::Storage::FileSystem::{ DELETE, FILE_APPEND_DATA, FILE_ATTRIBUTE_REPARSE_POINT, FILE_ATTRIBUTE_TAG_INFO, FILE_DELETE_CHILD, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_NAME_NORMALIZED, FILE_READ_ATTRIBUTES, - FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_WRITE_ATTRIBUTES, FILE_WRITE_DATA, FILE_WRITE_EA, - FileAttributeTagInfo, GETFINALPATHNAMEBYHANDLE_FLAGS, GetFileInformationByHandleEx, GetFinalPathNameByHandleW, - READ_CONTROL, VOLUME_NAME_GUID, WRITE_DAC, WRITE_OWNER, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, FILE_WRITE_ATTRIBUTES, FILE_WRITE_DATA, + FILE_WRITE_EA, FileAttributeTagInfo, GETFINALPATHNAMEBYHANDLE_FLAGS, GetFileInformationByHandleEx, + GetFinalPathNameByHandleW, READ_CONTROL, VOLUME_NAME_GUID, WRITE_DAC, WRITE_OWNER, }; use windows::core::PWSTR; @@ -106,15 +110,13 @@ const TRUSTED_INSTALLER_SID: &str = "S-1-5-80-956008885-3418522649-1831038044-18 /// Principals trusted to hold write access over a verified file. #[derive(Clone, Copy, PartialEq, Eq)] 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). @@ -160,23 +162,9 @@ impl Drop for OwnedSecurityDescriptor { } } -/// Verify that the policy file may only be written by SYSTEM, `LOCAL SERVICE`, or -/// built-in Administrators. -/// -/// The check is performed on the already-opened file handle so the verified security -/// descriptor belongs to the very same file that is subsequently read (no TOCTOU window -/// via file replacement). -/// -/// Rules (fail-closed): -/// - The owner must be a trusted principal. -/// - A DACL must be present (a NULL DACL grants everyone full control). -/// - Every access-allowed ACE granting write access must have a trusted principal as -/// the trustee (inherit-only ACEs are skipped, since they do not apply to the object; -/// callback allow ACEs are treated as unconditional allow ACEs, since their condition -/// can only narrow the grant). -/// - Unsupported (object) access-allowed ACE types are rejected. -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. @@ -184,20 +172,18 @@ pub(crate) fn verify_policy_directory_security(directory: &File) -> anyhow::Resu 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 +204,107 @@ pub(crate) fn verify_policy_file_path(file: &File, path: &Path) -> anyhow::Resul /// Compare Windows paths using the operating system's ordinal case folding. pub(crate) fn windows_paths_equal(left: &Path, right: &Path) -> bool { - let left: Vec = left.as_os_str().encode_wide().collect(); - let right: Vec = right.as_os_str().encode_wide().collect(); + os_strings_match_case_insensitive(left.as_os_str(), right.as_os_str()) +} + +pub(crate) fn paths_match_case_insensitive(left: &Path, right: &Path) -> bool { + windows_paths_equal(left, right) +} + +pub(crate) fn os_strings_match_case_insensitive(left: &OsStr, right: &OsStr) -> bool { + let left: Vec = left.encode_wide().collect(); + let right: Vec = right.encode_wide().collect(); // SAFETY: Both slices contain valid, initialized UTF-16 code units. unsafe { CompareStringOrdinal(&left, &right, true) == CSTR_EQUAL } } +/// Verify and summarize retained policy ancestors in root-to-leaf order. +/// +/// Ordered file identities define the path without encoding path text. +/// This avoids case normalization and preserves ill-formed UTF-16 path semantics. +pub(crate) fn verified_policy_ancestor_digest(handles: &[File], subject: &str) -> anyhow::Result<[u8; 32]> { + let mut levels = Vec::with_capacity(handles.len()); + + for (index, handle) in handles.iter().enumerate() { + let dir_subject = format!("{subject} ancestor level {index}"); + if is_reparse_point(handle).with_context(|| format!("failed to inspect {dir_subject}"))? { + bail!("{dir_subject} is a reparse point"); + } + let attributes = handle + .metadata() + .with_context(|| format!("failed to query metadata for {dir_subject}"))? + .file_attributes(); + if attributes & windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_DIRECTORY.0 == 0 { + bail!("{dir_subject} is not a directory"); + } + verify_policy_ancestor_directory_security(handle, &dir_subject)?; + let security_digest = + security_state_digest(handle).with_context(|| format!("failed to digest {dir_subject} security"))?; + levels.push(( + file_identity(handle).with_context(|| format!("failed to identify {dir_subject}"))?, + security_digest, + )); + } + + Ok(canonical_ancestor_digest(&levels)) +} + +fn canonical_ancestor_digest(levels: &[(FileIdentity, [u8; 32])]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"devolutions-policy-ancestor-digest-v2\0"); + hasher.update( + u32::try_from(levels.len()) + .expect("ancestor count fits u32") + .to_le_bytes(), + ); + for &(identity, security_digest) in levels { + update_ancestor_level_digest(&mut hasher, identity, security_digest); + } + hasher.finalize().into() +} + +fn update_ancestor_level_digest(hasher: &mut Sha256, identity: FileIdentity, security_digest: [u8; 32]) { + hasher.update(identity.volume_serial.to_le_bytes()); + hasher.update(identity.file_id); + hasher.update(security_digest); +} + +#[cfg(test)] +pub(crate) fn test_ancestor_digest(identity: FileIdentity, security_digest: [u8; 32]) -> [u8; 32] { + canonical_ancestor_digest(&[(identity, security_digest)]) +} + +/// Open and retain every existing lexical component in a policy directory chain. +pub(crate) fn retain_policy_no_reparse_directory_chain(dir: &Path, subject: &str) -> anyhow::Result> { + let mut components: Vec<&Path> = dir.ancestors().filter(|path| !path.as_os_str().is_empty()).collect(); + components.reverse(); + let mut handles = Vec::with_capacity(components.len()); + + for component in components { + let component_subject = format!("{subject} component '{}'", component.display()); + let handle = OpenOptions::new() + .access_mode((FILE_READ_ATTRIBUTES | FILE_TRAVERSE | READ_CONTROL).0) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE).0) + .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(component) + .with_context(|| format!("failed to open {component_subject}"))?; + + if is_reparse_point(&handle).with_context(|| format!("failed to inspect {component_subject}"))? { + bail!("{component_subject} is a reparse point"); + } + let attributes = handle + .metadata() + .with_context(|| format!("failed to query metadata for {component_subject}"))? + .file_attributes(); + if attributes & windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_DIRECTORY.0 == 0 { + bail!("{component_subject} is not a directory"); + } + handles.push(handle); + } + + Ok(handles) +} + /// Digest verified owner and DACL state for opaque policy-store fingerprints. pub(crate) fn security_state_digest(file: &File) -> anyhow::Result<[u8; 32]> { let handle = HANDLE(file.as_raw_handle()); @@ -248,20 +329,20 @@ pub(crate) fn security_state_digest(file: &File) -> anyhow::Result<[u8; 32]> { bail!("failed to read policy security state: error {}", status.0); } - let mut hasher = Sha256::new(); - if owner.0.is_null() { - hasher.update(b"no-owner"); + let owner_bytes = if owner.0.is_null() { + None } else { // SAFETY: The owner SID points into the live security descriptor. - let owner = unsafe { sid_to_string(owner) }; - hasher.update(owner.as_bytes()); - } - if dacl.is_null() { - hasher.update(b"null-dacl"); + let length = usize::try_from(unsafe { GetLengthSid(owner) }).expect("SID length fits usize"); + // SAFETY: GetLengthSid returned the complete size of the SID in the live descriptor. + Some(unsafe { std::slice::from_raw_parts(owner.0.cast::(), length) }) + }; + let ace_bytes = if dacl.is_null() { + None } else { // SAFETY: The DACL points into the live security descriptor. let ace_count = u32::from(unsafe { (*dacl).AceCount }); - hasher.update(ace_count.to_le_bytes()); + let mut entries = Vec::with_capacity(usize::try_from(ace_count).expect("ACE count fits usize")); for index in 0..ace_count { let mut ace: *mut core::ffi::c_void = std::ptr::null_mut(); // SAFETY: The index is within the DACL's reported ACE count. @@ -269,10 +350,208 @@ pub(crate) fn security_state_digest(file: &File) -> anyhow::Result<[u8; 32]> { // SAFETY: GetAce returned a complete ACE beginning with ACE_HEADER. let size = usize::from(unsafe { (*ace.cast::()).AceSize }); // SAFETY: AceSize bounds the complete ACE within the validated ACL. - hasher.update(unsafe { std::slice::from_raw_parts(ace.cast::(), size) }); + entries.push(unsafe { std::slice::from_raw_parts(ace.cast::(), size) }); + } + Some(entries) + }; + Ok(canonical_security_digest(owner_bytes, ace_bytes.as_deref())) +} + +fn canonical_security_digest(owner: Option<&[u8]>, dacl: Option<&[&[u8]]>) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"devolutions-policy-security-digest-v1\0"); + update_optional_bytes(&mut hasher, owner); + match dacl { + Some(entries) => { + hasher.update([1]); + hasher.update(u32::try_from(entries.len()).expect("ACE count fits u32").to_le_bytes()); + for entry in entries { + update_fixed_width_bytes(&mut hasher, entry); + } + } + None => hasher.update([0]), + } + hasher.finalize().into() +} + +fn update_optional_bytes(hasher: &mut Sha256, bytes: Option<&[u8]>) { + match bytes { + Some(bytes) => { + hasher.update([1]); + update_fixed_width_bytes(hasher, bytes); } + None => hasher.update([0]), + } +} + +fn update_fixed_width_bytes(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update( + u32::try_from(bytes.len()) + .expect("security component length fits u32") + .to_le_bytes(), + ); + hasher.update(bytes); +} + +fn admin_only_acl(inheritance: windows::Win32::Security::ACE_FLAGS) -> anyhow::Result { + use win_api_wrappers::security::acl::{ExplicitAccess, Trustee}; + use windows::Win32::Security::Authorization::GRANT_ACCESS; + + let system = Sid::from_well_known(WinLocalSystemSid, None).context("resolve SYSTEM SID")?; + let admins = Sid::from_well_known(WinBuiltinAdministratorsSid, None).context("resolve Administrators SID")?; + + Acl::new() + .context("initialize ACL")? + .set_entries(&[ + ExplicitAccess { + access_permissions: GENERIC_ALL.0, + access_mode: GRANT_ACCESS, + inheritance, + trustee: Trustee::Sid(system), + }, + ExplicitAccess { + access_permissions: GENERIC_ALL.0, + access_mode: GRANT_ACCESS, + inheritance, + trustee: Trustee::Sid(admins), + }, + ]) + .context("build admin-only ACL") +} + +fn admin_only_security_attributes_for_owner( + owner: Sid, + 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 acl = admin_only_acl(inheritance)?; + + Ok(SecurityAttributesInit { + owner: Some(owner), + dacl: Some(InheritableAcl { + kind: InheritableAclKind::Protected, + acl, + }), + ..Default::default() + } + .init()) +} + +pub(crate) fn admin_only_security_attributes(inherit_to_children: bool) -> anyhow::Result { + let owner = Sid::from_well_known(WinLocalSystemSid, None).context("resolve SYSTEM SID")?; + admin_only_security_attributes_for_owner(owner, inherit_to_children) +} + +/// Build protected transaction-file attributes using the verified managed directory owner. +/// +/// Production stores run as SYSTEM and retain SYSTEM ownership. +/// Elevated development stores whose directory is owned by Administrators retain that owner, +/// which avoids requiring an Administrator token to assign the unrelated SYSTEM SID. +pub(crate) fn managed_policy_transaction_security_attributes(directory: &File) -> anyhow::Result { + verify_policy_directory_security(directory) + .context("transaction directory failed managed policy security verification")?; + + let mut owner = PSID::default(); + let mut descriptor = OwnedSecurityDescriptor(PSECURITY_DESCRIPTOR::default()); + // SAFETY: `directory` is an open file handle, and the out parameters remain valid for the call. + let result = unsafe { + GetSecurityInfo( + HANDLE(directory.as_raw_handle()), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + Some(&mut owner), + None, + None, + None, + Some(&mut descriptor.0), + ) + }; + if result != ERROR_SUCCESS { + bail!("failed to read managed policy directory owner: error {}", result.0); + } + if owner.0.is_null() { + bail!("managed policy directory has no owner information"); + } + let process_owner = Process::current_process() + .token(TOKEN_QUERY) + .context("open current process token to select transaction owner")? + .sid_and_attributes() + .context("read current process owner to select transaction owner")? + .sid; + managed_policy_transaction_security_attributes_for_owners(owner, &process_owner) +} + +fn managed_policy_transaction_security_attributes_for_owners( + directory_owner: PSID, + process_owner: &Sid, +) -> anyhow::Result { + // SAFETY: Callers provide either a valid GetSecurityInfo owner SID or a test fixture SID. + if !unsafe { is_trusted_sid(directory_owner, TrustedWriters::ManagedPolicy) } { + // SAFETY: `owner` points into the valid descriptor returned by GetSecurityInfo. + let owner_string = unsafe { sid_to_string(directory_owner) }; + bail!("managed policy directory owner {owner_string} is not a trusted principal"); + } + let system = Sid::from_well_known(WinLocalSystemSid, None).context("resolve SYSTEM SID")?; + let owner = if process_owner == &system { + system + } else { + // SAFETY: The caller retains the valid directory owner SID through the copy. + unsafe { Sid::from_psid(directory_owner) }.context("copy managed policy directory owner")? + }; + admin_only_security_attributes_for_owner(owner, false) +} + +#[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, + ) } - Ok(hasher.finalize().into()) + .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. @@ -344,7 +623,7 @@ pub(crate) fn verify_retained_executable_security( /// already has it open, and the guard prevents modification, deletion, and renaming /// of the verified object until it is dropped. /// - The executable's owner and DACL must only allow writes by SYSTEM, built-in -/// Administrators, or `NT SERVICE\TrustedInstaller` (see [`verify_policy_file_security`] +/// Administrators, or `NT SERVICE\TrustedInstaller` (see [`verify_handle_security`] /// for the exact DACL rules). /// - Every ancestor directory of the final path (resolved from the verified handle) must /// not allow untrusted principals to rename or delete path components, so the name used @@ -401,7 +680,6 @@ pub(crate) fn verify_elevated_executable_security( &subject, TrustedWriters::AdminOrTrustedInstaller, TrustedWriters::AdminOrTrustedInstaller, - false, )?; Ok(Some(VerifiedExecutable { @@ -635,7 +913,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 +928,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 +955,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)), @@ -794,8 +1068,6 @@ fn verify_handle_security( /// Verify that `owner` is trusted and that `dacl` grants `tamper_mask` rights to /// `trusted_writers` SIDs only. /// -/// See [`verify_policy_file_security`] for the exact rules. -/// /// # Safety /// /// - `owner` must be null or point to a valid SID. @@ -888,23 +1160,12 @@ unsafe fn is_trusted_sid(sid: PSID, trusted_writers: TrustedWriters) -> bool { return true; } - // The Devolutions Agent installer creates `C:\ProgramData\Devolutions\Agent` with - // 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 - // SAFETY: Per function contract, `sid` points to a valid SID. - && unsafe { IsWellKnownSid(sid, WinLocalServiceSid) }.as_bool() - { - return true; - } - // SAFETY: Per function contract, `sid` points to a valid SID. if unsafe { IsWellKnownSid(sid, WinBuiltinAdministratorsSid) }.as_bool() { return true; } - if trusted_writers == TrustedWriters::AdminOnly { + if matches!(trusted_writers, TrustedWriters::ManagedPolicy) { return false; } @@ -951,7 +1212,7 @@ mod tests { ConvertStringSecurityDescriptorToSecurityDescriptorW, GRANT_ACCESS, SDDL_REVISION_1, }; use windows::Win32::Security::{ - GetSecurityDescriptorDacl, GetSecurityDescriptorOwner, NO_INHERITANCE, WinWorldSid, + GetSecurityDescriptorDacl, GetSecurityDescriptorOwner, NO_INHERITANCE, WinLocalServiceSid, WinWorldSid, }; use super::*; @@ -988,6 +1249,178 @@ mod tests { assert!(windows_paths_equal(&resolved, &executable)); } + #[test] + fn ace_digest_includes_callback_application_data() { + let left_entry: &[u8] = &[1, 2, 3, 4]; + let right_entry: &[u8] = &[1, 2, 3, 5]; + let left = canonical_security_digest(Some(&[1, 2]), Some(&[left_entry])); + let right = canonical_security_digest(Some(&[1, 2]), Some(&[right_entry])); + + assert_ne!(left, right); + } + + #[test] + fn security_digest_uses_fixed_width_golden_encoding() { + let owner: &[u8] = &[1, 1, 0, 0, 0, 0, 0, 5]; + let first: &[u8] = &[0, 0, 8, 0, 1, 0, 0, 0]; + let second: &[u8] = &[9, 0, 12, 0, 2, 0, 0, 0, 7, 8, 9, 10]; + let digest = canonical_security_digest(Some(owner), Some(&[first, second])); + + assert_eq!( + hex::encode(digest), + "af334410d4d80b647c235e0f3e550c9cc0598127282068cf78ca142b00f09154" + ); + + let native_32 = [u32::try_from(first.len()).unwrap().to_le_bytes().as_slice(), first].concat(); + let native_64 = [u64::try_from(first.len()).unwrap().to_le_bytes().as_slice(), first].concat(); + assert_ne!( + native_32, native_64, + "legacy native-width streams differ across architectures" + ); + assert_eq!( + canonical_security_digest(Some(owner), Some(&[first, second])), + digest, + "canonical digest is independent of native pointer width" + ); + } + + #[test] + fn ancestor_digest_changes_when_object_identity_changes_at_same_path_and_acl() { + let security = [7; 32]; + let first = FileIdentity { + volume_serial: 1, + file_id: [1; 16], + }; + let second = FileIdentity { + volume_serial: 1, + file_id: [2; 16], + }; + let digest = |identity| { + let mut hasher = Sha256::new(); + hasher.update(b"devolutions-policy-ancestor-digest-v2\0"); + hasher.update(1u32.to_le_bytes()); + update_ancestor_level_digest(&mut hasher, identity, security); + <[u8; 32]>::from(hasher.finalize()) + }; + + assert_ne!(digest(first), digest(second)); + } + + #[test] + fn ancestor_digest_binds_root_to_leaf_order() { + let root = FileIdentity { + volume_serial: 1, + file_id: [1; 16], + }; + let leaf = FileIdentity { + volume_serial: 1, + file_id: [2; 16], + }; + let security = [7; 32]; + + assert_ne!( + canonical_ancestor_digest(&[(root, security), (leaf, security)]), + canonical_ancestor_digest(&[(leaf, security), (root, security)]) + ); + } + + #[test] + fn ancestor_digest_is_path_text_independent_without_lossy_collapse() { + let upper = Path::new(r"C:\DÉVOLUTIONS"); + let lower = Path::new(r"c:\dévolutions"); + assert!(windows_paths_equal(upper, lower)); + let identity = FileIdentity { + volume_serial: 1, + file_id: [1; 16], + }; + let digest = canonical_ancestor_digest(&[(identity, [7; 32])]); + assert_eq!(digest, canonical_ancestor_digest(&[(identity, [7; 32])])); + + let first = OsString::from_wide(&[0xD800]); + let second = OsString::from_wide(&[0xD801]); + assert_eq!(first.to_string_lossy(), second.to_string_lossy()); + assert_ne!( + canonical_ancestor_digest(&[( + FileIdentity { + volume_serial: 1, + file_id: [1; 16], + }, + [7; 32], + )]), + canonical_ancestor_digest(&[( + FileIdentity { + volume_serial: 1, + file_id: [2; 16], + }, + [7; 32], + )]) + ); + } + + #[test] + fn admin_only_security_attributes_use_a_protected_dacl() { + let attributes = admin_only_security_attributes(false).expect("build admin-only security attributes"); + // SAFETY: `attributes` owns a live SECURITY_ATTRIBUTES and security descriptor. + let raw = unsafe { &*attributes.as_ptr() }; + // SAFETY: lpSecurityDescriptor points to the descriptor retained by `attributes`. + let descriptor = unsafe { + &*raw + .lpSecurityDescriptor + .cast::() + }; + + assert!(descriptor.Control.contains(windows::Win32::Security::SE_DACL_PROTECTED)); + assert!(descriptor.Control.contains(windows::Win32::Security::SE_DACL_PRESENT)); + } + + fn security_attributes_owner(attributes: &SecurityAttributes) -> PSID { + // SAFETY: `attributes` owns the live security descriptor. + let raw = unsafe { &*attributes.as_ptr() }; + let descriptor = PSECURITY_DESCRIPTOR(raw.lpSecurityDescriptor.cast()); + let mut owner = PSID::default(); + let mut defaulted = windows::core::BOOL(0); + // SAFETY: The descriptor is valid and the out parameters point to live variables. + unsafe { GetSecurityDescriptorOwner(descriptor, &mut owner, &mut defaulted) } + .expect("security attributes retain an owner"); + assert!(!defaulted.as_bool()); + owner + } + + #[test] + fn transaction_security_attributes_preserve_each_trusted_managed_owner() { + let system = Sid::from_well_known(WinLocalSystemSid, None).expect("resolve SYSTEM owner"); + let administrators = + Sid::from_well_known(WinBuiltinAdministratorsSid, None).expect("resolve Administrators owner"); + let local_service = Sid::from_well_known(WinLocalServiceSid, None).expect("resolve LocalService owner"); + + for (directory_owner, process_owner, expected_owner) in [ + (&system, &system, WinLocalSystemSid), + (&administrators, &system, WinLocalSystemSid), + (&administrators, &local_service, WinBuiltinAdministratorsSid), + ] { + let attributes = managed_policy_transaction_security_attributes_for_owners( + directory_owner.as_psid_const(), + process_owner, + ) + .expect("build transaction attributes"); + let copied_owner = security_attributes_owner(&attributes); + // SAFETY: `copied_owner` points into the security descriptor retained by `attributes`. + assert!(unsafe { IsWellKnownSid(copied_owner, expected_owner) }.as_bool()); + } + } + + #[test] + fn transaction_security_attributes_reject_untrusted_directory_owner() { + let owner = Sid::from_well_known(WinWorldSid, None).expect("resolve untrusted owner"); + let process_owner = Sid::from_well_known(WinLocalServiceSid, None).expect("resolve process owner"); + let error = + match managed_policy_transaction_security_attributes_for_owners(owner.as_psid_const(), &process_owner) { + Ok(_) => panic!("untrusted owner must not be copied into transaction files"), + Err(error) => error, + }; + assert!(error.to_string().contains("not a trusted principal"), "{error:#}"); + } + /// SDDL-backed security descriptor together with its extracted owner and DACL pointers. struct SddlDescriptor { _descriptor: OwnedSecurityDescriptor, @@ -1045,7 +1478,7 @@ mod tests { "test file", self.owner, self.dacl, - TrustedWriters::AdminOnly, + TrustedWriters::ManagedPolicy, WRITE_ACCESS_MASK, ) } @@ -1055,6 +1488,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. @@ -1084,11 +1530,28 @@ mod tests { } #[test] - fn local_service_write_ace_is_accepted_for_policy_file() { - // The installer creates the Agent ProgramData directory with write access for - // LOCAL SERVICE, so the policy-file check must accept it. + 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)"); - sd.verify().expect("LOCAL SERVICE write access must be accepted"); + let error = sd.verify_as_managed_policy(WRITE_ACCESS_MASK).unwrap_err(); + assert!( + error.to_string().contains("grants write access"), + "unexpected error: {error}" + ); + } + + #[test] + fn shared_create_rights_are_rejected_during_managed_directory_bootstrap() { + let shared = SddlDescriptor::parse("O:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x6;;;BU)"); + shared + .verify_with_mask(DIRECTORY_TAMPER_MASK) + .expect("create-only rights are safe after a child is pinned"); + let error = shared + .verify_as_managed_policy(PARENT_DIRECTORY_TAMPER_MASK) + .unwrap_err(); + assert!( + error.to_string().contains("grants write access"), + "unexpected error: {error}" + ); } #[test] @@ -1411,7 +1874,7 @@ mod tests { "policy ancestor", sd.owner, sd.dacl, - TrustedWriters::PolicyAncestor, + TrustedWriters::AdminOrTrustedInstaller, DIRECTORY_TAMPER_MASK, ) } @@ -1497,7 +1960,7 @@ mod tests { set_security(temp.path(), None, &[grant(GENERIC_ALL.0, everyone)]).unwrap(); let file = File::open(temp.path()).unwrap(); - let result = verify_policy_file_security(&file); + let result = verify_managed_policy_file_security(&file); assert!(result.is_err(), "everyone-writable policy file must be rejected"); } @@ -1535,7 +1998,7 @@ mod tests { .unwrap(); verify_policy_file_path(&file, &path).expect("ordinary policy path must be accepted"); - verify_policy_file_security(&file).expect("SYSTEM/Administrators-only policy file must be accepted"); + verify_managed_policy_file_security(&file).expect("SYSTEM/Administrators-only policy file must be accepted"); } #[test] @@ -1546,10 +2009,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 e42cf7711..631025078 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -1,17 +1,10 @@ //! Serialized policy management, validation, persistence, and reload. -//! Store tokens serialize API writers and reloads, not privileged out-of-band writes. -//! Conditional handle-relative publication is deferred. - -use std::fs::{File, OpenOptions}; -use std::io::{Read as _, Write as _}; -use std::mem::size_of; -use std::os::windows::ffi::OsStrExt as _; -use std::os::windows::fs::OpenOptionsExt as _; -use std::os::windows::io::AsRawHandle as _; -use std::path::{Component, Path, PathBuf}; +//! Store tokens serialize API writers and reloads. +//! Retained handles and conditional handle-relative publication preserve privileged out-of-band writes. + +use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; -use anyhow::Context as _; use chrono::Utc; use now_policy::PolicyDocument; use now_policy_api::{ @@ -20,86 +13,133 @@ use now_policy_api::{ PolicyReplacementRequest, PolicyStoreToken, PolicyValidationResult, PolicyWriteCapability, ServerContext, Transport, }; -use sha2::{Digest as _, Sha256}; -use windows::Win32::Foundation::HANDLE; -use windows::Win32::Storage::FileSystem::{ - FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, - FILE_SHARE_READ, FILE_SHARE_WRITE, FileIdInfo, GetFileInformationByHandleEx, GetVolumeInformationW, - GetVolumePathNameW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, READ_CONTROL, -}; -use windows::core::PCWSTR; -use crate::policy_security; mod receipt; mod validation; +mod windows; #[derive(Clone, Copy, Debug)] pub enum ReloadCause { ExternalChange, } -#[derive(Clone, PartialEq, Eq)] -struct DiskFingerprint([u8; 32]); - -struct Observation { - state: PolicyManagementState, - policy: Option, - invalid_diagnostics: Option, - write_capability: PolicyWriteCapability, - read_only_reason: Option, - configured_path: PathBuf, - fingerprint: DiskFingerprint, -} - -struct PersistedPolicy { - policy: PolicyDocument, - observation: Observation, -} - -enum WriteFailure { - PrePublication(anyhow::Error), - PostPublication(anyhow::Error), -} +type DiskFingerprint = windows::DiskFingerprint; +type Observation = windows::DiskObservation; +type PersistedPolicy = windows::PersistedPolicy; +type WriteFailure = windows::WriteFailure; trait PolicyStorage: Send + Sync { fn observe(&self, source: PolicyConfigurationSource, path: &Path) -> Observation; + fn observe_for_write(&self, source: PolicyConfigurationSource, path: &Path) -> Observation { + self.observe(source, path) + } fn create( &self, + _source: PolicyConfigurationSource, configured_path: &Path, observation: &Observation, bytes: &[u8], ) -> Result; fn replace( &self, + _source: PolicyConfigurationSource, configured_path: &Path, - observation: &Observation, + observation: &mut Observation, bytes: &[u8], ) -> Result; } -struct FilePolicyStorage; +struct FilePolicyStorage { + probe_cache: windows::AtomicityProbeCache, +} + +impl FilePolicyStorage { + fn new() -> Self { + Self { + probe_cache: windows::AtomicityProbeCache::new(), + } + } +} impl PolicyStorage for FilePolicyStorage { fn observe(&self, source: PolicyConfigurationSource, path: &Path) -> Observation { - observe_file(source, path) + windows::observe(source, path, &self.probe_cache) + } + + fn observe_for_write(&self, source: PolicyConfigurationSource, path: &Path) -> Observation { + windows::observe_for_write(source, path, &self.probe_cache) } fn create( &self, + _source: PolicyConfigurationSource, configured_path: &Path, observation: &Observation, bytes: &[u8], ) -> Result { - publish_file(configured_path, observation, bytes, false) + let hosting_dir = observation + .hosting_dir + .as_ref() + .expect("writable observations retain the verified hosting directory"); + windows::atomic_create( + hosting_dir, + &observation.fingerprint, + &observation.canonical_path, + bytes, + )?; + self.authoritative_reobserve(configured_path, bytes) } fn replace( &self, + _source: PolicyConfigurationSource, configured_path: &Path, - observation: &Observation, + observation: &mut Observation, bytes: &[u8], ) -> Result { - publish_file(configured_path, observation, bytes, true) + let hosting_dir = observation + .hosting_dir + .as_ref() + .expect("writable observations retain the verified hosting directory"); + windows::atomic_replace( + hosting_dir, + observation.retained_target.take(), + &observation.fingerprint, + &observation.canonical_path, + bytes, + )?; + self.authoritative_reobserve(configured_path, bytes) + } +} + +impl FilePolicyStorage { + fn authoritative_reobserve( + &self, + configured_path: &Path, + expected_bytes: &[u8], + ) -> Result { + let observation = windows::observe( + PolicyConfigurationSource::ConfiguredPath, + configured_path, + &self.probe_cache, + ); + let policy = observation + .policy + .ok_or_else(|| WriteFailure::PostPublication(anyhow::anyhow!("published policy failed re-observation")))?; + let expected: serde_json::Value = + serde_json::from_slice(expected_bytes).map_err(|error| WriteFailure::PostPublication(error.into()))?; + if serde_json::to_value(&policy).map_err(|error| WriteFailure::PostPublication(error.into()))? != expected { + return Err(WriteFailure::PostPublication(anyhow::anyhow!( + "re-observed policy does not match the committed document" + ))); + } + Ok(PersistedPolicy { + policy, + fingerprint: observation.fingerprint, + write_capability: observation.write_capability, + read_only_reason: observation.read_only_reason, + canonical_path: observation.canonical_path, + }) } } @@ -139,7 +179,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( @@ -150,8 +194,7 @@ impl PolicyStore { 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()), + crate::policy_loader::default_policy_path(), PolicyConfigurationSource::DefaultPath, ), }; @@ -180,7 +223,17 @@ impl PolicyStore { management_from_snapshot(&snapshot, self.source) } - pub(crate) fn configured_path(&self) -> PathBuf { + fn observe_storage(&self, retain_for_write: bool) -> (PathBuf, Observation) { + let path = self.configured_path.clone(); + let observation = if retain_for_write { + self.storage.observe_for_write(self.source, &path) + } else { + self.storage.observe(self.source, &path) + }; + (path, observation) + } + + pub(crate) fn watched_path(&self) -> PathBuf { self.snapshot().configured_path.clone() } @@ -201,7 +254,7 @@ impl PolicyStore { if *monitoring != Monitoring::Available { return self.management_snapshot(); } - let observation = self.storage.observe(self.source, &self.configured_path); + let (_, observation) = self.observe_storage(false); let management = self.publish_observation(observation); tracing::info!(?cause, state = ?management.state, "Reloaded package broker policy"); management @@ -212,7 +265,8 @@ impl PolicyStore { if *monitoring != Monitoring::Initializing { return self.management_snapshot(); } - let management = self.publish_observation(self.storage.observe(self.source, &self.configured_path)); + let (_, observation) = self.observe_storage(false); + let management = self.publish_observation(observation); *monitoring = Monitoring::Available; management } @@ -232,8 +286,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 +304,7 @@ impl PolicyStore { )); } let previous = self.snapshot(); - let observation = self.storage.observe(self.source, &self.configured_path); + let (write_configured_path, mut observation) = self.observe_storage(true); let fresh_token = token_for(&previous, &observation.fingerprint); // Both conflict modes require this exact token. @@ -320,15 +376,17 @@ impl PolicyStore { .map_err(|_| error_response(ErrorCode::InternalError, "failed to serialize the committed policy"))?; let persisted = if request.operation == PolicyReplacementOperation::Create { - self.storage.create(&self.configured_path, &observation, &bytes) + self.storage + .create(self.source, &write_configured_path, &observation, &bytes) } else { - self.storage.replace(&self.configured_path, &observation, &bytes) + self.storage + .replace(self.source, &write_configured_path, &mut observation, &bytes) }; let persisted = match persisted { Ok(persisted) => persisted, Err(WriteFailure::PrePublication(error)) => { tracing::warn!(error = format!("{error:#}"), "Policy persistence failed"); - let current = self.storage.observe(self.source, &self.configured_path); + let (_, current) = self.observe_storage(false); if current.fingerprint != observation.fingerprint { let management = self.publish_observation(current); return Err(error_with_management( @@ -342,12 +400,31 @@ impl PolicyStore { "failed to persist the policy", )); } + Err(WriteFailure::ConcurrentChange(error)) => { + tracing::warn!( + error = format!("{error:#}"), + "Conditional policy publication observed a concurrent storage change" + ); + let (_, current) = self.observe_storage(false); + if current.fingerprint == observation.fingerprint { + return Err(error_response( + ErrorCode::PolicyPersistenceFailed, + "failed to conditionally persist the policy", + )); + } + let management = self.publish_observation(current); + return Err(error_with_management( + ErrorCode::StalePolicyStoreToken, + "the policy storage changed during publication; retry with the current store token", + management, + )); + } Err(WriteFailure::PostPublication(error)) => { tracing::warn!( error = format!("{error:#}"), "Published policy failed authoritative reload" ); - let current = self.storage.observe(self.source, &self.configured_path); + let (_, current) = self.observe_storage(false); let management = self.publish_observation(current); return Err(error_with_management( ErrorCode::PolicyActivationFailed, @@ -357,16 +434,17 @@ impl PolicyStore { } }; - if persisted.observation.state != PolicyManagementState::Active { - let management = self.publish_observation(persisted.observation); - return Err(error_with_management( - ErrorCode::PolicyActivationFailed, - "the policy was published but failed authoritative reload", - management, - )); - } - let token = token_for(&previous, &persisted.observation.fingerprint); - let snapshot = Arc::new(snapshot_from_observation(persisted.observation, token)); + let token = token_for(&previous, &persisted.fingerprint); + let snapshot = Arc::new(Snapshot { + state: PolicyManagementState::Active, + policy: Some(Arc::new(persisted.policy.clone())), + invalid_diagnostics: None, + write_capability: persisted.write_capability, + read_only_reason: persisted.read_only_reason, + configured_path: persisted.canonical_path, + store_token: token, + fingerprint: persisted.fingerprint, + }); *self.snapshot.write().expect("policy store snapshot lock poisoned") = snapshot; Ok(ReplaceSuccess { @@ -400,9 +478,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 +534,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 +563,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,468 +602,54 @@ fn error_with_management( response } -fn observe_file(_source: PolicyConfigurationSource, configured_path: &Path) -> Observation { - let mut hasher = Sha256::new(); - for unit in configured_path.as_os_str().encode_wide() { - hasher.update(unit.to_le_bytes()); - } - - if !is_safe_path_shape(configured_path) { - return invalid_observation( - configured_path.to_owned(), - PolicyWriteCapability::Unsupported, - Some(PolicyReadOnlyReason::UnsafePath), - validation::DiskFailureReason::InsecureStorage, - hasher, - ); - } - - let extension = configured_path - .extension() - .and_then(|value| value.to_str()) - .unwrap_or_default() - .to_ascii_lowercase(); - if extension != "json" { - return invalid_observation( - configured_path.to_owned(), - PolicyWriteCapability::Unsupported, - Some(PolicyReadOnlyReason::UnsupportedFormat), - validation::DiskFailureReason::UnsupportedFormat, - hasher, - ); - } - - let display_path = match canonical_display_path(configured_path) { - Ok(path) => path, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return missing_observation( - configured_path.to_owned(), - PolicyWriteCapability::ReadOnly, - Some(PolicyReadOnlyReason::InsufficientPermissions), - hasher, - ); - } - Err(error) => { - tracing::warn!(error = %error, "Failed to resolve policy path"); - return invalid_observation( - configured_path.to_owned(), - PolicyWriteCapability::ReadOnly, - Some(PolicyReadOnlyReason::UnsafePath), - validation::DiskFailureReason::InsecureStorage, - hasher, - ); - } - }; - for unit in display_path.as_os_str().encode_wide() { - hasher.update(unit.to_le_bytes()); - } - - let Some(parent) = display_path.parent() else { - return invalid_observation( - display_path, - PolicyWriteCapability::ReadOnly, - Some(PolicyReadOnlyReason::UnsafePath), - validation::DiskFailureReason::Unreadable, - hasher, - ); - }; - let directory = match open_directory(parent) { - Ok(directory) => directory, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return missing_observation( - display_path, - PolicyWriteCapability::ReadOnly, - Some(PolicyReadOnlyReason::InsufficientPermissions), - hasher, - ); - } - Err(error) => { - tracing::warn!(error = %error, "Failed to open policy directory"); - return invalid_observation( - display_path, - PolicyWriteCapability::ReadOnly, - Some(PolicyReadOnlyReason::UnsafePath), - validation::DiskFailureReason::InsecureStorage, - hasher, - ); - } - }; - hash_file_identity(&directory, &mut hasher); - let directory_safe = match policy_security::verify_policy_path_ancestors(configured_path) - .and_then(|()| policy_security::verify_policy_path_ancestors(&display_path)) - .and_then(|()| { - let current_path = canonical_display_path(configured_path) - .context("failed to resolve policy path after security validation")?; - if policy_security::windows_paths_equal(&display_path, ¤t_path) { - Ok(()) - } else { - anyhow::bail!("policy path canonical chain changed during security validation") - } - }) - .and_then(|()| policy_security::verify_policy_directory_security(&directory)) - .and_then(|()| policy_security::security_state_digest(&directory)) - { - Ok(digest) => { - hasher.update(digest); - true - } - Err(error) => { - tracing::warn!( - error = format!("{error:#}"), - "Policy directory security validation failed" - ); - false - } - }; - if !directory_safe { - return invalid_observation( - display_path, - PolicyWriteCapability::ReadOnly, - Some(PolicyReadOnlyReason::UnsafePath), - validation::DiskFailureReason::InsecureStorage, - hasher, - ); - } - let atomic_filesystem = directory_safe && supports_atomic_replace(parent); - let capability = if !atomic_filesystem { - PolicyWriteCapability::Unsupported - } else { - PolicyWriteCapability::Writable - }; - let read_only_reason = match capability { - PolicyWriteCapability::Writable => None, - PolicyWriteCapability::Unsupported => Some(PolicyReadOnlyReason::UnsupportedFileSystem), - PolicyWriteCapability::ReadOnly => unreachable!("unsafe directories returned above"), - }; - - let mut file = match OpenOptions::new() - .read(true) - .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) - .open(&display_path) - { - Ok(file) => file, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return missing_observation(display_path, capability, read_only_reason, hasher); - } - Err(error) => { - tracing::warn!(error = %error, "Failed to open configured policy"); - return invalid_observation( - display_path, - capability, - read_only_reason, - validation::DiskFailureReason::Unreadable, - hasher, - ); - } - }; - hash_file_identity(&file, &mut hasher); - if let Err(error) = policy_security::verify_policy_file_path(&file, &display_path) - .and_then(|()| policy_security::verify_policy_file_security(&file)) - { - tracing::warn!( - error = format!("{error:#}"), - "Configured policy security validation failed" - ); - return invalid_observation( - display_path, - PolicyWriteCapability::ReadOnly, - Some(PolicyReadOnlyReason::UnsafePath), - validation::DiskFailureReason::InsecureStorage, - hasher, - ); - } - match policy_security::security_state_digest(&file) { - Ok(digest) => hasher.update(digest), - Err(error) => { - tracing::warn!( - error = format!("{error:#}"), - "Failed to fingerprint configured policy security" - ); - return invalid_observation( - display_path, - PolicyWriteCapability::ReadOnly, - Some(PolicyReadOnlyReason::UnsafePath), - validation::DiskFailureReason::InsecureStorage, - hasher, - ); - } - } - let mut bytes = Vec::new(); - if let Err(error) = file.read_to_end(&mut bytes) { - tracing::warn!(error = %error, "Failed to read configured policy"); - return invalid_observation( - display_path, - capability, - read_only_reason, - validation::DiskFailureReason::Unreadable, - hasher, - ); - } - hasher.update(&bytes); - let policy = serde_json::from_slice::(&bytes); - let policy = match policy { - Ok(policy) => policy, - Err(error) => { - tracing::warn!(error = %error, "Configured policy parsing failed"); - let failure = disk_parse_failure_reason(&bytes); - return invalid_observation(display_path, capability, read_only_reason, failure, hasher); - } - }; - let committed_validation = validation::validate_committed_policy(&policy); - if !committed_validation.is_valid { - tracing::warn!( - findings = ?committed_validation.findings, - "Configured policy semantic validation failed" - ); - return invalid_observation( - display_path, - capability, - read_only_reason, - validation::DiskFailureReason::FailedSemanticValidation, - hasher, - ); - } - - Observation { - state: PolicyManagementState::Active, - policy: Some(policy), - invalid_diagnostics: None, - write_capability: capability, - read_only_reason, - configured_path: display_path, - fingerprint: DiskFingerprint(hasher.finalize().into()), - } -} - -fn publish_file( - configured_path: &Path, - observation: &Observation, - bytes: &[u8], - replace: bool, -) -> Result { - let path = &observation.configured_path; - let parent = path - .parent() - .ok_or_else(|| WriteFailure::PrePublication(anyhow::anyhow!("policy path has no parent")))?; - let leaf = path - .file_name() - .ok_or_else(|| WriteFailure::PrePublication(anyhow::anyhow!("policy path has no file name")))?; - let temp_path = parent.join(format!( - ".{}.{}.tmp", - leaf.to_string_lossy(), - uuid::Uuid::new_v4().simple() - )); - let prepared = (|| { - let mut temp = OpenOptions::new().write(true).create_new(true).open(&temp_path)?; - temp.write_all(bytes)?; - temp.sync_all()?; - policy_security::verify_policy_file_security(&temp)?; - drop(temp); - - let from = wide_path(&temp_path); - let to = wide_path(path); - let flags = if replace { - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH - } else { - MOVEFILE_WRITE_THROUGH - }; - // SAFETY: Both buffers are live, nul-terminated absolute paths. - unsafe { MoveFileExW(PCWSTR(from.as_ptr()), PCWSTR(to.as_ptr()), flags) }?; - Ok::<(), anyhow::Error>(()) - })(); - if let Err(error) = prepared { - let _ = std::fs::remove_file(&temp_path); - return Err(WriteFailure::PrePublication(error)); - } - - let reloaded = (|| { - let reloaded = observe_file(PolicyConfigurationSource::ConfiguredPath, configured_path); - let policy = reloaded - .policy - .clone() - .ok_or_else(|| anyhow::anyhow!("published policy failed authoritative reload"))?; - let expected: serde_json::Value = serde_json::from_slice(bytes)?; - if serde_json::to_value(&policy)? != expected { - anyhow::bail!("published policy does not match the requested committed document"); - } - Ok(PersistedPolicy { - policy, - observation: reloaded, - }) - })(); - reloaded.map_err(WriteFailure::PostPublication) -} - -fn missing_observation( - path: PathBuf, - capability: PolicyWriteCapability, - reason: Option, - mut hasher: Sha256, -) -> Observation { - hasher.update(b"missing"); - Observation { - state: PolicyManagementState::Missing, - policy: None, - invalid_diagnostics: None, - write_capability: capability, - read_only_reason: reason, - configured_path: path, - fingerprint: DiskFingerprint(hasher.finalize().into()), - } -} - -fn invalid_observation( - path: PathBuf, - capability: PolicyWriteCapability, - reason: Option, - failure: validation::DiskFailureReason, - mut hasher: Sha256, -) -> Observation { - hasher.update(format!("{failure:?}")); - Observation { - state: PolicyManagementState::Invalid, - policy: None, - invalid_diagnostics: Some(InvalidPolicyDiagnostics { - diagnostics_version: API_VERSION_STR.into(), - findings: vec![validation::disk_failure_finding(failure)], - }), - write_capability: capability, - read_only_reason: reason, - configured_path: path, - fingerprint: DiskFingerprint(hasher.finalize().into()), - } -} - -fn is_safe_path_shape(path: &Path) -> bool { - let raw = path.as_os_str().to_string_lossy(); - path.is_absolute() - && path.file_name().is_some() - && !raw.split(['\\', '/']).any(|segment| matches!(segment, "." | "..")) - && path - .components() - .all(|component| !matches!(component, Component::CurDir | Component::ParentDir)) -} - -fn canonical_display_path(path: &Path) -> std::io::Result { - let parent = path - .parent() - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "policy path has no parent"))?; - let leaf = path - .file_name() - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "policy path has no file name"))?; - Ok(parent.canonicalize()?.join(leaf)) -} - -fn open_directory(path: &Path) -> std::io::Result { - OpenOptions::new() - .access_mode(FILE_READ_ATTRIBUTES.0 | READ_CONTROL.0) - .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0) - .open(path) -} - -fn supports_atomic_replace(path: &Path) -> bool { - let path = wide_path(path); - let mut root = vec![0; 512]; - // SAFETY: The path is nul-terminated and the root buffer is writable. - if unsafe { GetVolumePathNameW(PCWSTR(path.as_ptr()), &mut root) }.is_err() { - return false; - } - let mut filesystem = vec![0; 261]; - // SAFETY: GetVolumePathNameW returned a nul-terminated root and the output buffer is writable. - if unsafe { GetVolumeInformationW(PCWSTR(root.as_ptr()), None, None, None, None, Some(&mut filesystem)) }.is_err() { - return false; - } - let length = filesystem - .iter() - .position(|unit| *unit == 0) - .unwrap_or(filesystem.len()); - matches!( - String::from_utf16_lossy(&filesystem[..length]).as_str(), - "NTFS" | "ReFS" - ) -} - -fn hash_file_identity(file: &File, hasher: &mut Sha256) { - let mut info = FILE_ID_INFO::default(); - let size = u32::try_from(size_of::()).expect("FILE_ID_INFO size fits u32"); - // SAFETY: The file handle and correctly sized output buffer are valid for the call. - if unsafe { GetFileInformationByHandleEx(HANDLE(file.as_raw_handle()), FileIdInfo, (&raw mut info).cast(), size) } - .is_ok() - { - hasher.update(info.VolumeSerialNumber.to_le_bytes()); - hasher.update(info.FileId.Identifier); - } -} - -fn wide_path(path: &Path) -> Vec { - path.as_os_str().encode_wide().chain(std::iter::once(0)).collect() +#[cfg(test)] +fn observe_file(source: PolicyConfigurationSource, path: &Path) -> Observation { + windows::observe(source, path, &windows::AtomicityProbeCache::new()) } #[cfg(test)] struct TestStorage { observation: parking_lot::Mutex, fail_persist: std::sync::atomic::AtomicBool, + fail_concurrent_check: std::sync::atomic::AtomicBool, + fail_target_retention: std::sync::atomic::AtomicBool, + race_before_persist: parking_lot::Mutex>, + post_persist_capability: parking_lot::Mutex)>>, + persisted_configured_paths: parking_lot::Mutex>, } #[cfg(test)] impl TestStorage { fn new(policy: Option) -> Self { - let state = if policy.is_some() { - PolicyManagementState::Active - } else { - PolicyManagementState::Missing - }; Self { - observation: parking_lot::Mutex::new(Observation { - state, - policy, - invalid_diagnostics: None, - write_capability: PolicyWriteCapability::Writable, - read_only_reason: None, - configured_path: PathBuf::from(r"C:\policy.json"), - fingerprint: DiskFingerprint([0; 32]), - }), + observation: parking_lot::Mutex::new(test_observation(policy, false, 0)), fail_persist: std::sync::atomic::AtomicBool::new(false), + fail_concurrent_check: std::sync::atomic::AtomicBool::new(false), + fail_target_retention: std::sync::atomic::AtomicBool::new(false), + race_before_persist: parking_lot::Mutex::new(None), + post_persist_capability: parking_lot::Mutex::new(None), + persisted_configured_paths: parking_lot::Mutex::new(Vec::new()), } } fn invalid() -> Self { - let mut storage = Self::new(None); - storage.observation = parking_lot::Mutex::new(Observation { - state: PolicyManagementState::Invalid, - policy: None, - invalid_diagnostics: Some(InvalidPolicyDiagnostics { - diagnostics_version: API_VERSION_STR.into(), - findings: vec![validation::disk_failure_finding( - validation::DiskFailureReason::MalformedContent, - )], - }), - write_capability: PolicyWriteCapability::Writable, - read_only_reason: None, - configured_path: PathBuf::from(r"C:\policy.json"), - fingerprint: DiskFingerprint([1; 32]), - }); - storage + Self { + observation: parking_lot::Mutex::new(test_observation(None, true, 1)), + fail_persist: std::sync::atomic::AtomicBool::new(false), + fail_concurrent_check: std::sync::atomic::AtomicBool::new(false), + fail_target_retention: std::sync::atomic::AtomicBool::new(false), + race_before_persist: parking_lot::Mutex::new(None), + post_persist_capability: parking_lot::Mutex::new(None), + persisted_configured_paths: parking_lot::Mutex::new(Vec::new()), + } } fn set_disk_state(&self, policy: Option, invalid: bool, marker: u8) { - let mut observation = self.observation.lock(); - observation.state = if invalid { - PolicyManagementState::Invalid - } else if policy.is_some() { - PolicyManagementState::Active - } else { - PolicyManagementState::Missing - }; - observation.policy = policy; - observation.invalid_diagnostics = invalid.then(|| InvalidPolicyDiagnostics { - diagnostics_version: API_VERSION_STR.into(), - findings: vec![validation::disk_failure_finding( - validation::DiskFailureReason::MalformedContent, - )], - }); - observation.fingerprint = DiskFingerprint([marker; 32]); + *self.observation.lock() = test_observation(policy, invalid, marker); + } + + fn race_before_next_persist(&self, policy: PolicyDocument) { + *self.race_before_persist.lock() = Some(policy); } } @@ -996,21 +659,44 @@ impl PolicyStorage for TestStorage { clone_observation(&self.observation.lock()) } + fn observe_for_write(&self, source: PolicyConfigurationSource, path: &Path) -> Observation { + let mut observation = self.observe(source, path); + if observation.state != PolicyManagementState::Missing + && !self + .fail_target_retention + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + observation.retained_target = Some(windows::RetainedPolicyFile::for_fake(observation.fingerprint.clone())); + } + observation + } + fn create( &self, - _configured_path: &Path, + _source: PolicyConfigurationSource, + configured_path: &Path, observation: &Observation, bytes: &[u8], ) -> Result { + self.persisted_configured_paths.lock().push(configured_path.to_owned()); self.persist(observation, bytes) } fn replace( &self, - _configured_path: &Path, - observation: &Observation, + _source: PolicyConfigurationSource, + configured_path: &Path, + observation: &mut Observation, bytes: &[u8], ) -> Result { + self.persisted_configured_paths.lock().push(configured_path.to_owned()); + let retained = observation + .retained_target + .take() + .ok_or_else(|| WriteFailure::ConcurrentChange(anyhow::anyhow!("missing retained test target")))?; + retained + .verify_matches(&observation.fingerprint) + .map_err(WriteFailure::ConcurrentChange)?; self.persist(observation, bytes) } } @@ -1023,34 +709,80 @@ impl TestStorage { "injected persistence failure" ))); } + if self + .fail_concurrent_check + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + return Err(WriteFailure::ConcurrentChange(anyhow::anyhow!( + "injected identity query failure" + ))); + } + if let Some(external) = self.race_before_persist.lock().take() { + *self.observation.lock() = test_observation(Some(external), false, 9); + return Err(WriteFailure::ConcurrentChange(anyhow::anyhow!( + "injected external policy replacement" + ))); + } let policy: PolicyDocument = serde_json::from_slice(bytes).map_err(|error| WriteFailure::PrePublication(error.into()))?; let mut next = clone_observation(observation); next.state = PolicyManagementState::Active; next.policy = Some(policy.clone()); next.invalid_diagnostics = None; - next.fingerprint = DiskFingerprint(Sha256::digest(bytes).into()); + next.fingerprint = DiskFingerprint::test_active(bytes, 2, 1, 1, 1); + if let Some((capability, reason)) = self.post_persist_capability.lock().take() { + next.write_capability = capability; + next.read_only_reason = reason; + next.fingerprint = DiskFingerprint::test_active(bytes, 2, 1, 1, 2); + } *self.observation.lock() = clone_observation(&next); Ok(PersistedPolicy { policy, - observation: next, + fingerprint: next.fingerprint, + write_capability: next.write_capability, + read_only_reason: next.read_only_reason, + canonical_path: next.canonical_path, }) } } -fn disk_parse_failure_reason(content: &[u8]) -> validation::DiskFailureReason { - if serde_json::from_slice::(content) - .ok() - .and_then(|value| { - value - .as_object() - .map(|object| object.contains_key("$schema") || object.contains_key("PolicyVersion")) - }) - == Some(true) - { - validation::DiskFailureReason::LegacyPolicyContract +#[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 { - validation::DiskFailureReason::MalformedContent + 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, } } @@ -1062,60 +794,247 @@ 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 tests { +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!({ + "PolicyFormatVersion": "1.0.0", + "Metadata": { "Id": id, "Publisher": "Test" }, + "Enforcement": { "DefaultDecision": "Deny" }, + "Rules": [] + })) + .expect("valid draft") + } + + fn policy(id: &str, revision: u32) -> PolicyDocument { + draft(id) + .into_policy_document(revision, Utc::now()) + .expect("valid committed policy") + } + #[test] - fn legacy_policy_identity_has_a_precise_disk_diagnostic() { - for content in [ - br#"{"$schema":"legacy"}"#.as_slice(), - br#"{"PolicyVersion":"1.0.0"}"#.as_slice(), - ] { - assert_eq!( - disk_parse_failure_reason(content), - validation::DiskFailureReason::LegacyPolicyContract - ); + fn default_store_uses_only_the_canonical_policy_path() { + let storage = Arc::new(TestStorage::new(None)); + storage.observation.lock().canonical_path = crate::policy_loader::default_policy_path(); + let store = PolicyStore::load_with_storage(None, storage as Arc, Monitoring::Available); + + assert_eq!(store.configured_path, crate::policy_loader::default_policy_path()); + assert_eq!(store.watched_path(), crate::policy_loader::default_policy_path()); + } + + 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 compatible_format_version_is_bound_to_receipts_and_persisted_tokens() { + 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 mut request = update_request(&store); + request.draft["PolicyFormatVersion"] = serde_json::json!("1.7.3"); + let error = store + .replace(request.clone()) + .await + .expect_err("format version is receipt-bound"); + assert_eq!(error.code, ErrorCode::ValidationFailed); + + let validation = store.validate_draft(&request.draft); + assert_eq!(validation.validator_version, "now-package-broker-policy-validator/10"); + let canonical = validation.canonical_draft.as_ref().expect("compatible draft"); + let old_receipt = + store + .receipt_key + .issue("now-package-broker-policy-validator/8", canonical, &validation.findings); + request.validation_receipt = old_receipt; + let error = store + .replace(request.clone()) + .await + .expect_err("old validator receipt is rejected"); + assert_eq!(error.code, ErrorCode::ValidationFailed); + + request.validation_receipt = validation.validation_receipt.expect("current receipt"); + let before = store.management_snapshot().store_token; + let result = store.replace(request).await.expect("compatible format is writable"); + assert_eq!( + serde_json::to_value(&result.policy).expect("serialize committed policy")["PolicyFormatVersion"], + "1.7.3" + ); + assert_ne!(before, result.management.store_token); + let reloaded = store.reload_from_disk(ReloadCause::ExternalChange).await; + assert_eq!(reloaded.store_token, result.management.store_token); assert_eq!( - disk_parse_failure_reason(br#"{"PolicyFormatVersion":"broken"}"#), - validation::DiskFailureReason::MalformedContent + serde_json::to_value(store.active_policy().expect("active policy").as_ref()) + .expect("serialize reloaded policy"), + serde_json::to_value(result.policy).expect("serialize committed policy") ); } - #[test] - fn committed_policy_reader_rejects_legacy_identity_fields() { - let current: serde_json::Value = - serde_json::from_str(include_str!("../assets/samples/corporate-allowlist.policy.json")) - .expect("sample policy is valid JSON"); - for (legacy_field, legacy_value) in [ - ("$schema", serde_json::json!("legacy")), - ("PolicyVersion", serde_json::json!("1.0.0")), - ] { - let mut legacy = current.clone(); - legacy[legacy_field] = legacy_value; - let error = serde_json::from_value::(legacy).expect_err("legacy field must be rejected"); - assert!(error.to_string().contains(legacy_field), "{error}"); - } + #[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 + ); } - #[test] - fn committed_policy_reader_preserves_compatible_format_version() { - let mut current: serde_json::Value = - serde_json::from_str(include_str!("../assets/samples/corporate-allowlist.policy.json")) - .expect("sample policy is valid JSON"); - current["PolicyFormatVersion"] = serde_json::json!("1.7.3"); - let policy = serde_json::from_value::(current).expect("compatible format version"); + #[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!( - serde_json::to_value(policy) - .expect("serialize policy") - .pointer("/PolicyFormatVersion"), - Some(&serde_json::json!("1.7.3")) + store + .active_policy() + .expect("previous policy remains active") + .metadata + .revision, + 1 + ); + } + + #[tokio::test] + async fn failed_target_retention_preserves_the_active_snapshot() { + let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); + let store = PolicyStore::load_with_storage( + Some(PathBuf::from(r"C:\policy.json")), + Arc::clone(&storage) as Arc, + Monitoring::Available, ); + let request = update_request(&store); + let previous_token = store.management_snapshot().store_token; + storage + .fail_target_retention + .store(true, std::sync::atomic::Ordering::SeqCst); + + let error = store.replace(request).await.expect_err("target retention fails"); + + assert_eq!(error.code, ErrorCode::PolicyPersistenceFailed); + assert_eq!(store.management_snapshot().store_token, previous_token); + assert_eq!( + store + .active_policy() + .expect("previous policy remains active") + .metadata + .revision, + 1 + ); + } + + #[tokio::test] + async fn replacement_returns_authoritative_post_write_capability() { + let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); + let store = PolicyStore::load_with_storage( + Some(PathBuf::from(r"C:\policy.json")), + Arc::clone(&storage) as Arc, + Monitoring::Available, + ); + let request = update_request(&store); + *storage.post_persist_capability.lock() = + Some((PolicyWriteCapability::ReadOnly, Some(PolicyReadOnlyReason::UnsafePath))); + + let success = store.replace(request).await.expect("policy replacement succeeds"); + + assert_eq!(success.management.write_capability, PolicyWriteCapability::ReadOnly); + assert_eq!( + success.management.read_only_reason, + Some(PolicyReadOnlyReason::UnsafePath) + ); + assert_eq!( + store.management_snapshot().write_capability, + PolicyWriteCapability::ReadOnly + ); + } + + #[tokio::test] + async fn custom_watcher_uses_canonical_path_but_writes_reobserve_configured_path() { + let configured = PathBuf::from(r"C:\RUNNER~1\AppData\Local\Temp\policy.json"); + let canonical = PathBuf::from(r"C:\actions\runneradmin\AppData\Local\Temp\policy.json"); + let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); + storage.observation.lock().canonical_path = canonical.clone(); + let store = PolicyStore::load_with_storage( + Some(configured.clone()), + Arc::clone(&storage) as Arc, + Monitoring::Available, + ); + + assert_eq!(store.watched_path(), canonical); + let success = store.replace(update_request(&store)).await.expect("replace policy"); + assert_eq!(&*storage.persisted_configured_paths.lock(), &[configured]); + assert_eq!(store.watched_path(), 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_path(), replacement_canonical); } } diff --git a/crates/now-package-broker/src/policy_store/receipt.rs b/crates/now-package-broker/src/policy_store/receipt.rs index b6822df87..4aec0b184 100644 --- a/crates/now-package-broker/src/policy_store/receipt.rs +++ b/crates/now-package-broker/src/policy_store/receipt.rs @@ -92,9 +92,8 @@ mod tests { fn draft(id: &str) -> PolicyDraftDocument { serde_json::from_value(serde_json::json!({ "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": id, "Publisher": "Test" }, - "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Enforcement": { "DefaultDecision": "Deny" }, "Rules": [] })) .expect("valid draft") @@ -264,28 +263,16 @@ mod tests { #[tokio::test] async fn canonical_sensitive_warnings_accept_the_original_receipt() { let options = [ - ("SkipHashCheck", "SkipHashCheck", "AllowSkipHashCheck"), - ("PreRelease", "PreRelease", "AllowPreRelease"), - ( - "AllowCustomInstallLocation", - "HasCustomInstallLocation", - "AllowCustomInstallLocation", - ), - ("AllowPrePostCommands", "HasPrePostCommands", "AllowPrePostCommands"), - ( - "AllowKillBeforeOperation", - "HasKillBeforeOperation", - "AllowKillBeforeOperation", - ), - ( - "AllowUninstallPrevious", - "HasUninstallPrevious", - "AllowUninstallPrevious", - ), - ("AllowCustomParameters", "HasCustomParameters", "AllowCustomParameters"), + ("SkipHashCheck", "AllowSkipHashCheck"), + ("PreRelease", "AllowPreRelease"), + ("AllowCustomInstallLocation", "AllowCustomInstallLocation"), + ("AllowPrePostCommands", "AllowPrePostCommands"), + ("AllowKillBeforeOperation", "AllowKillBeforeOperation"), + ("AllowUninstallPrevious", "AllowUninstallPrevious"), + ("AllowCustomParameters", "AllowCustomParameters"), ]; - for (option, match_field, constraint_field) in options { - for explicit in ["Constraint", "EmptyMatch", "Default"] { + for (option, constraint_field) in options { + for explicit in ["Constraint", "AbsentMatch", "Default"] { let store = PolicyStore::for_tests(None); let mut raw = serde_json::to_value(draft(&format!("{option}-{explicit}"))).expect("serialize draft"); let mut rule = serde_json::json!({ @@ -299,7 +286,7 @@ mod tests { rule["Constraints"] = serde_json::json!({}); rule["Constraints"][constraint_field] = serde_json::json!(true); } - "EmptyMatch" => rule["Match"][match_field] = serde_json::json!([]), + "AbsentMatch" => {} "Default" => {} _ => unreachable!(), } diff --git a/crates/now-package-broker/src/policy_store/validation.rs b/crates/now-package-broker/src/policy_store/validation.rs index 230bac6ee..2f0771679 100644 --- a/crates/now-package-broker/src/policy_store/validation.rs +++ b/crates/now-package-broker/src/policy_store/validation.rs @@ -1,13 +1,16 @@ //! Strict deterministic validation for editable policy documents. -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; -use now_policy::{Decision, PolicyConstraints, PolicyDraftDocument, PolicyDraftMetadata, PolicyMatch, PolicyRule}; +use now_policy::{ + Decision, PackageIdentifierCondition, PolicyConstraints, PolicyDraftDocument, PolicyDraftMetadata, PolicyMatch, + PolicyRule, VersionCondition, +}; use now_policy_api::{ API_VERSION_STR, PolicyFinding, PolicyFindingCode, PolicyFindingSeverity, PolicyValidationResult, }; -pub(super) const VALIDATOR_VERSION: &str = "now-package-broker-policy-validator/9"; +pub(super) const VALIDATOR_VERSION: &str = "now-package-broker-policy-validator/10"; const MAX_RULES: usize = 1024; const MAX_RULE_PRIORITY: u32 = i32::MAX as u32; const MAX_FINDING_MESSAGE_CHARS: usize = 2048; @@ -15,23 +18,10 @@ const MAX_FINDINGS: usize = 128; const MATCH_COLLECTION_MAXIMA: &[(&str, usize)] = &[ ("Operations", 3), ("Managers", 16), - ("Sources", 128), - ("PackageIdentifiers", 1024), - ("PackageNames", 1024), - ("Versions", 256), + ("SourceNames", 128), ("Scopes", 2), ("Architectures", 5), - ("Elevation", 2), -]; -const BOOLEAN_MATCH_FIELDS: &[&str] = &[ - "Interactive", - "SkipHashCheck", - "PreRelease", - "HasCustomParameters", - "HasCustomInstallLocation", - "HasPrePostCommands", - "HasKillBeforeOperation", - "HasUninstallPrevious", + ("ExecutionElevation", 2), ]; const CONSTRAINT_COLLECTION_MAXIMA: &[(&str, usize)] = &[ ("AllowedInstallLocationPatterns", 64), @@ -39,10 +29,12 @@ const CONSTRAINT_COLLECTION_MAXIMA: &[(&str, usize)] = &[ ("AllowedCustomParameterPatterns", 128), ("DeniedCustomParameters", 128), ]; + struct Findings { values: Vec, has_error: bool, } + impl Findings { fn new() -> Self { Self { @@ -50,6 +42,7 @@ impl Findings { has_error: false, } } + fn push(&mut self, finding: PolicyFinding) { let is_error = finding.severity == PolicyFindingSeverity::Error; self.has_error |= is_error; @@ -64,10 +57,12 @@ impl Findings { self.values[MAX_FINDINGS - 1] = finding; } } + fn is_saturated(&self) -> bool { self.values.len() == MAX_FINDINGS } } + pub(super) fn validate_draft(raw: &serde_json::Value) -> PolicyValidationResult { let mut findings = Findings::new(); if !raw.is_object() { @@ -78,24 +73,12 @@ pub(super) fn validate_draft(raw: &serde_json::Value) -> PolicyValidationResult )); return invalid_result(findings); } - if reject_legacy_policy_identity(raw, &mut findings) { - return invalid_result(findings); - } - check_constant( - raw, - "PolicyType", - "/PolicyType", - "PackageBrokerPolicy", - PolicyFindingCode::UnsupportedPolicyType, - &mut findings, - ); check_policy_format_version(raw, &mut findings); - if has_error(&findings) { - return invalid_result(findings); - } - if check_raw_collection_bounds(raw, &mut findings) { + check_raw_validity_interval(raw, &mut findings); + if has_error(&findings) || check_raw_collection_bounds(raw, &mut findings) { return invalid_result(findings); } + match serde_json::from_value::(raw.clone()) { Ok(draft) => { semantic_checks(raw, &draft, &mut findings); @@ -111,13 +94,16 @@ pub(super) fn validate_draft(raw: &serde_json::Value) -> PolicyValidationResult } } } + pub(super) fn validate_committed_policy(policy: &now_policy::PolicyDocument) -> PolicyValidationResult { let raw = serde_json::to_value(policy.to_draft()).expect("committed policy draft serializes"); validate_draft(&raw) } + fn has_error(findings: &Findings) -> bool { findings.has_error } + fn invalid_result(findings: Findings) -> PolicyValidationResult { PolicyValidationResult { result_version: API_VERSION_STR.into(), @@ -128,6 +114,7 @@ fn invalid_result(findings: Findings) -> PolicyValidationResult { findings: findings.values, } } + fn valid_result(draft: PolicyDraftDocument, findings: Findings) -> PolicyValidationResult { PolicyValidationResult { result_version: API_VERSION_STR.into(), @@ -138,6 +125,7 @@ fn valid_result(draft: PolicyDraftDocument, findings: Findings) -> PolicyValidat findings: findings.values, } } + fn finding( severity: PolicyFindingSeverity, code: PolicyFindingCode, @@ -162,12 +150,15 @@ fn finding( message, } } + fn error(code: PolicyFindingCode, path: impl Into, message: impl Into) -> PolicyFinding { finding(PolicyFindingSeverity::Error, code, path, message) } + fn warning(code: PolicyFindingCode, path: impl Into, message: impl Into) -> PolicyFinding { finding(PolicyFindingSeverity::Warning, code, path, message) } + fn rule_finding( rule: &PolicyRule, severity: PolicyFindingSeverity, @@ -179,52 +170,7 @@ fn rule_finding( finding.rule_id = Some(now_policy_api::ResourceId::from(rule.id.0.as_str())); finding } -fn check_constant( - raw: &serde_json::Value, - key: &str, - path: &str, - expected: &str, - mismatch_code: PolicyFindingCode, - findings: &mut Findings, -) { - match raw.get(key) { - None => findings.push(error( - PolicyFindingCode::MissingRequiredField, - path, - format!("missing required field '{key}'"), - )), - Some(serde_json::Value::String(value)) if value == expected => {} - Some(serde_json::Value::String(value)) => findings.push(error( - mismatch_code, - path, - format!("unsupported value '{value}'; expected '{expected}'"), - )), - Some(_) => findings.push(error( - PolicyFindingCode::InvalidFieldType, - path, - format!("'{key}' must be a string"), - )), - } -} -fn reject_legacy_policy_identity(raw: &serde_json::Value, findings: &mut Findings) -> bool { - let has_schema = raw.get("$schema").is_some(); - if has_schema { - findings.push(error( - PolicyFindingCode::UnsupportedPolicyFormatVersion, - "/$schema", - "'$schema' is unsupported; remove it and use 'PolicyFormatVersion'", - )); - } - let has_policy_version = raw.get("PolicyVersion").is_some(); - if has_policy_version { - findings.push(error( - PolicyFindingCode::UnsupportedPolicyFormatVersion, - "/PolicyVersion", - "'PolicyVersion' is unsupported; rename it to 'PolicyFormatVersion'", - )); - } - has_schema || has_policy_version -} + fn check_policy_format_version(raw: &serde_json::Value, findings: &mut Findings) { const PATH: &str = "/PolicyFormatVersion"; match raw.get("PolicyFormatVersion") { @@ -261,11 +207,10 @@ fn check_policy_format_version(raw: &serde_json::Value, findings: &mut Findings) )), } } + pub(crate) fn classify_parse_error(parse_error: &serde_json::Error) -> PolicyFinding { let message = parse_error.to_string(); - let code = if message.contains("boolean match arrays") { - PolicyFindingCode::IneffectiveBooleanMatch - } else if message.contains("missing field") { + let code = if message.contains("missing field") { PolicyFindingCode::MissingRequiredField } else if message.contains("unknown field") { PolicyFindingCode::UnknownField @@ -280,6 +225,7 @@ pub(crate) fn classify_parse_error(parse_error: &serde_json::Error) -> PolicyFin format!("policy draft does not match the expected schema: {message}"), ) } + fn check_raw_collection_bounds(raw: &serde_json::Value, findings: &mut Findings) -> bool { let Some(rules) = raw.get("Rules").and_then(serde_json::Value::as_array) else { return false; @@ -297,19 +243,6 @@ fn check_raw_collection_bounds(raw: &serde_json::Value, findings: &mut Findings) for &(field, max) in MATCH_COLLECTION_MAXIMA { check_raw_set_array(matches, field, max, &format!("{base}/Match/{field}"), findings); } - for &field in BOOLEAN_MATCH_FIELDS { - if matches - .get(field) - .and_then(serde_json::Value::as_array) - .is_some_and(|values| values.len() > 1) - { - findings.push(error( - PolicyFindingCode::IneffectiveBooleanMatch, - format!("{base}/Match/{field}"), - "boolean match arrays may contain at most one value", - )); - } - } } if let Some(constraints) = rule.get("Constraints").and_then(serde_json::Value::as_object) { for &(field, max) in CONSTRAINT_COLLECTION_MAXIMA { @@ -328,6 +261,7 @@ fn check_raw_collection_bounds(raw: &serde_json::Value, findings: &mut Findings) } has_error(findings) } + fn check_raw_array_len( object: &serde_json::Map, field: &str, @@ -339,6 +273,7 @@ fn check_raw_array_len( check_max_len(values.len(), max, path, findings); } } + fn check_raw_set_array( object: &serde_json::Map, field: &str, @@ -368,16 +303,17 @@ fn check_raw_set_array( } } } + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum DiskFailureReason { Unreadable, InsecureStorage, MalformedContent, - LegacyPolicyContract, UnsupportedFormat, FailedSemanticValidation, WatcherUnavailable, } + pub(crate) fn disk_failure_finding(reason: DiskFailureReason) -> PolicyFinding { let message = match reason { DiskFailureReason::Unreadable => "the configured policy file could not be opened or read", @@ -385,24 +321,14 @@ pub(crate) fn disk_failure_finding(reason: DiskFailureReason) -> PolicyFinding { DiskFailureReason::MalformedContent => { "the configured policy file does not contain a policy matching the expected schema" } - DiskFailureReason::LegacyPolicyContract => { - "the configured policy file uses the unsupported legacy '$schema' or 'PolicyVersion' field; replace it with the canonical 'PolicyFormatVersion' contract" - } DiskFailureReason::UnsupportedFormat => "the configured policy path uses an unsupported format", DiskFailureReason::FailedSemanticValidation => "the configured policy file failed semantic validation", DiskFailureReason::WatcherUnavailable => "policy change monitoring is unavailable", }; - let code = match reason { - DiskFailureReason::LegacyPolicyContract => PolicyFindingCode::UnsupportedPolicyFormatVersion, - _ => PolicyFindingCode::SchemaViolation, - }; - error(code, "", message) + error(PolicyFindingCode::SchemaViolation, "", message) } + fn semantic_checks(raw: &serde_json::Value, draft: &PolicyDraftDocument, findings: &mut Findings) { - if draft.rules.len() > MAX_RULES { - check_max_len(draft.rules.len(), MAX_RULES, "/Rules", findings); - return; - } check_metadata(&draft.metadata, findings); check_duplicate_rule_ids(&draft.rules, findings); for (index, rule) in draft.rules.iter().enumerate() { @@ -435,12 +361,25 @@ fn semantic_checks(raw: &serde_json::Value, draft: &PolicyDraftDocument, finding check_sensitive_options(raw, index, rule, findings); } } + fn check_metadata(metadata: &PolicyDraftMetadata, findings: &mut Findings) { check_string_len(&metadata.publisher, 1, 128, "/Metadata/Publisher", findings); if let Some(description) = &metadata.description { check_string_len(description, 0, 512, "/Metadata/Description", findings); } - if let (Some(valid_from), Some(valid_until)) = (metadata.valid_from, metadata.valid_until) +} + +fn check_raw_validity_interval(raw: &serde_json::Value, findings: &mut Findings) { + let Some(metadata) = raw.get("Metadata").and_then(serde_json::Value::as_object) else { + return; + }; + let parse = |field| { + metadata + .get(field) + .and_then(serde_json::Value::as_str) + .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) + }; + if let (Some(valid_from), Some(valid_until)) = (parse("ValidFrom"), parse("ValidUntil")) && valid_from >= valid_until { findings.push(error( @@ -450,12 +389,10 @@ fn check_metadata(metadata: &PolicyDraftMetadata, findings: &mut Findings) { )); } } + fn check_duplicate_rule_ids(rules: &[PolicyRule], findings: &mut Findings) { let mut seen: HashMap<&str, usize> = HashMap::new(); for (index, rule) in rules.iter().enumerate() { - if findings.is_saturated() { - return; - } if let Some(first_index) = seen.insert(&rule.id.0, index) { findings.push(rule_finding( rule, @@ -467,9 +404,9 @@ fn check_duplicate_rule_ids(rules: &[PolicyRule], findings: &mut Findings) { } } } + fn check_rule(index: usize, rule: &PolicyRule, findings: &mut Findings) { let base = format!("/Rules/{index}"); - let matches = &rule.match_criteria; if rule.priority > MAX_RULE_PRIORITY { findings.push(rule_finding( rule, @@ -482,136 +419,82 @@ fn check_rule(index: usize, rule: &PolicyRule, findings: &mut Findings) { if let Some(reason) = &rule.reason { check_string_len(reason, 0, 512, &format!("{base}/Reason"), findings); } - check_max_len(matches.managers.len(), 16, &format!("{base}/Match/Managers"), findings); - check_max_len(matches.sources.len(), 128, &format!("{base}/Match/Sources"), findings); - check_max_len( - matches.package_identifiers.len(), - 1024, - &format!("{base}/Match/PackageIdentifiers"), - findings, - ); - check_max_len( - matches.package_names.len(), - 1024, - &format!("{base}/Match/PackageNames"), - findings, - ); - check_max_len(matches.versions.len(), 256, &format!("{base}/Match/Versions"), findings); - if !matches.package_names.is_empty() { - findings.push(rule_finding( + if let Some(PackageIdentifierCondition::Patterns(patterns)) = &rule.match_criteria.package_identifiers { + check_patterns( + index, rule, - PolicyFindingSeverity::Error, - PolicyFindingCode::InvalidFieldValue, - format!("{base}/Match/PackageNames"), - "PackageNames is unsupported because requests do not provide a package display name", - )); + "Match/PackageIdentifiers/Patterns", + patterns.iter().map(AsRef::as_ref), + findings, + ); + } + if let Some(VersionCondition::Range(range)) = &rule.match_criteria.version { + check_version_range(index, rule, range, findings); } - check_version_range(index, rule, findings); - check_patterns( - index, - rule, - "Match/Sources", - matches.sources.iter().take(128).map(AsRef::as_ref), - findings, - ); - check_patterns( - index, - rule, - "Match/PackageIdentifiers", - matches.package_identifiers.iter().take(1024).map(AsRef::as_ref), - findings, - ); if let Some(constraints) = &rule.constraints { check_constraints(index, rule, constraints, findings); } } + fn check_constraints(index: usize, rule: &PolicyRule, constraints: &PolicyConstraints, findings: &mut Findings) { let base = format!("/Rules/{index}/Constraints"); - check_max_len( - constraints.allowed_install_location_patterns.len(), - 64, - &format!("{base}/AllowedInstallLocationPatterns"), - findings, - ); - check_max_len( - constraints.allowed_custom_parameters.len(), - 128, - &format!("{base}/AllowedCustomParameters"), - findings, - ); - check_max_len( - constraints.allowed_custom_parameter_patterns.len(), - 128, - &format!("{base}/AllowedCustomParameterPatterns"), - findings, - ); - check_max_len( - constraints.denied_custom_parameters.len(), - 128, - &format!("{base}/DeniedCustomParameters"), - findings, - ); check_patterns( index, rule, "Constraints/AllowedInstallLocationPatterns", - constraints - .allowed_install_location_patterns - .iter() - .take(64) - .map(AsRef::as_ref), + constraints.allowed_install_location_patterns.iter().map(AsRef::as_ref), findings, ); check_patterns( index, rule, "Constraints/AllowedCustomParameterPatterns", - constraints - .allowed_custom_parameter_patterns - .iter() - .take(128) - .map(AsRef::as_ref), + constraints.allowed_custom_parameter_patterns.iter().map(AsRef::as_ref), findings, ); - let matches = &rule.match_criteria; - for (values, allowed, name) in [ - (&matches.interactive, constraints.allow_interactive, "Interactive"), + for (value, allowed, name) in [ + ( + rule.match_criteria.interactive, + constraints.allow_interactive, + "Interactive", + ), ( - &matches.skip_hash_check, + rule.match_criteria.skip_hash_check, constraints.allow_skip_hash_check, "SkipHashCheck", ), - (&matches.pre_release, constraints.allow_pre_release, "PreRelease"), ( - &matches.has_custom_install_location, + rule.match_criteria.pre_release, + constraints.allow_pre_release, + "PreRelease", + ), + ( + rule.match_criteria.has_custom_install_location, constraints.allow_custom_install_location, "HasCustomInstallLocation", ), ( - &matches.has_custom_parameters, + rule.match_criteria.has_custom_parameters, constraints.allow_custom_parameters, "HasCustomParameters", ), ( - &matches.has_pre_post_commands, + rule.match_criteria.has_pre_post_commands, constraints.allow_pre_post_commands, "HasPrePostCommands", ), ( - &matches.has_kill_before_operation, + rule.match_criteria.has_kill_before_operation, constraints.allow_kill_before_operation, "HasKillBeforeOperation", ), ( - &matches.has_uninstall_previous, + rule.match_criteria.has_uninstall_previous, constraints.allow_uninstall_previous, "HasUninstallPrevious", ), ] { - if findings.is_saturated() { - return; - } - if !allowed && values.len() == 1 && values.contains(&true) { + if value == Some(true) && !allowed { findings.push(rule_finding( rule, PolicyFindingSeverity::Error, @@ -622,32 +505,18 @@ fn check_constraints(index: usize, rule: &PolicyRule, constraints: &PolicyConstr } } } -fn check_version_range(index: usize, rule: &PolicyRule, findings: &mut Findings) { - let Some(range) = &rule.match_criteria.version_range else { - return; - }; - let base = format!("/Rules/{index}/Match/VersionRange"); - let min = parse_version_bound( - range.min_version.as_deref(), - &format!("{base}/MinVersion"), - rule, - findings, - ); - let max = parse_version_bound( - range.max_version.as_deref(), - &format!("{base}/MaxVersion"), - rule, - findings, - ); - if range.min_version.is_none() && range.max_version.is_none() { - findings.push(rule_finding( - rule, - PolicyFindingSeverity::Error, - PolicyFindingCode::EmptyVersionRange, - &base, - "version range must specify MinVersion or MaxVersion", - )); - } else if let (Some(min), Some(max)) = (min.as_ref(), max.as_ref()) + +fn check_version_range(index: usize, rule: &PolicyRule, range: &now_policy::VersionRange, findings: &mut Findings) { + let base = format!("/Rules/{index}/Match/Version/Range"); + let min = range + .min_version + .as_ref() + .and_then(|version| semver::Version::parse(version).ok()); + let max = range + .max_version + .as_ref() + .and_then(|version| semver::Version::parse(version).ok()); + if let (Some(min), Some(max)) = (min.as_ref(), max.as_ref()) && min > max { findings.push(rule_finding( @@ -675,37 +544,7 @@ fn check_version_range(index: usize, rule: &PolicyRule, findings: &mut Findings) } } } -fn parse_version_bound( - value: Option<&str>, - path: &str, - rule: &PolicyRule, - findings: &mut Findings, -) -> Option { - let value = value?; - if value.is_empty() || value.len() > 128 { - findings.push(rule_finding( - rule, - PolicyFindingSeverity::Error, - PolicyFindingCode::InvalidVersionRange, - path, - "version bound must contain 1 to 128 characters", - )); - return None; - } - match semver::Version::parse(value) { - Ok(version) => Some(version), - Err(parse_error) => { - findings.push(rule_finding( - rule, - PolicyFindingSeverity::Error, - PolicyFindingCode::InvalidVersionRange, - path, - format!("invalid semantic version: {parse_error}"), - )); - None - } - } -} + fn check_patterns>( index: usize, rule: &PolicyRule, @@ -714,11 +553,7 @@ fn check_patterns>( findings: &mut Findings, ) { for pattern in patterns { - if findings.is_saturated() { - return; - } - let pattern = pattern.as_ref(); - let regex = format!("^{}$", regex::escape(pattern).replace(r"\*", ".*")); + let regex = format!("^{}$", regex::escape(pattern.as_ref()).replace(r"\*", ".*")); if regex::RegexBuilder::new(®ex).case_insensitive(true).build().is_err() { findings.push(rule_finding( rule, @@ -730,6 +565,7 @@ fn check_patterns>( } } } + fn check_sensitive_options(raw: &serde_json::Value, index: usize, rule: &PolicyRule, findings: &mut Findings) { if !rule.enabled || rule.decision != Decision::Allow { return; @@ -737,51 +573,50 @@ fn check_sensitive_options(raw: &serde_json::Value, index: usize, rule: &PolicyR let defaults = PolicyConstraints::default(); let constraints = rule.constraints.as_ref().unwrap_or(&defaults); let matches: &PolicyMatch = &rule.match_criteria; - let reachable = |values: &BTreeSet| values.is_empty() || values.contains(&true); + let reachable = |value: Option| value != Some(false); let options = [ ( - constraints.allow_skip_hash_check && reachable(&matches.skip_hash_check), + constraints.allow_skip_hash_check && reachable(matches.skip_hash_check), "SkipHashCheck", "SkipHashCheck", "AllowSkipHashCheck", ), ( - constraints.allow_pre_release && reachable(&matches.pre_release), + constraints.allow_pre_release && reachable(matches.pre_release), "PreRelease", "PreRelease", "AllowPreRelease", ), ( - constraints.allow_custom_install_location && reachable(&matches.has_custom_install_location), + constraints.allow_custom_install_location && reachable(matches.has_custom_install_location), "AllowCustomInstallLocation", "HasCustomInstallLocation", "AllowCustomInstallLocation", ), ( - constraints.allow_pre_post_commands && reachable(&matches.has_pre_post_commands), + constraints.allow_pre_post_commands && reachable(matches.has_pre_post_commands), "AllowPrePostCommands", "HasPrePostCommands", "AllowPrePostCommands", ), ( - constraints.allow_kill_before_operation && reachable(&matches.has_kill_before_operation), + constraints.allow_kill_before_operation && reachable(matches.has_kill_before_operation), "AllowKillBeforeOperation", "HasKillBeforeOperation", "AllowKillBeforeOperation", ), ( - constraints.allow_uninstall_previous && reachable(&matches.has_uninstall_previous), + constraints.allow_uninstall_previous && reachable(matches.has_uninstall_previous), "AllowUninstallPrevious", "HasUninstallPrevious", "AllowUninstallPrevious", ), ( constraints.allow_custom_parameters - && reachable(&matches.has_custom_parameters) + && reachable(matches.has_custom_parameters) && !constraints .denied_custom_parameters .iter() - .take(128) .any(|pattern| pattern.as_ref() == "*"), "AllowCustomParameters", "HasCustomParameters", @@ -789,9 +624,6 @@ fn check_sensitive_options(raw: &serde_json::Value, index: usize, rule: &PolicyR ), ]; for (enabled, option, match_field, constraint_field) in options { - if findings.is_saturated() { - return; - } if enabled { let rule_path = format!("/Rules/{index}"); let match_path = format!("{rule_path}/Match/{match_field}"); @@ -817,6 +649,7 @@ fn check_sensitive_options(raw: &serde_json::Value, index: usize, rule: &PolicyR } } } + fn check_string_len(value: &str, min: usize, max: usize, path: &str, findings: &mut Findings) { let length = value.chars().count(); if !(min..=max).contains(&length) { @@ -827,6 +660,7 @@ fn check_string_len(value: &str, min: usize, max: usize, path: &str, findings: & )); } } + fn check_max_len(len: usize, max: usize, path: &str, findings: &mut Findings) { if len > max { findings.push(error( @@ -842,490 +676,127 @@ mod tests { use serde_json::json; use super::*; + fn draft() -> serde_json::Value { json!({ "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "policy-a", "Publisher": "Test" }, - "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Enforcement": { "DefaultDecision": "Deny" }, "Rules": [] }) } + fn rule(id: &str, match_value: serde_json::Value) -> serde_json::Value { - json!({ - "Id": id, - "Priority": 1, - "Decision": "Deny", - "Match": match_value - }) - } - fn has_code(result: &PolicyValidationResult, code: PolicyFindingCode) -> bool { - result.findings.iter().any(|finding| finding.code == code) - } - #[test] - fn strict_valid_draft_is_canonicalized_deterministically() { - let raw = draft(); - let first = validate_draft(&raw); - let second = validate_draft(&raw); - assert!(first.is_valid); - assert_eq!( - serde_json::to_value(first.canonical_draft).expect("serialize canonical draft"), - serde_json::to_value(second.canonical_draft).expect("serialize canonical draft") - ); - } - #[test] - fn constants_and_unknown_fields_are_rejected() { - for (pointer, value, code) in [ - ( - "/PolicyType", - json!("OtherPolicy"), - PolicyFindingCode::UnsupportedPolicyType, - ), - ( - "/PolicyFormatVersion", - json!("2.0.0"), - PolicyFindingCode::UnsupportedPolicyFormatVersion, - ), - ] { - let mut raw = draft(); - *raw.pointer_mut(pointer).expect("pointer exists") = value; - assert!(has_code(&validate_draft(&raw), code)); - } - let mut raw = draft(); - raw["Unexpected"] = json!(true); - assert!(has_code(&validate_draft(&raw), PolicyFindingCode::UnknownField)); - } - #[test] - fn legacy_policy_identity_is_rejected_with_precise_diagnostics() { - for (legacy_field, expected_message) in [ - ( - "$schema", - "'$schema' is unsupported; remove it and use 'PolicyFormatVersion'", - ), - ( - "PolicyVersion", - "'PolicyVersion' is unsupported; rename it to 'PolicyFormatVersion'", - ), - ] { - let mut raw = draft(); - raw[legacy_field] = json!("1.0.0"); - let result = validate_draft(&raw); - assert!(!result.is_valid); - assert_eq!(result.findings.len(), 1); - assert_eq!( - result.findings[0].code, - PolicyFindingCode::UnsupportedPolicyFormatVersion - ); - assert_eq!(result.findings[0].message, expected_message); - } - } - #[test] - fn compatible_policy_format_version_is_preserved() { - let mut raw = draft(); - raw["PolicyFormatVersion"] = json!("1.7.3"); - let result = validate_draft(&raw); - assert!(result.is_valid); - assert_eq!( - serde_json::to_value(result.canonical_draft) - .expect("serialize canonical draft") - .pointer("/PolicyFormatVersion"), - Some(&json!("1.7.3")) - ); - } - #[test] - fn structural_bounds_and_duplicate_ids_are_rejected() { - let mut raw = draft(); - raw["Metadata"]["Publisher"] = json!("x".repeat(129)); - assert!(has_code(&validate_draft(&raw), PolicyFindingCode::SchemaViolation)); - let mut raw = draft(); - raw["Rules"] = json!([ - rule("duplicate", json!({ "Managers": ["Winget"] })), - rule("duplicate", json!({ "Managers": ["Npm"] })) - ]); - assert!(has_code(&validate_draft(&raw), PolicyFindingCode::DuplicateRuleId)); - } - #[test] - fn oversized_rules_stop_after_one_structural_finding() { - let mut raw = draft(); - raw["Rules"] = serde_json::Value::Array( - (0..=MAX_RULES) - .map(|_| rule("duplicate", json!({ "Managers": ["Winget"] }))) - .collect(), - ); - let started = std::time::Instant::now(); - let result = validate_draft(&raw); - assert!(started.elapsed() < std::time::Duration::from_secs(5)); - assert!(!result.is_valid); - assert!(result.canonical_draft.is_none()); - assert!(result.validation_receipt.is_none()); - assert_eq!(result.findings.len(), 1); - assert_eq!(result.findings[0].code, PolicyFindingCode::SchemaViolation); - assert_eq!(result.findings[0].path, "/Rules"); - } - #[test] - fn warning_findings_are_capped_without_invalidating_the_draft() { - let mut raw = draft(); - raw["Rules"] = serde_json::Value::Array( - (0..64) - .map(|index| { - let mut value = rule(&format!("allow-{index}"), json!({ "Managers": ["Winget"] })); - value["Decision"] = json!("Allow"); - value - }) - .collect(), - ); - let started = std::time::Instant::now(); - let first = validate_draft(&raw); - let second = validate_draft(&raw); - assert!(started.elapsed() < std::time::Duration::from_secs(5)); - assert!(first.is_valid); - assert!(first.canonical_draft.is_some()); - assert!(first.validation_receipt.is_none()); - assert_eq!(first.findings.len(), MAX_FINDINGS); - assert!( - first - .findings - .iter() - .all(|finding| finding.severity == PolicyFindingSeverity::Warning) - ); - assert_eq!( - serde_json::to_value(&first.findings).expect("serialize findings"), - serde_json::to_value(&second.findings).expect("serialize findings") - ); + json!({ "Id": id, "Priority": 1, "Decision": "Deny", "Match": match_value }) } #[test] - fn warning_heavy_draft_with_a_late_error_remains_invalid() { - let mut raw = draft(); - let mut rules: Vec<_> = (0..64) - .map(|index| { - let mut value = rule(&format!("allow-{index}"), json!({ "Managers": ["Winget"] })); - value["Decision"] = json!("Allow"); - value - }) - .collect(); - let mut invalid = rule("invalid-last", json!({ "Managers": ["Winget"] })); - invalid["Priority"] = json!(u64::from(MAX_RULE_PRIORITY) + 1); - rules.push(invalid); - raw["Rules"] = serde_json::Value::Array(rules); - let result = validate_draft(&raw); - assert!(!result.is_valid); - assert!(result.canonical_draft.is_none()); - assert!(result.validation_receipt.is_none()); - assert!(result.findings.iter().any(|finding| { - finding.severity == PolicyFindingSeverity::Error - && finding.code == PolicyFindingCode::InvalidFieldValue - && finding.path == "/Rules/64/Priority" - })); - } - #[test] - fn oversized_pattern_collections_have_bounded_ordered_findings() { + fn final_contract_is_canonical_and_uses_scalar_booleans() { let mut raw = draft(); - let sources: Vec<_> = (0..2048).map(|index| json!(format!("source-{index}"))).collect(); - let packages: Vec<_> = (0..2048).map(|index| json!(format!("package-{index}"))).collect(); raw["Rules"] = json!([rule( - "oversized", - json!({ "Sources": sources, "PackageIdentifiers": packages }) - )]); - let result = validate_draft(&raw); - assert!(!result.is_valid); - assert_eq!(result.findings.len(), 2); - assert_eq!(result.findings[0].path, "/Rules/0/Match/Sources"); - assert_eq!(result.findings[1].path, "/Rules/0/Match/PackageIdentifiers"); - } - #[test] - fn every_schema_array_bound_is_rejected_before_typed_parsing() { - let collections = MATCH_COLLECTION_MAXIMA - .iter() - .map(|&(field, max)| ("Match", field, max)) - .chain(BOOLEAN_MATCH_FIELDS.iter().map(|&field| ("Match", field, 1))) - .chain( - CONSTRAINT_COLLECTION_MAXIMA - .iter() - .map(|&(field, max)| ("Constraints", field, max)), - ); - for (section, field, max) in collections { - let mut raw = draft(); - let mut value = rule("bounded", json!({ "Managers": ["Winget"] })); - value[section][field] = serde_json::Value::Array(vec![json!(false); max + 1]); - raw["Rules"] = json!([value]); - let result = validate_draft(&raw); - assert!(!result.is_valid, "{section}/{field}"); - assert!(result.canonical_draft.is_none(), "{section}/{field}"); - assert!(result.validation_receipt.is_none(), "{section}/{field}"); - assert_eq!(result.findings.len(), 1, "{section}/{field}"); - assert_eq!(result.findings[0].path, format!("/Rules/0/{section}/{field}")); - } - } - - #[test] - fn set_backed_match_arrays_reject_exact_duplicates() { - for (field, value) in [ - ("Operations", "Install"), - ("Managers", "Winget"), - ("Sources", "source"), - ("PackageIdentifiers", "package"), - ("PackageNames", "name"), - ("Versions", "1.0.0"), - ("Scopes", "User"), - ("Architectures", "X64"), - ("Elevation", "Elevated"), - ] { - let mut raw = draft(); - let mut duplicate = rule("duplicate", json!({ "Managers": ["Winget"] })); - duplicate["Match"][field] = json!([value, value]); - raw["Rules"] = json!([duplicate]); - let result = validate_draft(&raw); - assert!(!result.is_valid, "{field}"); - assert!(result.canonical_draft.is_none(), "{field}"); - assert!(result.validation_receipt.is_none(), "{field}"); - assert!(result.findings.iter().any(|finding| { - finding.code == PolicyFindingCode::SchemaViolation - && finding.path == format!("/Rules/0/Match/{field}") - && finding.message.contains("duplicate value") - })); - } - } - - #[test] - fn raw_uniqueness_is_case_sensitive_and_excludes_constraint_vectors() { - let mut raw = draft(); - let mut distinct = rule( - "distinct", + "allow", json!({ - "Operations": ["Install", "Update"], - "Managers": ["Winget", "Npm"], - "Sources": ["source", "Source"], - "PackageIdentifiers": ["package", "Package"], - "Versions": ["1.0.0", "2.0.0"], - "Scopes": ["User", "Machine"], - "Architectures": ["X64", "Arm64"], - "Elevation": ["Standard", "Elevated"] - }), - ); - distinct["Constraints"] = json!({ - "AllowedInstallLocationPatterns": ["C:\\Tools", "C:\\Tools"] - }); - raw["Rules"] = json!([distinct]); + "Managers": ["Winget"], + "SourceNames": ["winget"], + "PackageIdentifiers": { "Exact": ["Microsoft.PowerToys"] }, + "Interactive": false + }) + )]); let result = validate_draft(&raw); assert!(result.is_valid); - assert_eq!( - result.canonical_draft.expect("valid canonical draft").rules[0] - .constraints - .as_ref() - .expect("constraints") - .allowed_install_location_patterns - .len(), - 2 - ); + let canonical = + serde_json::to_value(result.canonical_draft.expect("canonical draft")).expect("serialize draft"); + assert_eq!(canonical.pointer("/Rules/0/Match/Interactive"), Some(&json!(false))); } #[test] - fn large_boolean_arrays_are_rejected_quickly_and_deterministically() { - let oversized = serde_json::Value::Array(vec![json!(true); 125_000]); - let mut match_value = serde_json::Map::new(); - for field in BOOLEAN_MATCH_FIELDS { - match_value.insert((*field).to_owned(), oversized.clone()); + fn shared_contract_rejects_invalid_rule_shapes() { + let cases = [ + json!({ "SourceNames": ["winget"] }), + json!({ "Managers": ["Winget"], "Interactive": [true] }), + json!({ "Managers": ["Winget"], "PackageIdentifiers": { "Exact": [] } }), + ]; + for match_value in cases { + let mut raw = draft(); + raw["Rules"] = json!([rule("rule", match_value)]); + assert!(!validate_draft(&raw).is_valid); } + let mut deny_with_constraints = rule("rule", json!({ "Managers": ["Winget"] })); + deny_with_constraints["Constraints"] = json!({ "AllowInteractive": false }); let mut raw = draft(); - raw["Rules"] = json!([rule("booleans", match_value.into())]); - let started = std::time::Instant::now(); - let first = validate_draft(&raw); - let second = validate_draft(&raw); - assert!(started.elapsed() < std::time::Duration::from_secs(5)); - assert!(!first.is_valid && first.canonical_draft.is_none() && first.validation_receipt.is_none()); - assert_eq!(first.findings.len(), BOOLEAN_MATCH_FIELDS.len()); - assert_eq!( - serde_json::to_value(first.findings).expect("serialize findings"), - serde_json::to_value(second.findings).expect("serialize findings") - ); + raw["Rules"] = json!([deny_with_constraints]); + assert!(!validate_draft(&raw).is_valid); } #[test] - fn ineffective_boolean_matches_and_unsupported_criteria_are_rejected() { + fn validity_window_must_be_strictly_increasing() { let mut raw = draft(); - raw["Rules"] = json!([rule("r1", json!({ "Interactive": [false, true] }))]); - assert!(has_code( - &validate_draft(&raw), - PolicyFindingCode::IneffectiveBooleanMatch - )); - raw["Rules"] = json!([rule("r1", json!({ "PackageNames": ["Display Name"] }))]); - assert!(has_code(&validate_draft(&raw), PolicyFindingCode::InvalidFieldValue)); + raw["Metadata"]["ValidFrom"] = json!("2026-01-01T00:00:00Z"); + raw["Metadata"]["ValidUntil"] = json!("2026-01-01T00:00:00Z"); + let result = validate_draft(&raw); + assert!(!result.is_valid); + assert_eq!(result.findings[0].code, PolicyFindingCode::InvalidValidityInterval); + assert_eq!(result.findings[0].path, "/Metadata/ValidUntil"); } #[test] - fn invalid_ranges_validity_and_contradictions_are_rejected() { + fn version_range_must_contain_a_stable_version_without_prerelease_opt_in() { let mut raw = draft(); raw["Rules"] = json!([rule( - "r1", - json!({ "VersionRange": { "MinVersion": "2.0.0", "MaxVersion": "1.0.0" } }) + "rule", + json!({ + "Managers": ["Winget"], + "Version": { + "Range": { + "MinVersion": "1.0.0-alpha", + "MaxVersion": "1.0.0-beta", + "IncludePrerelease": false + } + } + }), )]); - assert!(has_code(&validate_draft(&raw), PolicyFindingCode::EmptyVersionRange)); - let mut raw = draft(); - let mut contradictory = rule("r1", json!({ "Interactive": [true] })); - contradictory["Constraints"] = json!({ "AllowInteractive": false }); - raw["Rules"] = json!([contradictory]); - assert!(has_code( - &validate_draft(&raw), - PolicyFindingCode::ContradictoryConstraints - )); - } - - #[test] - fn prerelease_exclusion_rejects_ranges_without_stable_versions() { - for (min, max, include_prerelease, expected_valid) in [ - (Some("1.0.0-alpha"), Some("1.0.0-beta"), false, false), - (Some("1.0.0-alpha"), Some("1.0.0-beta"), true, true), - (Some("1.0.0-alpha"), Some("1.0.0"), false, true), - (None, Some("0.0.0-alpha"), false, false), - (None, Some("0.0.0"), false, true), - (Some("1.0.0"), Some("2.0.0-alpha"), false, true), - ] { - let mut raw = draft(); - let mut range = json!({ "IncludePrerelease": include_prerelease }); - if let Some(min) = min { - range["MinVersion"] = json!(min); - } - if let Some(max) = max { - range["MaxVersion"] = json!(max); - } - raw["Rules"] = json!([rule("range", json!({ "VersionRange": range }))]); - let result = validate_draft(&raw); - assert_eq!( - result.is_valid, expected_valid, - "{min:?}..{max:?}, prerelease={include_prerelease}" - ); - assert_eq!(result.validator_version, VALIDATOR_VERSION); - if expected_valid { - assert!(result.canonical_draft.is_some()); - } else { - assert!(result.canonical_draft.is_none()); - assert!(result.validation_receipt.is_none()); - let finding = result - .findings - .iter() - .find(|finding| finding.code == PolicyFindingCode::EmptyVersionRange) - .expect("empty range finding"); - assert_eq!(finding.path, "/Rules/0/Match/VersionRange"); - } - } + let result = validate_draft(&raw); + assert!(!result.is_valid); + assert_eq!(result.findings[0].code, PolicyFindingCode::EmptyVersionRange); + assert_eq!(result.findings[0].path, "/Rules/0/Match/Version/Range"); } #[test] - fn validity_interval_requires_strictly_increasing_instants() { - for (valid_from, valid_until, expected_valid) in [ - (None, None, true), - (Some("2026-01-01T00:00:00Z"), None, true), - (None, Some("2026-01-01T00:00:00Z"), true), - (Some("2026-01-01T00:00:00Z"), Some("2026-01-01T00:00:01Z"), true), - (Some("2026-01-01T00:00:00Z"), Some("2026-01-01T00:00:00Z"), false), - (Some("2026-01-01T00:00:00Z"), Some("2025-12-31T19:00:00-05:00"), false), - (Some("2026-02-01T00:00:00Z"), Some("2026-01-01T00:00:00Z"), false), + fn metadata_schema_bounds_are_enforced() { + for (pointer, value) in [ + ("/Metadata/Publisher", json!("")), + ("/Metadata/Publisher", json!("x".repeat(129))), + ("/Metadata/Description", json!("x".repeat(513))), ] { let mut raw = draft(); - if let Some(valid_from) = valid_from { - raw["Metadata"]["ValidFrom"] = json!(valid_from); - } - if let Some(valid_until) = valid_until { - raw["Metadata"]["ValidUntil"] = json!(valid_until); - } - let result = validate_draft(&raw); - assert_eq!(result.is_valid, expected_valid, "{valid_from:?}..{valid_until:?}"); - assert_eq!(result.validator_version, VALIDATOR_VERSION); - if expected_valid { - assert!(result.canonical_draft.is_some()); + if pointer == "/Metadata/Description" { + raw["Metadata"]["Description"] = value; } else { - assert!(result.canonical_draft.is_none()); - assert!(result.validation_receipt.is_none()); - let finding = result - .findings - .iter() - .find(|finding| finding.code == PolicyFindingCode::InvalidValidityInterval) - .expect("invalid interval finding"); - assert_eq!(finding.path, "/Metadata/ValidUntil"); - assert_eq!(finding.message, "ValidUntil must be after ValidFrom"); + raw["Metadata"]["Publisher"] = value; } + assert!(!validate_draft(&raw).is_valid, "{pointer} must be rejected"); } } #[test] - fn risky_postures_produce_ordered_warnings() { + fn empty_match_collections_are_omitted_from_canonical_drafts() { let mut raw = draft(); - raw["Enforcement"]["AuditMode"] = json!(true); - raw["Enforcement"]["DefaultDecision"] = json!("Allow"); - let mut allow = rule("allow", json!({ "Managers": ["Winget"] })); - allow["Decision"] = json!("Allow"); - raw["Rules"] = json!([allow]); + raw["Rules"] = json!([rule("rule", json!({ "Managers": ["Winget"], "Scopes": [] }))]); let result = validate_draft(&raw); assert!(result.is_valid); - assert_eq!(result.findings[0].code, PolicyFindingCode::AuditModeEnabled); - assert_eq!(result.findings[1].code, PolicyFindingCode::DefaultAllow); - assert!(has_code(&result, PolicyFindingCode::SensitiveOptionAllowed)); - } - - #[test] - fn sensitive_option_warnings_point_into_the_submitted_draft() { - let options = [ - ("SkipHashCheck", "SkipHashCheck", "AllowSkipHashCheck"), - ("PreRelease", "PreRelease", "AllowPreRelease"), - ( - "AllowCustomInstallLocation", - "HasCustomInstallLocation", - "AllowCustomInstallLocation", - ), - ("AllowPrePostCommands", "HasPrePostCommands", "AllowPrePostCommands"), - ( - "AllowKillBeforeOperation", - "HasKillBeforeOperation", - "AllowKillBeforeOperation", - ), - ( - "AllowUninstallPrevious", - "HasUninstallPrevious", - "AllowUninstallPrevious", - ), - ("AllowCustomParameters", "HasCustomParameters", "AllowCustomParameters"), - ]; - for (option, match_field, constraint_field) in options { - for explicit in ["Match", "Constraints", "Default"] { - let mut raw = draft(); - let mut allow = rule("allow", json!({ "Managers": ["Winget"] })); - allow["Decision"] = json!("Allow"); - match explicit { - "Match" => allow["Match"][match_field] = json!([true]), - "Constraints" => { - allow["Constraints"] = json!({}); - allow["Constraints"][constraint_field] = json!(true); - } - "Default" => {} - _ => unreachable!(), - } - raw["Rules"] = json!([allow]); - let result = validate_draft(&raw); - assert!(result.is_valid, "{option} via {explicit}"); - let finding = result - .findings - .iter() - .find(|finding| finding.arguments.get("option") == Some(&json!(option))) - .unwrap_or_else(|| panic!("missing {option} finding via {explicit}")); - let expected_path = match explicit { - "Match" => format!("/Rules/0/Match/{match_field}"), - "Constraints" => format!("/Rules/0/Constraints/{constraint_field}"), - "Default" => "/Rules/0".to_owned(), - _ => unreachable!(), - }; - assert_eq!(finding.path, expected_path); - assert!(raw.pointer(&finding.path).is_some(), "missing {}", finding.path); - assert!(!finding.arguments.contains_key("Option")); - } - } + let canonical = + serde_json::to_value(result.canonical_draft.expect("canonical draft")).expect("serialize draft"); + assert!(canonical.pointer("/Rules/0/Match/Scopes").is_none()); } #[test] - fn disk_diagnostics_are_sanitized_and_bounded() { - let finding = disk_failure_finding(DiskFailureReason::MalformedContent); - assert_eq!(finding.code, PolicyFindingCode::SchemaViolation); - assert!(!finding.message.contains("secret")); - assert!(finding.message.chars().count() <= MAX_FINDING_MESSAGE_CHARS); + fn null_boolean_criteria_are_absent_in_canonical_drafts() { + let mut raw = draft(); + raw["Rules"] = json!([rule("rule", json!({ "Managers": ["Winget"], "Interactive": null }))]); + let result = validate_draft(&raw); + assert!(result.is_valid); + let canonical = + serde_json::to_value(result.canonical_draft.expect("canonical draft")).expect("serialize draft"); + assert!(canonical.pointer("/Rules/0/Match/Interactive").is_none()); } } 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..972029cf2 --- /dev/null +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -0,0 +1,5004 @@ +//! 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. + +use std::ffi::{OsStr, OsString}; +use std::fs::{File, OpenOptions}; +use std::mem::size_of; +use std::os::windows::ffi::{OsStrExt as _, OsStringExt as _}; +use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, bail, ensure}; +use now_policy::PolicyDocument; +use now_policy_api::{ + API_VERSION_STR, InvalidPolicyDiagnostics, PolicyConfigurationSource, PolicyManagementState, PolicyReadOnlyReason, + PolicyStoreToken, PolicyWriteCapability, +}; +use sha2::{Digest as _, Sha256}; +use win_api_wrappers::str::{U16CStrExt as _, U16CString}; +use win_api_wrappers::undoc::OBJECT_ATTRIBUTES; +use windows::Win32::Foundation::{ + ERROR_ACCESS_DENIED, ERROR_ALREADY_EXISTS, ERROR_FILE_EXISTS, ERROR_INVALID_FUNCTION, ERROR_INVALID_PARAMETER, + ERROR_NOT_SUPPORTED, ERROR_SHARING_VIOLATION, GENERIC_READ, GENERIC_WRITE, HANDLE, NTSTATUS, UNICODE_STRING, + WIN32_ERROR, +}; +use windows::Win32::Storage::FileSystem::{ + CREATE_NEW, CreateFileW, DELETE, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, + FILE_DISPOSITION_FLAG_DELETE, FILE_DISPOSITION_FLAG_IGNORE_READONLY_ATTRIBUTE, + FILE_DISPOSITION_FLAG_POSIX_SEMANTICS, FILE_DISPOSITION_INFO_EX, FILE_DISPOSITION_INFO_EX_FLAGS, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAG_WRITE_THROUGH, FILE_GENERIC_READ, + FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_RENAME_INFO_0, FILE_SHARE_DELETE, + FILE_SHARE_NONE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, FileDispositionInfoEx, FileRenameInfo, + FileRenameInfoEx, GetVolumeInformationW, GetVolumePathNameW, READ_CONTROL, SetFileInformationByHandle, +}; +#[cfg(test)] +use windows::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MoveFileExW}; + +use crate::policy_security::{self, FileIdentity}; +use crate::policy_store::validation; + +/// Base file name for the policy file (a fixed name inside its dedicated directory). +pub(super) const POLICY_FILE_NAME: &str = "package-broker-policy.json"; +const FILE_SYNCHRONIZE: u32 = 0x0010_0000; +const FILE_RENAME_INFORMATION_EX_CLASS: i32 = 65; +const FILE_NON_DIRECTORY_FILE: u32 = 0x0000_0040; +const FILE_OPEN_REPARSE_POINT: u32 = 0x0020_0000; +const OBJ_CASE_INSENSITIVE: u32 = 0x0000_0040; + +#[repr(C)] +struct IoStatusBlock { + status_or_pointer: usize, + information: usize, +} + +#[link(name = "ntdll")] +unsafe extern "system" { + fn NtOpenFile( + file_handle: *mut HANDLE, + desired_access: u32, + object_attributes: *const OBJECT_ATTRIBUTES, + io_status_block: *mut IoStatusBlock, + share_access: u32, + open_options: u32, + ) -> NTSTATUS; + + fn NtSetInformationFile( + file_handle: HANDLE, + io_status_block: *mut IoStatusBlock, + file_information: *const core::ffi::c_void, + length: u32, + file_information_class: i32, + ) -> NTSTATUS; +} + +/// 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 `.yaml`/`.yml` (or extensionless) configured path is rejected +/// up front rather than discovered only when its content fails to parse as JSON. +#[derive(Debug)] +enum ConfiguredPathError { + UnsafeShape(String), + UnsupportedFormat(String), +} + +impl std::fmt::Display for ConfiguredPathError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnsafeShape(message) | Self::UnsupportedFormat(message) => f.write_str(message), + } + } +} + +fn validate_configured_path_shape(path: &Path) -> Result<(), ConfiguredPathError> { + if !path.is_absolute() { + return Err(ConfiguredPathError::UnsafeShape(format!( + "configured policy path must be absolute: {}", + path.display() + ))); + } + + let raw = path.as_os_str().to_string_lossy(); + if raw.ends_with('\\') || raw.ends_with('/') { + return Err(ConfiguredPathError::UnsafeShape(format!( + "configured policy path must not end with a path separator: {}", + path.display() + ))); + } + + // Detected on the *raw* configured string, not via `path.components()`: per + // `Path::components()`'s own documented normalization, an intermediate `.` segment + // (e.g. `C:\foo\.\bar.json`) is silently normalized away and never surfaces as a + // `Component::CurDir` at all, so a components-based check would never catch it. + for segment in raw.split(['\\', '/']) { + if segment == "." { + return Err(ConfiguredPathError::UnsafeShape(format!( + "configured policy path must not contain a '.' component: {}", + path.display() + ))); + } + if segment == ".." { + return Err(ConfiguredPathError::UnsafeShape(format!( + "configured policy path must not contain a '..' component: {}", + path.display() + ))); + } + } + + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + return Err(ConfiguredPathError::UnsafeShape(format!( + "configured policy path must name a file: {}", + path.display() + ))); + }; + + let has_json_extension = Path::new(file_name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")); + if !has_json_extension { + return Err(ConfiguredPathError::UnsupportedFormat(format!( + "configured policy path must name a '.json' file (case-insensitive), got '{file_name}'; \ + the package broker no longer supports any other format" + ))); + } + + Ok(()) +} + +/// Outcome of the one-time filesystem atomic-replace capability probe, classified into +/// the advisory reason it would map to if unwritable. +type ProbeResult = Result<(), (PolicyReadOnlyReason, String)>; + +fn probe_failure_capability(reason: PolicyReadOnlyReason) -> PolicyWriteCapability { + match reason { + PolicyReadOnlyReason::UnsupportedFileSystem => PolicyWriteCapability::Unsupported, + _ => PolicyWriteCapability::ReadOnly, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct AtomicityProbeKey { + identity: FileIdentity, + security_digest: [u8; 32], +} + +#[derive(Clone)] +struct CachedAtomicityProbe { + key: AtomicityProbeKey, + result: ProbeResult, + retry_at: Option, +} + +/// Caches filesystem atomic-replace probes by directory identity and security digest. +/// Directory replacement or ACL changes invalidate every result. +/// Failed probes are retried after a bounded delay so fixed-name collisions recover without restart. +pub(super) struct AtomicityProbeCache { + cached: std::sync::Mutex>, +} + +impl AtomicityProbeCache { + const FAILURE_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); + + pub(super) fn new() -> Self { + Self { + cached: std::sync::Mutex::new(None), + } + } + + /// Returns the cached probe result for `dir`/`dir_identity`/`dir_security_digest`, + /// re-probing (and updating the cache) if this is the first call or either the + /// directory's identity or its own security digest no longer matches what was last + /// cached. + fn get_or_probe(&self, dir: &Path, dir_identity: FileIdentity, dir_security_digest: [u8; 32]) -> ProbeResult { + self.get_or_probe_at(dir, dir_identity, dir_security_digest, std::time::Instant::now()) + } + + fn get_or_probe_at( + &self, + dir: &Path, + dir_identity: FileIdentity, + dir_security_digest: [u8; 32], + now: std::time::Instant, + ) -> ProbeResult { + let key = AtomicityProbeKey { + identity: dir_identity, + security_digest: dir_security_digest, + }; + let mut cached = self.cached.lock().expect("atomicity probe cache lock poisoned"); + + if let Some(cached) = cached.as_ref() + && cached.key == key + && cached.retry_at.is_none_or(|retry_at| now < retry_at) + { + return cached.result.clone(); + } + + let result = probe_write_capability(dir).map_err(|error| { + let reason = if error.downcast_ref::().is_some() + || error.downcast_ref::().is_some() + { + PolicyReadOnlyReason::UnsupportedFileSystem + } else { + PolicyReadOnlyReason::InsufficientPermissions + }; + (reason, format!("{error:#}")) + }); + *cached = Some(CachedAtomicityProbe { + key, + result: result.clone(), + retry_at: result.is_err().then_some(now + Self::FAILURE_RETRY_INTERVAL), + }); + result + } +} + +/// Open a directory without following reparse points, sharing read/write but not delete, +/// so the object cannot be renamed or deleted while this handle (and any later handle +/// derived from re-verifying it) is alive. +fn open_directory_no_reparse(path: &Path) -> anyhow::Result { + OpenOptions::new() + .access_mode((FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | FILE_TRAVERSE | READ_CONTROL).0 | FILE_SYNCHRONIZE) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE).0) + .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(path) + .with_context(|| format!("failed to open {}", path.display())) +} + +/// Open `path`, confirm it is a genuine directory (not a reparse point standing in for +/// one), and resolve its final path from the handle. +/// +/// Fails closed on any ambiguity: missing path, wrong object type, or reparse point. +/// +/// This only verifies `path` itself; callers additionally verify retained ancestors with +/// [`policy_security::verified_policy_ancestor_digest`], so an untrusted principal further +/// up the tree (e.g. on the shared `%ProgramData%\Devolutions\Agent` parent, where the +/// installer grants `LOCAL SERVICE` write access for unrelated Agent features) cannot +/// delete or replace this directory out from under an already-verified identity check. +fn open_and_verify_directory_identity(path: &Path) -> anyhow::Result<(File, PathBuf)> { + let handle = open_directory_no_reparse(path)?; + verify_directory_handle_type(&handle, &path.display().to_string())?; + let final_path = policy_security::final_path_from_handle(&handle) + .with_context(|| format!("failed to resolve {}", path.display()))?; + + Ok((handle, final_path)) +} + +fn verify_directory_handle_type(handle: &File, subject: &str) -> anyhow::Result<()> { + let attributes = handle + .metadata() + .with_context(|| format!("failed to query metadata for {subject}"))? + .file_attributes(); + + if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { + bail!("{subject} is a reparse point (symlink/junction); the policy directory must be a real directory"); + } + if attributes & FILE_ATTRIBUTE_DIRECTORY.0 == 0 { + bail!("{subject} is not a directory"); + } + + Ok(()) +} + +/// Holds the verified hosting directory and its lexical ancestors through publication. +/// These handles block path-component replacement, and the hosting handle anchors relative transaction names. +/// Test storage models the same checks with identity and security generations. +pub(super) struct VerifiedHostingDirectory { + handle: Option, + ancestor_handles: Vec, + canonical_path: PathBuf, + identity: FileIdentity, + security_digest: [u8; 32], +} + +impl VerifiedHostingDirectory { + /// The canonical directory path resolved from the verified handle when this was + /// built (item 22). + pub(super) fn canonical_path(&self) -> &Path { + &self.canonical_path + } + + /// The hosting directory's identity as observed when this was built. + pub(super) fn identity(&self) -> FileIdentity { + self.identity + } + + /// Build a synthetic instance for the parent module's `TestStorage`. + /// It models the hosting directory with generation counters and has no Windows handle. + #[cfg(test)] + pub(super) fn for_fake_storage(canonical_path: PathBuf, identity: FileIdentity, security_digest: [u8; 32]) -> Self { + Self { + handle: None, + ancestor_handles: Vec::new(), + canonical_path, + identity, + security_digest, + } + } + + /// Re-verify that this held-open directory still has the identity and security state + /// observed for the transaction. + fn verify_unchanged(&self) -> anyhow::Result<[u8; 32]> { + let handle = self.handle.as_ref().expect( + "BUG: reverify is only ever called by the real Windows write path (atomic_replace/atomic_create), \ + which always holds a real handle", + ); + policy_security::verify_policy_directory_security(handle) + .context("hosting directory failed security verification during post-write verification")?; + let identity = policy_security::file_identity(handle) + .context("failed to re-query hosting directory identity during post-write verification")?; + ensure!( + identity == self.identity, + "hosting directory identity changed unexpectedly while its handle was held open" + ); + let current_security = policy_security::security_state_digest(handle) + .context("failed to recompute hosting directory security digest during write verification")?; + ensure!( + current_security == self.security_digest, + "hosting directory security changed while its handle was held open" + ); + Ok(current_security) + } + + fn ancestor_digest(&self) -> anyhow::Result<[u8; 32]> { + policy_security::verified_policy_ancestor_digest(&self.ancestor_handles, "policy directory") + } +} + +/// Create the dedicated default policy directory (if it does not already exist) with an +/// admin-only ACL established atomically at creation, then verify it. +/// +/// The ACL is passed as explicit `SECURITY_ATTRIBUTES` to `CreateDirectoryW` itself (see +/// [`policy_security::admin_only_security_attributes`]), so there is no window between +/// creation and securing it during which an untrusted principal could race the directory. +/// The existing path through ProgramData is verified and retained before creation. +/// The runtime creates only the fixed `Devolutions` and `PackageBroker` components. +/// +/// The broker owns this directory end-to-end, but unlike a naive "create, then chmod" +/// approach, an *existing* directory (e.g. from a previous run) is only ever verified, +/// never rewritten: if it already exists with an insecure ACL (inherited, tampered with, +/// or planted by a race/reparse before this call ever ran), this fails closed instead of +/// silently repairing it, since repairing would extend trust to whatever object happened +/// to already occupy the path. +/// +/// Returns the retained directory, canonical path, ancestor-security digest, and retained lexical ancestors. +fn ensure_default_directory_secured(dir: &Path) -> anyhow::Result<(File, PathBuf, [u8; 32], Vec)> { + let security_attributes = policy_security::admin_only_security_attributes(true) + .context("build admin-only security attributes for the policy directory")?; + let vendor = dir.parent().context("default policy directory has no vendor parent")?; + let program_data = vendor + .parent() + .context("default policy directory is outside ProgramData")?; + let vendor_name = vendor + .file_name() + .context("default policy vendor directory has no name")?; + let leaf_name = dir.file_name().context("default policy directory has no name")?; + ensure!( + policy_security::os_strings_match_case_insensitive(vendor_name, OsStr::new("Devolutions")) + && policy_security::os_strings_match_case_insensitive(leaf_name, OsStr::new("PackageBroker")), + "default policy directory has an unexpected shape" + ); + + let mut ancestor_handles = + policy_security::retain_policy_no_reparse_directory_chain(program_data, "ProgramData directory")?; + let program_data_handle = ancestor_handles.pop().context("ProgramData directory chain is empty")?; + let canonical_program_data = policy_security::final_path_from_handle(&program_data_handle)?; + ensure!( + policy_security::paths_match_case_insensitive(&canonical_program_data, program_data), + "ProgramData resolved to an unexpected location" + ); + policy_security::verify_policy_ancestor_directory_security(&program_data_handle, "ProgramData directory")?; + + let vendor_handle = ensure_secure_directory_component( + &program_data_handle, + vendor_name, + &security_attributes, + DirectorySecurityRole::SharedAncestor, + |_| Ok(()), + )?; + let handle = ensure_secure_directory_component( + &vendor_handle, + leaf_name, + &security_attributes, + DirectorySecurityRole::DedicatedPolicy, + |_| { + verify_directory_handle_type(&vendor_handle, "shared policy ancestor directory")?; + policy_security::verify_policy_directory_security(&vendor_handle) + .context("shared policy ancestor grants unsafe create rights during bootstrap") + }, + )?; + ancestor_handles.push(program_data_handle); + ancestor_handles.push(vendor_handle); + let final_path = policy_security::final_path_from_handle(&handle)?; + let ancestor_security_digest = + policy_security::verified_policy_ancestor_digest(&ancestor_handles, "policy directory")?; + + Ok((handle, final_path, ancestor_security_digest, ancestor_handles)) +} + +#[derive(Clone, Copy)] +enum DirectorySecurityRole { + SharedAncestor, + DedicatedPolicy, +} + +fn ensure_secure_directory_component( + parent: &File, + name: &OsStr, + security_attributes: &win_api_wrappers::security::attributes::SecurityAttributes, + security_role: DirectorySecurityRole, + before_create: impl FnOnce(&Path) -> anyhow::Result<()>, +) -> anyhow::Result { + let path = policy_security::final_path_from_handle(parent) + .context("failed to resolve secure directory component parent")? + .join(name); + let opened = match open_and_verify_directory_identity(&path) { + Ok(opened) => opened, + Err(error) + if error + .root_cause() + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) => + { + before_create(&path)?; + let create_error = win_api_wrappers::fs::create_directory(&path, Some(security_attributes)).err(); + if let Some(create_error) = &create_error { + tracing::debug!( + path = %path.display(), + error = %format!("{create_error:#}"), + "Secure directory creation lost a race; reopening the winner" + ); + } + match open_and_verify_directory_identity(&path) { + Ok(opened) => opened, + Err(reopen_error) => { + if let Some(create_error) = create_error { + return Err(create_error).with_context(|| { + format!( + "failed to securely create {} and no race winner could be reopened ({reopen_error:#})", + path.display() + ) + }); + } + return Err(reopen_error).with_context(|| format!("failed to reopen {}", path.display())); + } + } + } + Err(error) => return Err(error), + }; + let (handle, final_path) = opened; + ensure!( + policy_security::paths_match_case_insensitive(&final_path, &path), + "{} resolved to unexpected location {}", + path.display(), + final_path.display() + ); + match security_role { + DirectorySecurityRole::SharedAncestor => { + policy_security::verify_policy_ancestor_directory_security(&handle, "shared policy ancestor directory") + } + DirectorySecurityRole::DedicatedPolicy => policy_security::verify_policy_directory_security(&handle), + } + .with_context(|| format!("{} does not meet the required directory security", path.display()))?; + Ok(handle) +} + +/// Verify (never rewrite) that a custom-configured policy directory already meets the +/// same security bar as the dedicated default directory, including its ancestor chain. +/// +/// Returns the retained directory, canonical path, ancestor-security digest, and retained lexical ancestors. +fn verify_custom_directory_secure(dir: &Path) -> anyhow::Result<(File, PathBuf, [u8; 32], Vec)> { + let mut ancestor_handles = + policy_security::retain_policy_no_reparse_directory_chain(dir, "configured policy directory")?; + let handle = ancestor_handles + .pop() + .context("configured policy directory chain is empty")?; + let final_path = policy_security::final_path_from_handle(&handle)?; + policy_security::verify_policy_directory_security(&handle)?; + let ancestor_security_digest = + policy_security::verified_policy_ancestor_digest(&ancestor_handles, "policy directory")?; + Ok((handle, final_path, ancestor_security_digest, ancestor_handles)) +} + +/// Marker error indicating [`probe_write_capability`] failed because the hosting +/// filesystem is not known to support the atomic same-directory replacement semantics +/// `atomic_replace` depends on (as opposed to an ACL/quota/permission problem). +#[derive(Debug)] +struct UnsupportedFilesystem(String); + +impl std::fmt::Display for UnsupportedFilesystem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "filesystem '{}' is not known to support atomic same-directory replacement", + self.0 + ) + } +} + +impl std::error::Error for UnsupportedFilesystem {} + +#[derive(Debug)] +struct UnsupportedAtomicSemantics(String); + +impl std::fmt::Display for UnsupportedAtomicSemantics { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for UnsupportedAtomicSemantics {} + +/// Filesystem names known to support atomic same-directory handle renames. +/// Conservative by design: an unrecognized filesystem is treated as unsupported. +const ATOMIC_REPLACE_CAPABLE_FILESYSTEMS: &[&str] = &["NTFS", "ReFS"]; + +/// Verifies that `dir` supports the handle-based tombstone and create-new publication semantics required by [`atomic_replace`]. +/// +/// First, it conservatively classifies the filesystem because some filesystems and filter drivers silently use non-atomic copy-then-delete renames. +/// It then runs a nondestructive probe with fixed create-new names so failed attempts remain bounded. +fn probe_write_capability(dir: &Path) -> anyhow::Result<()> { + let filesystem = volume_filesystem_name(dir).context("query volume filesystem")?; + if !ATOMIC_REPLACE_CAPABLE_FILESYSTEMS + .iter() + .any(|name| name.eq_ignore_ascii_case(&filesystem)) + { + return Err(UnsupportedFilesystem(filesystem).into()); + } + + let dir_handle = open_directory_no_reparse(dir)?; + let source_path = dir.join(".package-broker-write-probe-a.tmp"); + let target_path = dir.join(".package-broker-write-probe-b.tmp"); + let tombstone_path = dir.join(".package-broker-write-probe-old.tmp"); + let source = create_probe_file(&source_path, b"probe-source", false)?; + let target = match create_probe_file(&target_path, b"probe-target", true) { + Ok(target) => target, + Err(error) => { + return match cleanup_probe_file(source, &source_path, "write-capability probe source") { + Ok(()) => Err(error), + Err(cleanup_error) => { + Err(error.context(format!("probe source cleanup also failed: {cleanup_error:#}"))) + } + }; + } + }; + let mut target_tombstoned = false; + let mut source_published = false; + let mut target_deleted = false; + let probe_result = (|| -> anyhow::Result<()> { + verify_no_replace_collision(&source, &target, &dir_handle, &source_path, &target_path)?; + rename_file_handle( + &target, + &dir_handle, + tombstone_path.file_name().expect("probe path has leaf"), + ) + .context("probe target-to-tombstone handle rename")?; + target_tombstoned = true; + rename_file_handle( + &source, + &dir_handle, + target_path.file_name().expect("probe path has leaf"), + ) + .context("probe create-new handle publication")?; + source_published = true; + let replaced = std::fs::read(&target_path).context("read write-capability probe result")?; + ensure!( + replaced == b"probe-source", + "atomic replacement did not take effect on this filesystem" + ); + delete_file_handle(&target).context("probe POSIX tombstone unlink")?; + target_deleted = true; + Ok(()) + })(); + + let source_cleanup_path = if source_published { &target_path } else { &source_path }; + let source_cleanup = cleanup_probe_file(source, source_cleanup_path, "write-capability probe source"); + let target_cleanup = if target_deleted { + drop(target); + ensure_path_absent(&tombstone_path, "write-capability probe target") + } else { + let target_cleanup_path = if target_tombstoned { + &tombstone_path + } else { + &target_path + }; + cleanup_probe_file(target, target_cleanup_path, "write-capability probe target") + }; + + probe_result.and(source_cleanup).and(target_cleanup) +} + +fn verify_no_replace_collision( + source: &File, + target: &File, + dir: &File, + source_path: &Path, + target_path: &Path, +) -> anyhow::Result<()> { + let check = (|| -> anyhow::Result<()> { + let source_identity = policy_security::file_identity(source)?; + let target_identity = policy_security::file_identity(target)?; + let source_content = read_file_from_start(source)?; + let target_content = read_file_from_start(target)?; + + match rename_file_handle(source, dir, target_path.file_name().expect("probe target has a leaf")) { + Err(error) if error.is_collision() => {} + Err(error) if error.is_unsupported() => { + return Err(UnsupportedAtomicSemantics(format!("no-replace collision is unsupported: {error}")).into()); + } + Err(error) if error.is_permission_failure() => { + return Err(anyhow::Error::new(error).context("no-replace collision was blocked by permissions")); + } + Err(error) => { + return Err(anyhow::Error::new(error).context("no-replace collision returned an unexpected status")); + } + Ok(()) => bail!("no-replace rename unexpectedly replaced an occupied destination"), + } + verify_probe_directory_entry( + dir, + source_path.file_name().expect("probe source has a leaf"), + source_identity, + )?; + verify_probe_directory_entry( + dir, + target_path.file_name().expect("probe target has a leaf"), + target_identity, + )?; + ensure!( + policy_security::file_identity(source)? == source_identity + && policy_security::file_identity(target)? == target_identity, + "no-replace collision changed a retained probe identity" + ); + ensure!( + read_file_from_start(source)? == source_content && read_file_from_start(target)? == target_content, + "no-replace collision changed retained probe content" + ); + Ok(()) + })(); + + check.map_err(|error| { + if error + .downcast_ref::() + .is_some_and(RenameFailure::is_permission_failure) + || error.downcast_ref::().is_some() + { + error + } else { + UnsupportedAtomicSemantics(format!("filesystem failed no-replace collision semantics: {error:#}")).into() + } + }) +} + +fn verify_probe_directory_entry(dir: &File, leaf: &OsStr, expected_identity: FileIdentity) -> anyhow::Result<()> { + let reopened = open_file_relative(dir, leaf)?; + ensure!( + policy_security::file_identity(&reopened)? == expected_identity, + "probe directory entry no longer names the retained file" + ); + ensure!( + policy_security::file_link_count(&reopened)? == 1, + "probe directory entry has multiple hard links" + ); + Ok(()) +} + +fn open_file_relative(dir: &File, leaf: &OsStr) -> anyhow::Result { + let mut name: Vec = leaf.encode_wide().collect(); + ensure!( + !name.is_empty() && !name.contains(&0) && !name.contains(&u16::from(b'\\')) && !name.contains(&u16::from(b'/')), + "relative file name is not a single valid path component" + ); + let name_byte_len = name + .len() + .checked_mul(size_of::()) + .and_then(|length| u16::try_from(length).ok()) + .context("relative file name is too long")?; + let object_name = UNICODE_STRING { + Length: name_byte_len, + MaximumLength: name_byte_len, + Buffer: windows::core::PWSTR(name.as_mut_ptr()), + }; + let object_attributes = OBJECT_ATTRIBUTES { + Length: u32::try_from(size_of::()).expect("OBJECT_ATTRIBUTES size fits u32"), + RootDirectory: HANDLE(dir.as_raw_handle()), + ObjectName: &raw const object_name, + Attributes: OBJ_CASE_INSENSITIVE, + SecurityDescriptor: std::ptr::null(), + SecurityQualityOfService: std::ptr::null(), + }; + let mut handle = HANDLE::default(); + let mut io_status = IoStatusBlock { + status_or_pointer: 0, + information: 0, + }; + + // SAFETY: The retained directory handle, object attributes, name, output handle, and I/O status remain valid. + let status = unsafe { + NtOpenFile( + &mut handle, + FILE_READ_ATTRIBUTES.0, + &object_attributes, + &mut io_status, + (FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0, + FILE_NON_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT, + ) + }; + ensure!( + status.0 >= 0, + "failed to reopen probe directory entry with NT status {:#010X}", + status.0.cast_unsigned() + ); + + // SAFETY: Successful NtOpenFile returned a new owned handle. + Ok(File::from(unsafe { OwnedHandle::from_raw_handle(handle.0) })) +} + +fn cleanup_probe_file(file: File, path: &Path, subject: &str) -> anyhow::Result<()> { + let cleanup = delete_file_handle(&file).with_context(|| format!("failed to remove {subject}")); + drop(file); + cleanup.and_then(|()| ensure_path_absent(path, subject)) +} + +fn ensure_path_absent(path: &Path, subject: &str) -> anyhow::Result<()> { + match std::fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Ok(_) => bail!("{subject} cleanup did not remove the directory entry"), + Err(error) => Err(error).with_context(|| format!("failed to verify {subject} cleanup")), + } +} + +fn create_probe_file(path: &Path, bytes: &[u8], allow_delete_share: bool) -> anyhow::Result { + use std::io::Write as _; + + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .access_mode(GENERIC_READ.0 | GENERIC_WRITE.0 | DELETE.0 | READ_CONTROL.0) + .share_mode( + if allow_delete_share { + FILE_SHARE_READ | FILE_SHARE_DELETE + } else { + FILE_SHARE_READ + } + .0, + ) + .custom_flags((FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH).0) + .open(path) + .with_context(|| format!("failed to create write-capability probe {}", path.display()))?; + if let Err(error) = file + .write_all(bytes) + .and_then(|()| file.sync_all()) + .with_context(|| format!("failed to persist write-capability probe {}", path.display())) + { + return match delete_file_handle(&file) { + Ok(()) => Err(error), + Err(cleanup_error) => Err(error.context(format!("probe cleanup also failed: {cleanup_error:#}"))), + }; + } + Ok(file) +} + +/// Classify the filesystem hosting `dir` (e.g. `"NTFS"`, `"ReFS"`, `"FAT32"`). +fn volume_filesystem_name(dir: &Path) -> anyhow::Result { + let dir_wide = U16CString::from_os_str(dir.as_os_str()).context("directory path contains an interior NUL")?; + + let mut volume_root = vec![0u16; 512]; + // SAFETY: `dir_wide` is a valid NUL-terminated wide string live for the call, and + // `volume_root` is a live, writable buffer. + unsafe { GetVolumePathNameW(dir_wide.as_pcwstr(), &mut volume_root) }.context("GetVolumePathNameW failed")?; + + let mut filesystem_name = vec![0u16; 261]; + // SAFETY: `volume_root` is a valid, NUL-terminated wide root path as returned by + // `GetVolumePathNameW` above, live for the call; `filesystem_name` is a live, writable + // buffer; every other output parameter is `None`, which the API accepts. + unsafe { + GetVolumeInformationW( + windows::core::PCWSTR(volume_root.as_ptr()), + None, + None, + None, + None, + Some(&mut filesystem_name), + ) + } + .context("GetVolumeInformationW failed")?; + + let nul_at = filesystem_name + .iter() + .position(|&unit| unit == 0) + .unwrap_or(filesystem_name.len()); + Ok(String::from_utf16_lossy(&filesystem_name[..nul_at])) +} + +/// Internal identity of the object, content, and verified security state observed on disk. +/// Fingerprint changes rotate the opaque store token; fingerprints are never serialized. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum DiskFingerprint { + /// A successfully parsed, security-verified, and semantically-valid policy file. + Active { + parent: FileIdentity, + target: FileIdentity, + content_digest: [u8; 32], + security_digest: [u8; 32], + /// Digest of the hosting directory's own owner and DACL. + dir_security_digest: [u8; 32], + ancestor_security_digest: [u8; 32], + }, + /// No file at the resolved path. Carries the verified identity of the parent + /// directory (its own security digest, and its ancestor chain's security summary), + /// so a parent replacement (or a differently identified custom path) is still + /// distinguishable even though there is no leaf to identify. + /// `parent`/`dir_security_digest`/`ancestor_security_digest` are `None` when even the + /// directory itself could not be verified (its own security/ancestor check failed): + /// still Missing -- there is no leaf to distrust either way -- but `path` (the + /// canonical, or best-effort literal, configured path) still prevents two distinct + /// configured paths in that situation from colliding (mirrors `Invalid::path`; item + /// 15). + Missing { + path: PathBuf, + parent: Option, + dir_security_digest: Option<[u8; 32]>, + ancestor_security_digest: Option<[u8; 32]>, + }, + /// A file exists but could not be trusted or activated: unreadable, failed storage + /// security validation, not valid JSON matching the expected schema, or (structurally + /// valid JSON that is nonetheless) semantically invalid. + /// + /// Every component is independently optional because how far observation got before + /// failing determines what could actually be resolved (e.g. a target that cannot + /// even be opened has no identity or content digest yet). `path` -- the canonical + /// configured path, or the best-effort literal one when it could not be + /// canonicalized at all -- is always present precisely so that two distinct + /// configured paths that both fail identically (e.g. both "parent cannot be opened", + /// with no identity available to distinguish them) never collide (item 15). + Invalid { + path: PathBuf, + parent: Option, + dir_security_digest: Option<[u8; 32]>, + ancestor_security_digest: Option<[u8; 32]>, + target: Option, + content_digest: Option<[u8; 32]>, + security_digest: Option<[u8; 32]>, + /// Stable internal failure reason (never itself exposed by the management API; + /// see `validation::disk_failure_finding`), included so distinct reasons at the + /// exact same path/identity still rotate the token (e.g. a file that was + /// insecurely-stored becomes merely malformed after its ACL is fixed). + reason: String, + }, +} + +impl DiskFingerprint { + fn target_state(&self) -> Option<(FileIdentity, [u8; 32], [u8; 32])> { + match self { + Self::Active { + target, + content_digest, + security_digest, + .. + } + | Self::Invalid { + target: Some(target), + content_digest: Some(content_digest), + security_digest: Some(security_digest), + .. + } => Some((*target, *content_digest, *security_digest)), + _ => None, + } + } + + fn ancestor_security_digest(&self) -> Option<[u8; 32]> { + match self { + Self::Active { + ancestor_security_digest, + .. + } + | Self::Missing { + ancestor_security_digest: Some(ancestor_security_digest), + .. + } + | Self::Invalid { + ancestor_security_digest: Some(ancestor_security_digest), + .. + } => Some(*ancestor_security_digest), + _ => None, + } + } +} + +#[cfg(test)] +impl DiskFingerprint { + /// Build a synthetic fingerprint for the in-memory `TestStorage` test double, + /// which has no real Windows file handles to derive identity from. + /// + /// `target_generation` and `parent_generation` stand in for [`FileIdentity`]: bump + /// either to simulate the corresponding real-world object being deleted and recreated + /// (even with byte-identical content), and `acl_generation` to simulate a + /// security-descriptor change with no content change (folded into both the target's + /// own security digest and the ancestor-chain summary, since the fake models "some + /// security-relevant state changed" as a single dimension rather than distinguishing + /// which level of the tree). + /// `dir_acl_generation` is independent so a hosting-directory-only ACL change (with no + /// leaf or ancestor-chain change) still rotates the fingerprint on its own. + pub(super) fn test_active( + content: &[u8], + target_generation: u32, + parent_generation: u32, + acl_generation: u32, + dir_acl_generation: u32, + ) -> Self { + Self::Active { + parent: test_identity(parent_generation), + target: test_identity(target_generation), + content_digest: sha256_digest(content), + security_digest: sha256_digest(&acl_generation.to_le_bytes()), + dir_security_digest: sha256_digest(&dir_acl_generation.to_le_bytes()), + ancestor_security_digest: sha256_digest(&acl_generation.to_le_bytes()), + } + } + + pub(super) fn test_missing(parent_generation: u32, dir_acl_generation: u32) -> Self { + Self::Missing { + path: PathBuf::from(r"C:\fake\package-broker-policy.json"), + parent: Some(test_identity(parent_generation)), + dir_security_digest: Some(test_security_digest(dir_acl_generation)), + ancestor_security_digest: Some(sha256_digest(b"test-ancestor-security")), + } + } + + pub(super) fn test_invalid( + content: &[u8], + target_generation: u32, + parent_generation: u32, + acl_generation: u32, + dir_acl_generation: u32, + ) -> Self { + Self::Invalid { + path: PathBuf::from(r"C:\fake\package-broker-policy.json"), + parent: Some(test_identity(parent_generation)), + dir_security_digest: Some(test_security_digest(dir_acl_generation)), + ancestor_security_digest: Some(test_security_digest(acl_generation)), + target: Some(test_identity(target_generation)), + content_digest: Some(sha256_digest(content)), + security_digest: Some(test_security_digest(acl_generation)), + reason: format!("{:?}", validation::DiskFailureReason::MalformedContent), + } + } +} + +#[cfg(test)] +pub(super) fn test_identity(generation: u32) -> FileIdentity { + let mut file_id = [0u8; 16]; + file_id[..4].copy_from_slice(&generation.to_le_bytes()); + FileIdentity { + volume_serial: 0, + file_id, + } +} + +#[cfg(test)] +pub(super) fn test_security_digest(generation: u32) -> [u8; 32] { + sha256_digest(&generation.to_le_bytes()) +} + +pub(super) fn sha256_digest(bytes: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher.finalize().into() +} + +pub(super) fn unavailable_fingerprint(path: PathBuf) -> DiskFingerprint { + DiskFingerprint::Invalid { + path, + parent: None, + dir_security_digest: None, + ancestor_security_digest: None, + target: None, + content_digest: None, + security_digest: None, + reason: format!("{:?}", validation::DiskFailureReason::WatcherUnavailable), + } +} + +/// Exact policy file handle retained from a write transaction's token observation through publication. +pub(super) enum RetainedPolicyFile { + Real(File), + #[cfg(test)] + Fake(Box), +} + +impl RetainedPolicyFile { + pub(super) fn verify_matches(&self, expected: &DiskFingerprint) -> anyhow::Result<()> { + #[cfg(test)] + if let Self::Fake(observed) = self { + ensure!( + observed.as_ref() == expected, + "fake retained target does not match the observed fingerprint" + ); + return Ok(()); + } + let handle = match self { + Self::Real(handle) => handle, + #[cfg(test)] + Self::Fake(_) => unreachable!("fake retained target returned before Windows verification"), + }; + let (expected_target, expected_content, expected_security) = expected + .target_state() + .context("write observation did not retain a complete target fingerprint")?; + + ensure!( + policy_security::file_identity(handle)? == expected_target, + "retained policy file identity changed after token validation" + ); + ensure!( + policy_security::file_link_count(handle)? == 1, + "retained policy file acquired another hard link after token validation" + ); + policy_security::verify_managed_policy_file_security(handle) + .context("retained policy file security changed after token validation")?; + ensure!( + policy_security::security_state_digest(handle)? == expected_security, + "retained policy file security digest changed after token validation" + ); + + let bytes = read_file_from_start(handle)?; + ensure!( + sha256_digest(&bytes) == expected_content, + "retained policy file content changed after token validation" + ); + Ok(()) + } + + fn handle(&self) -> &File { + match self { + Self::Real(handle) => handle, + #[cfg(test)] + Self::Fake(_) => panic!("fake retained targets have no Windows handle"), + } + } + + #[cfg(test)] + fn into_handle(self) -> File { + match self { + Self::Real(handle) => handle, + Self::Fake(_) => panic!("fake retained targets have no Windows handle"), + } + } + + #[cfg(test)] + pub(super) fn for_fake(fingerprint: DiskFingerprint) -> Self { + Self::Fake(Box::new(fingerprint)) + } +} + +/// Exact observed state of the policy file on disk, together with the write capability +/// resolved *as part of the same observation* (item 20/26): capability is never derived +/// from a separately cached snapshot, so it can never silently drift from the state it +/// describes. A malformed-but-securely-stored file (capability follows the directory's +/// own resolved capability, allowing Repair) is distinguished from an insecure/unreadable +/// target (capability is forced to `ReadOnly`/`UnsafePath` regardless of the directory's +/// own capability, and Repair therefore fails): see item 26. +pub(super) struct DiskObservation { + pub state: PolicyManagementState, + pub policy: Option, + pub invalid_diagnostics: Option, + pub fingerprint: DiskFingerprint, + pub write_capability: PolicyWriteCapability, + pub read_only_reason: Option, + /// Canonical path formed from the verified parent handle and exact configured `.json` leaf name; see item 22. + /// Falls back to the literal configured path when the path cannot be canonicalized. + /// Trusted target access, publication, display, and watching use this path as applicable. + /// `PolicyStore` retains the original configured path for authoritative re-observation and security-chain validation. + pub canonical_path: PathBuf, + /// The hosting directory verified during this observation. + /// Writable observations keep it alive through publication and postverification. + pub hosting_dir: Option, + /// Exact target handle retained by write observations. + pub retained_target: Option, +} + +/// Context accumulated while observation fails partway through, for building the most +/// complete [`DiskFingerprint::Invalid`] the failure allows (item 15): every field is +/// optional because how far observation got before failing determines what could +/// actually be resolved (e.g. a directory that cannot even be opened has no parent +/// identity to report). +#[derive(Default)] +struct InvalidContext { + parent: Option, + dir_security_digest: Option<[u8; 32]>, + ancestor_security_digest: Option<[u8; 32]>, + target: Option, + content_digest: Option<[u8; 32]>, + security_digest: Option<[u8; 32]>, +} + +struct OpenedPolicyFile { + file: File, + retained_for_write: bool, +} + +fn verify_policy_leaf_type_if_present(path: &Path) -> anyhow::Result<()> { + let file = match OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES.0 | READ_CONTROL.0) + .share_mode(FILE_SHARE_READ.0) + .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error).with_context(|| format!("failed to inspect policy leaf {}", path.display())), + }; + let attributes = file.metadata()?.file_attributes(); + ensure!( + attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 == 0, + "policy leaf is a reparse point" + ); + ensure!( + attributes & FILE_ATTRIBUTE_DIRECTORY.0 == 0, + "policy leaf is a directory" + ); + Ok(()) +} + +fn open_policy_file(path: &Path, retain_for_write: bool) -> std::io::Result { + if retain_for_write { + match OpenOptions::new() + .access_mode(FILE_GENERIC_READ.0 | DELETE.0 | READ_CONTROL.0) + .share_mode(FILE_SHARE_READ.0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(path) + { + Ok(file) => { + return Ok(OpenedPolicyFile { + file, + retained_for_write: true, + }); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Err(error), + Err(error) => { + tracing::warn!( + path = %path.display(), + %error, + "Failed to retain the configured policy file for conditional publication" + ); + } + } + } + + OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ.0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(path) + .map(|file| OpenedPolicyFile { + file, + retained_for_write: false, + }) +} + +fn resolved_policy_path_matches(resolved: &Path, canonical_parent: &Path, configured_leaf: &OsStr) -> bool { + let Some(resolved_parent) = resolved.parent() else { + return false; + }; + let Some(resolved_leaf) = resolved.file_name() else { + return false; + }; + + policy_security::paths_match_case_insensitive(resolved_parent, canonical_parent) + && paths_component_matches_case_insensitive(resolved_leaf, configured_leaf) +} + +fn paths_component_matches_case_insensitive(a: &OsStr, b: &OsStr) -> bool { + policy_security::os_strings_match_case_insensitive(a, b) +} + +/// Observe the exact current disk state of the configured policy file. +/// +/// Resolves (and, for the default path, idempotently creates) the canonical directory +/// and re-verifies its shape/security/ancestor chain and write capability on every call +/// (item 20): only the one-time, side-effecting filesystem atomic-replace probe is +/// cached (`probe_cache`; see [`AtomicityProbeCache`]), never the cheap security checks. +/// +/// The hosting directory is opened without delete sharing and held open for the whole +/// observation: both to fold its identity into the fingerprint (detecting the directory +/// itself being replaced) and so it cannot be deleted or renamed out from under the +/// target file while it is being examined. The leaf file is opened without following +/// reparse points, and its own handle-resolved final path must match the canonical +/// directory and expected leaf name, case-insensitively (item 22): a reparse point or +/// hard-link alias standing in for the configured file is never trusted, whatever its +/// content, but a leaf whose on-disk casing merely differs from the configured path +/// (Windows filesystems are case-insensitive but case-preserving) is accepted as the same file. +/// Security is verified on the target's open handle before any content is trusted, and content is +/// read from that same handle, so the verified security descriptor always belongs to the +/// exact bytes subsequently parsed (no TOCTOU window via file replacement). A +/// structurally valid document is additionally, authoritatively revalidated the same +/// deterministic way a submitted draft is (item 30): a committed file is never activated +/// on structural parseability alone. +/// +/// A configured path whose shape/extension is unsupported (item 18/22) -- relative, +/// empty/non-file leaf, trailing separator, `.`/`..` component, or an extension other +/// than `.json` -- is reported with the shared contract's dedicated +/// [`PolicyReadOnlyReason::UnsupportedFormat`]. +pub(super) fn observe( + source: PolicyConfigurationSource, + configured_path: &Path, + probe_cache: &AtomicityProbeCache, +) -> DiskObservation { + observe_impl(source, configured_path, probe_cache, false) +} + +pub(super) fn observe_for_write( + source: PolicyConfigurationSource, + configured_path: &Path, + probe_cache: &AtomicityProbeCache, +) -> DiskObservation { + observe_impl(source, configured_path, probe_cache, true) +} + +fn observe_impl( + source: PolicyConfigurationSource, + configured_path: &Path, + probe_cache: &AtomicityProbeCache, + retain_target: bool, +) -> DiskObservation { + if let Err(diagnostic) = validate_configured_path_shape(configured_path) { + tracing::warn!( + path = %configured_path.display(), reason = %diagnostic, + "Configured policy path has an unsupported shape or extension" + ); + let (failure, read_only_reason) = match diagnostic { + ConfiguredPathError::UnsafeShape(_) => ( + validation::DiskFailureReason::InsecureStorage, + PolicyReadOnlyReason::UnsafePath, + ), + ConfiguredPathError::UnsupportedFormat(_) => ( + validation::DiskFailureReason::UnsupportedFormat, + PolicyReadOnlyReason::UnsupportedFormat, + ), + }; + return invalid_observation( + configured_path, + failure, + InvalidContext::default(), + PolicyWriteCapability::Unsupported, + Some(read_only_reason), + ); + } + + let dir = configured_path.parent().unwrap_or_else(|| Path::new(".")); + let leaf_name = configured_path + .file_name() + .expect("shape validation already required a named leaf file"); + let 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) + } + }; + return observe_leaf_under_unverifiable_directory(configured_path, write_capability, read_only_reason); + } + }; + let canonical_path = canonical_dir.join(leaf_name); + let parent = match policy_security::file_identity(&dir_handle) { + Ok(identity) => identity, + Err(error) => { + tracing::warn!( + path = %canonical_dir.display(), %error, + "Failed to query the configured policy directory identity" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + InvalidContext::default(), + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + + 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::verified_policy_ancestor_digest(&ancestor_handles, "policy directory") { + Ok(digest) => digest, + Err(error) => { + tracing::warn!( + path = %canonical_dir.display(), error = %format!("{error:#}"), + "Held policy directory ancestor chain failed security verification" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + InvalidContext { + parent: Some(parent), + dir_security_digest: Some(dir_security_digest), + ..Default::default() + }, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + let 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" + ); + (probe_failure_capability(reason), Some(reason)) + } + }; + + let hosting_dir = (base_write_capability == PolicyWriteCapability::Writable).then_some(VerifiedHostingDirectory { + handle: Some(dir_handle), + ancestor_handles, + canonical_path: canonical_dir.clone(), + identity: parent, + security_digest: dir_security_digest, + }); + + let invalid_ctx = InvalidContext { + parent: Some(parent), + dir_security_digest: Some(dir_security_digest), + ancestor_security_digest: Some(ancestor_security_digest), + ..Default::default() + }; + + if let Err(error) = verify_policy_leaf_type_if_present(&canonical_path) { + tracing::warn!( + path = %canonical_path.display(), + error = %format!("{error:#}"), + "Configured policy leaf has an unsafe type" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + let opened = match open_policy_file(&canonical_path, retain_target) { + Ok(opened) => opened, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if let Err(error) = verify_policy_leaf_type_if_present(&canonical_path) { + tracing::warn!( + path = %canonical_path.display(), + error = %format!("{error:#}"), + "Configured policy leaf became unsafe while opening" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + return DiskObservation { + state: PolicyManagementState::Missing, + policy: None, + invalid_diagnostics: None, + fingerprint: DiskFingerprint::Missing { + path: canonical_path.clone(), + parent: Some(parent), + dir_security_digest: Some(dir_security_digest), + ancestor_security_digest: Some(ancestor_security_digest), + }, + write_capability: base_write_capability, + read_only_reason: base_read_only_reason, + canonical_path, + hosting_dir, + retained_target: None, + }; + } + Err(error) => { + tracing::warn!(path = %canonical_path.display(), %error, "Failed to open the configured policy file"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + let file = opened.file; + + if let Err(error) = policy_security::verify_policy_file_path(&file, &canonical_path) { + tracing::warn!( + path = %canonical_path.display(), + error = %format!("{error:#}"), + "Configured policy file failed path verification" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + + let attributes = match file.metadata() { + Ok(metadata) => metadata.file_attributes(), + Err(error) => { + tracing::warn!(path = %canonical_path.display(), %error, "Failed to query the configured policy file metadata"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { + tracing::warn!( + path = %canonical_path.display(), + "Configured policy file is a reparse point (symlink); refusing to trust a retargeted file" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + if attributes & FILE_ATTRIBUTE_DIRECTORY.0 != 0 { + tracing::warn!(path = %canonical_path.display(), "Configured policy path resolved to a directory, not a file"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + + // A policy leaf with multiple names is ambiguous regardless of which name + // GetFinalPathNameByHandleW happens to report. Reject it using file metadata rather + // than inferring link identity from that reported path. + let link_count = match policy_security::file_link_count(&file) { + Ok(link_count) => link_count, + Err(error) => { + tracing::warn!(path = %canonical_path.display(), %error, "Failed to query the configured policy file link count"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + if link_count != 1 { + tracing::warn!( + path = %canonical_path.display(), + link_count, + "Configured policy file has multiple hard links" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + + // Resolve both the parent and leaf from their held handles. This tolerates a lexical + // 8.3 alias in the configured parent while still requiring the resolved leaf to be + // exactly the configured name modulo Windows casing. + match policy_security::final_path_from_handle(&file) { + Ok(resolved) => { + let resolved_matches = resolved_policy_path_matches(&resolved, &canonical_dir, leaf_name); + if !resolved_matches { + tracing::warn!( + path = %canonical_path.display(), resolved = %resolved.display(), + "Configured policy file resolved to an unexpected location" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + } + + Err(error) => { + tracing::warn!( + path = %canonical_path.display(), %error, + "Failed to resolve the configured policy file's final path" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + } + + let target = match policy_security::file_identity(&file) { + Ok(identity) => identity, + Err(error) => { + tracing::warn!(path = %canonical_path.display(), %error, "Failed to query the configured policy file identity"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + let invalid_ctx = InvalidContext { + target: Some(target), + ..invalid_ctx + }; + + if let Err(security_error) = policy_security::verify_managed_policy_file_security(&file) { + // Fail closed without ever reading content past a failed security check: + // 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, + } +} + +fn observe_leaf_under_unverifiable_directory( + configured_path: &Path, + write_capability: PolicyWriteCapability, + read_only_reason: PolicyReadOnlyReason, +) -> DiskObservation { + // Do not follow the leaf: a dangling reparse point is an existing unsafe entry, not + // an absent policy. Errors remain invalid because the directory could not be trusted. + match std::fs::symlink_metadata(configured_path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => DiskObservation { + state: PolicyManagementState::Missing, + policy: None, + invalid_diagnostics: None, + fingerprint: DiskFingerprint::Missing { + path: configured_path.to_owned(), + parent: None, + dir_security_digest: None, + ancestor_security_digest: None, + }, + write_capability, + read_only_reason: Some(read_only_reason), + canonical_path: configured_path.to_owned(), + hosting_dir: None, + retained_target: None, + }, + Ok(_) | Err(_) => invalid_observation( + configured_path, + validation::DiskFailureReason::Unreadable, + InvalidContext::default(), + write_capability, + Some(read_only_reason), + ), + } +} + +/// Build a generic, sanitized [`DiskObservation`] for a storage-level failure (shape, +/// I/O, or security) that prevented the configured policy file from even being read as +/// JSON. Never includes raw OS/security error text: see [`validation::disk_failure_finding`]. +fn invalid_observation( + path: &Path, + reason: validation::DiskFailureReason, + context: InvalidContext, + write_capability: PolicyWriteCapability, + read_only_reason: Option, +) -> DiskObservation { + DiskObservation { + state: PolicyManagementState::Invalid, + policy: None, + invalid_diagnostics: Some(InvalidPolicyDiagnostics { + diagnostics_version: API_VERSION_STR.into(), + findings: vec![validation::disk_failure_finding(reason)], + }), + fingerprint: DiskFingerprint::Invalid { + path: path.to_owned(), + parent: context.parent, + dir_security_digest: context.dir_security_digest, + ancestor_security_digest: context.ancestor_security_digest, + target: context.target, + content_digest: context.content_digest, + security_digest: context.security_digest, + reason: format!("{reason:?}"), + }, + write_capability, + read_only_reason, + canonical_path: path.to_owned(), + hosting_dir: None, + retained_target: None, + } +} + +/// Mint a fresh, process-random, opaque token conforming to `PolicyStoreToken`'s own +/// safe-ASCII/length contract. Tokens never encode or derive from disk content/identity: +/// [`PolicyStore::token_for`](super::PolicyStore) is the only place a token is ever +/// produced, and it only ever calls this when the observed [`DiskFingerprint`] changed. +pub(super) fn random_store_token() -> PolicyStoreToken { + uuid::Uuid::new_v4().hyphenated().to_string().into() +} + +/// Result of a successful atomic write. +pub(super) struct PersistedPolicy { + pub policy: PolicyDocument, + pub fingerprint: DiskFingerprint, + pub write_capability: PolicyWriteCapability, + pub read_only_reason: Option, + pub canonical_path: PathBuf, +} + +/// A write failure classified by whether publication occurred or a concurrent change won. +pub(super) enum WriteFailure { + /// Failed before new content was published. + /// Reobservation recovers any retained tombstone before this maps to `ErrorCode::PolicyPersistenceFailed`. + PrePublication(anyhow::Error), + /// A concurrent external change prevented identity-bound publication. + /// The caller must synchronously reobserve and return a stale-token conflict. + ConcurrentChange(anyhow::Error), + /// Failed after the rename made the new content live: the caller must synchronously + /// reobserve disk under the same write lock and publish whatever that reveals rather + /// than trusting the previous in-memory snapshot. Maps to + /// `ErrorCode::PolicyActivationFailed`. + PostPublication(anyhow::Error), +} + +/// Conditionally persist `bytes` against the exact retained target observed for the store token. +/// +/// The observed target is moved by handle to a reserved tombstone and the flushed replacement is moved by handle to the final leaf without replacement. +/// Any raced-in final entry is preserved and reported as [`WriteFailure::ConcurrentChange`]. +/// A durable marker makes every interruption recoverable before the next observation. +pub(super) fn atomic_replace( + hosting_dir: &VerifiedHostingDirectory, + observed_target: Option, + expected_fingerprint: &DiskFingerprint, + final_path: &Path, + bytes: &[u8], +) -> Result { + let observed_target = observed_target + .context("replacement observation did not retain the target handle") + .map_err(WriteFailure::ConcurrentChange)?; + verify_replacement_evidence(hosting_dir, &observed_target, expected_fingerprint) + .map_err(WriteFailure::ConcurrentChange)?; + + conditional_replace(hosting_dir, observed_target, expected_fingerprint, final_path, bytes) +} + +fn verify_replacement_evidence( + hosting_dir: &VerifiedHostingDirectory, + observed_target: &RetainedPolicyFile, + expected_fingerprint: &DiskFingerprint, +) -> anyhow::Result<()> { + verify_directory_evidence(hosting_dir, expected_fingerprint)?; + observed_target + .verify_matches(expected_fingerprint) + .context("observed policy changed before conditional publication") +} + +fn verify_directory_evidence( + hosting_dir: &VerifiedHostingDirectory, + expected_fingerprint: &DiskFingerprint, +) -> anyhow::Result<()> { + hosting_dir + .verify_unchanged() + .context("hosting directory changed after policy observation")?; + let expected_ancestor_security = expected_fingerprint + .ancestor_security_digest() + .context("write observation has no ancestor security fingerprint")?; + let current_ancestor_security = hosting_dir + .ancestor_digest() + .context("policy directory ancestor security changed after token validation")?; + if current_ancestor_security != expected_ancestor_security { + return Err(anyhow::anyhow!( + "policy directory ancestor security changed after token validation" + )); + } + Ok(()) +} + +#[derive(Debug)] +struct TransactionPaths { + id: uuid::Uuid, + marker_staging: PathBuf, + marker: PathBuf, + old: PathBuf, + new: PathBuf, +} + +impl TransactionPaths { + fn new(dir: &Path, final_path: &Path) -> anyhow::Result { + let leaf = final_path + .file_name() + .and_then(OsStr::to_str) + .context("policy path has no Unicode leaf name")?; + let id = uuid::Uuid::new_v4(); + let prefix = format!(".{leaf}.txn-{id}"); + Ok(Self { + id, + marker_staging: dir.join(format!("{prefix}.marker.prepare")), + marker: dir.join(format!("{prefix}.marker")), + old: dir.join(format!("{prefix}.old")), + new: dir.join(format!("{prefix}.new")), + }) + } +} + +#[derive(Debug)] +struct TransactionMarker { + id: uuid::Uuid, + final_leaf: String, + old_identity: FileIdentity, + old_content_digest: [u8; 32], + old_security_digest: [u8; 32], + new_identity: FileIdentity, + new_content_digest: [u8; 32], + new_security_digest: [u8; 32], +} + +impl TransactionMarker { + fn from_observation( + id: uuid::Uuid, + final_path: &Path, + expected: &DiskFingerprint, + new_file: &File, + new_bytes: &[u8], + ) -> anyhow::Result { + let (old_identity, old_content_digest, old_security_digest) = expected + .target_state() + .context("replacement requires a complete observed target fingerprint")?; + Ok(Self { + id, + final_leaf: final_path + .file_name() + .and_then(OsStr::to_str) + .context("policy path has no Unicode leaf name")? + .to_owned(), + old_identity, + old_content_digest, + old_security_digest, + new_identity: policy_security::file_identity(new_file) + .context("failed to capture replacement policy identity")?, + new_content_digest: sha256_digest(new_bytes), + new_security_digest: policy_security::security_state_digest(new_file) + .context("failed to capture replacement policy security")?, + }) + } + + fn to_bytes(&self) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "Version": 3, + "TransactionId": self.id.to_string(), + "FinalLeaf": self.final_leaf, + "OldVolumeSerial": self.old_identity.volume_serial, + "OldFileId": hex::encode(self.old_identity.file_id), + "OldContentDigest": hex::encode(self.old_content_digest), + "OldSecurityDigest": hex::encode(self.old_security_digest), + "NewVolumeSerial": self.new_identity.volume_serial, + "NewFileId": hex::encode(self.new_identity.file_id), + "NewContentDigest": hex::encode(self.new_content_digest), + "NewSecurityDigest": hex::encode(self.new_security_digest), + })) + .expect("transaction marker fields always serialize") + } + + fn from_bytes(bytes: &[u8]) -> anyhow::Result { + let value: serde_json::Value = serde_json::from_slice(bytes).context("transaction marker is not valid JSON")?; + let object = value.as_object().context("transaction marker must be an object")?; + ensure!(object.len() == 11, "transaction marker contains unexpected fields"); + ensure!( + object.get("Version").and_then(serde_json::Value::as_u64) == Some(3), + "unsupported transaction marker" + ); + let text = |name: &str| -> anyhow::Result<&str> { + object + .get(name) + .and_then(serde_json::Value::as_str) + .with_context(|| format!("transaction marker {name} is missing or invalid")) + }; + let decode = |name: &str| -> anyhow::Result<[u8; 32]> { + let mut output = [0u8; 32]; + hex::decode_to_slice(text(name)?, &mut output) + .with_context(|| format!("transaction marker {name} is invalid"))?; + Ok(output) + }; + let decode_file_id = |name: &str| -> anyhow::Result<[u8; 16]> { + let mut output = [0u8; 16]; + hex::decode_to_slice(text(name)?, &mut output) + .with_context(|| format!("transaction marker {name} is invalid"))?; + Ok(output) + }; + Ok(Self { + id: uuid::Uuid::parse_str(text("TransactionId")?).context("transaction marker id is invalid")?, + final_leaf: text("FinalLeaf")?.to_owned(), + old_identity: FileIdentity { + volume_serial: object + .get("OldVolumeSerial") + .and_then(serde_json::Value::as_u64) + .context("transaction marker OldVolumeSerial is missing or invalid")?, + file_id: decode_file_id("OldFileId")?, + }, + old_content_digest: decode("OldContentDigest")?, + old_security_digest: decode("OldSecurityDigest")?, + new_identity: FileIdentity { + volume_serial: object + .get("NewVolumeSerial") + .and_then(serde_json::Value::as_u64) + .context("transaction marker NewVolumeSerial is missing or invalid")?, + file_id: decode_file_id("NewFileId")?, + }, + new_content_digest: decode("NewContentDigest")?, + new_security_digest: decode("NewSecurityDigest")?, + }) + } +} + +fn conditional_replace( + hosting_dir: &VerifiedHostingDirectory, + observed_target: RetainedPolicyFile, + expected_fingerprint: &DiskFingerprint, + final_path: &Path, + bytes: &[u8], +) -> Result { + use std::io::Write as _; + + let dir_handle = hosting_dir + .handle + .as_ref() + .expect("real storage always retains the directory handle"); + let paths = + TransactionPaths::new(hosting_dir.canonical_path(), final_path).map_err(WriteFailure::PrePublication)?; + + let mut marker_file = + create_secure_transaction_file_in(dir_handle, &paths.marker_staging).map_err(WriteFailure::PrePublication)?; + let mut temp_file = match create_secure_transaction_file_in(dir_handle, &paths.new) { + Ok(file) => file, + Err(error) => { + let error = cleanup_transaction_files(error, &[(&marker_file, "marker staging cleanup also failed")]); + return Err(WriteFailure::PrePublication(error)); + } + }; + if let Err(error) = temp_file + .write_all(bytes) + .and_then(|()| temp_file.sync_all()) + .context("failed to persist replacement policy") + .and_then(|()| { + policy_security::verify_managed_policy_file_security(&temp_file) + .context("replacement policy temporary file failed security verification") + }) + { + let error = cleanup_transaction_files( + error, + &[ + (&temp_file, "temporary replacement cleanup also failed"), + (&marker_file, "marker staging cleanup also failed"), + ], + ); + return Err(WriteFailure::PrePublication(error)); + } + let marker = + match TransactionMarker::from_observation(paths.id, final_path, expected_fingerprint, &temp_file, bytes) { + Ok(marker) => marker, + Err(error) => { + let error = cleanup_transaction_files( + error, + &[ + (&temp_file, "replacement policy cleanup also failed"), + (&marker_file, "marker staging cleanup also failed"), + ], + ); + return Err(WriteFailure::ConcurrentChange(error)); + } + }; + + if let Err(error) = marker_file + .write_all(&marker.to_bytes()) + .and_then(|()| marker_file.sync_all()) + .context("failed to persist policy transaction marker") + { + let error = cleanup_transaction_files( + error, + &[ + (&marker_file, "incomplete transaction marker cleanup also failed"), + (&temp_file, "replacement policy cleanup also failed"), + ], + ); + return Err(WriteFailure::PrePublication(error)); + } + if let Err(error) = rename_file_handle( + &marker_file, + dir_handle, + paths.marker.file_name().expect("transaction marker path has leaf"), + ) { + let error = cleanup_transaction_files( + anyhow::Error::new(error).context("failed to publish completed transaction marker"), + &[ + (&marker_file, "transaction marker staging cleanup also failed"), + (&temp_file, "replacement policy cleanup also failed"), + ], + ); + return Err(WriteFailure::PrePublication(error)); + } + + let prepared = PreparedTransaction { + dir_handle, + observed_target: &observed_target, + temp_file: &temp_file, + paths: &paths, + final_path, + }; + publish_prepared_transaction( + &prepared, + || Ok(()), + || verify_replacement_evidence(hosting_dir, &observed_target, expected_fingerprint), + || Ok(()), + )?; + + let persisted = + verify_persisted_handle(hosting_dir, &temp_file, final_path, bytes).map_err(WriteFailure::PostPublication)?; + delete_file_handle(observed_target.handle()).map_err(WriteFailure::PostPublication)?; + drop(observed_target); + delete_file_handle(&marker_file).map_err(WriteFailure::PostPublication)?; + drop(marker_file); + Ok(persisted) +} + +fn cleanup_transaction_files(mut error: anyhow::Error, files: &[(&File, &str)]) -> anyhow::Error { + for (file, message) in files { + if let Err(cleanup_error) = delete_file_handle(file) { + error = error.context(format!("{message}: {cleanup_error:#}")); + } + } + error +} + +struct PreparedTransaction<'a> { + dir_handle: &'a File, + observed_target: &'a RetainedPolicyFile, + temp_file: &'a File, + paths: &'a TransactionPaths, + final_path: &'a Path, +} + +fn publish_prepared_transaction( + transaction: &PreparedTransaction<'_>, + after_tombstone: impl FnOnce() -> anyhow::Result<()>, + verify_before_publish: impl FnOnce() -> anyhow::Result<()>, + after_publish: impl FnOnce() -> anyhow::Result<()>, +) -> Result<(), WriteFailure> { + if let Err(error) = rename_file_handle( + transaction.observed_target.handle(), + transaction.dir_handle, + transaction.paths.old.file_name().expect("transaction path has leaf"), + ) { + return Err(WriteFailure::PrePublication( + anyhow::Error::new(error).context("failed to reserve observed policy as transaction tombstone"), + )); + } + + after_tombstone().map_err(|error| { + WriteFailure::PrePublication(error.context("transaction interrupted after reserving the observed policy")) + })?; + + if let Err(error) = verify_before_publish() { + let restore_error = rename_file_handle( + transaction.observed_target.handle(), + transaction.dir_handle, + transaction.final_path.file_name().expect("policy path has leaf"), + ) + .err(); + return Err(WriteFailure::ConcurrentChange(match restore_error { + Some(restore_error) => error.context(format!( + "pre-publication evidence changed and the exact tombstone could not be restored: {restore_error}" + )), + None => error.context("pre-publication evidence changed; the exact tombstone was restored"), + })); + } + + if let Err(publish_error) = rename_file_handle( + transaction.temp_file, + transaction.dir_handle, + transaction.final_path.file_name().expect("policy path has leaf"), + ) { + let restore_result = rename_file_handle( + transaction.observed_target.handle(), + transaction.dir_handle, + transaction.final_path.file_name().expect("policy path has leaf"), + ); + if let Err(restore_error) = restore_result { + let final_guard = open_optional_final_guard(transaction.final_path).map_err(|error| { + WriteFailure::ConcurrentChange(error.context(format!( + "replacement publication and tombstone restoration failed: {publish_error}; {restore_error}" + ))) + })?; + let Some(final_guard) = final_guard else { + return Err(WriteFailure::PrePublication( + anyhow::Error::new(publish_error).context(format!( + "replacement publication failed and the original tombstone could not be restored: {restore_error}" + )), + )); + }; + drop(final_guard); + return Err(WriteFailure::ConcurrentChange( + anyhow::Error::new(publish_error).context("replacement lost a create-new publication race"), + )); + } + return Err(WriteFailure::PrePublication( + anyhow::Error::new(publish_error).context("replacement publication failed and the tombstone was restored"), + )); + } + + after_publish() + .context("transaction interrupted after replacement publication") + .map_err(WriteFailure::PostPublication)?; + + Ok(()) +} + +fn create_secure_transaction_file_in(directory: &File, path: &Path) -> anyhow::Result { + let security_attributes = policy_security::managed_policy_transaction_security_attributes(directory) + .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) })) +} + +#[cfg(test)] +fn create_secure_transaction_file(path: &Path) -> anyhow::Result { + let security_attributes = + policy_security::admin_only_security_attributes(false).context("build transaction file security")?; + let path = U16CString::from_os_str(path.as_os_str()).context("transaction path contains an interior NUL")?; + // SAFETY: The path and security attributes remain valid for the call, and the returned handle is owned. + let handle = unsafe { + CreateFileW( + path.as_pcwstr(), + GENERIC_READ.0 | GENERIC_WRITE.0 | DELETE.0 | READ_CONTROL.0, + FILE_SHARE_NONE, + Some(security_attributes.as_ptr()), + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH, + None, + ) + } + .context("failed to create secure transaction file")?; + // SAFETY: CreateFileW returned a new owned handle. + Ok(File::from(unsafe { OwnedHandle::from_raw_handle(handle.0) })) +} + +fn open_transaction_file(path: &Path) -> anyhow::Result { + OpenOptions::new() + .access_mode(FILE_GENERIC_READ.0 | DELETE.0 | READ_CONTROL.0) + .share_mode(FILE_SHARE_READ.0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(path) + .with_context(|| format!("failed to open transaction remnant {}", path.display())) +} + +#[derive(Debug)] +enum RenameFailure { + Win32(std::io::Error), + Native { win32: std::io::Error, status: NTSTATUS }, +} + +impl RenameFailure { + const STATUS_OBJECT_NAME_COLLISION: u32 = 0xC000_0035; + const STATUS_ACCESS_DENIED: u32 = 0xC000_0022; + const STATUS_SHARING_VIOLATION: u32 = 0xC000_0043; + const STATUS_INVALID_INFO_CLASS: u32 = 0xC000_0003; + const STATUS_INVALID_PARAMETER: u32 = 0xC000_000D; + const STATUS_INVALID_DEVICE_REQUEST: u32 = 0xC000_0010; + const STATUS_NOT_SUPPORTED: u32 = 0xC000_00BB; + + fn is_collision(&self) -> bool { + match self { + Self::Win32(error) => win32_error_is(error, &[ERROR_FILE_EXISTS, ERROR_ALREADY_EXISTS]), + Self::Native { status, .. } => status.0.cast_unsigned() == Self::STATUS_OBJECT_NAME_COLLISION, + } + } + + fn is_permission_failure(&self) -> bool { + match self { + Self::Win32(error) => win32_error_is(error, &[ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION]), + Self::Native { status, .. } => { + matches!( + status.0.cast_unsigned(), + Self::STATUS_ACCESS_DENIED | Self::STATUS_SHARING_VIOLATION + ) + } + } + } + + fn is_unsupported(&self) -> bool { + match self { + Self::Win32(error) => win32_error_is( + error, + &[ERROR_INVALID_FUNCTION, ERROR_NOT_SUPPORTED, ERROR_INVALID_PARAMETER], + ), + Self::Native { status, .. } => matches!( + status.0.cast_unsigned(), + Self::STATUS_INVALID_INFO_CLASS + | Self::STATUS_INVALID_PARAMETER + | Self::STATUS_INVALID_DEVICE_REQUEST + | Self::STATUS_NOT_SUPPORTED + ), + } + } +} + +impl std::fmt::Display for RenameFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Win32(error) => write!(f, "handle-relative rename failed through Win32: {error}"), + Self::Native { win32, status } => write!( + f, + "handle-relative rename failed through Win32 ({win32}) and NT ({:#010X})", + status.0.cast_unsigned() + ), + } + } +} + +impl std::error::Error for RenameFailure {} + +fn win32_error_is(error: &std::io::Error, codes: &[WIN32_ERROR]) -> bool { + let Some(raw) = error.raw_os_error() else { + return false; + }; + + codes.iter().any(|code| { + raw == code.0.cast_signed() + // windows-rs converts its Error to io::Error with the HRESULT as raw_os_error. + || raw == code.to_hresult().0 + }) +} + +fn rename_with_fallback( + preferred: impl FnOnce() -> std::io::Result<()>, + fallback: impl FnOnce() -> Result<(), NTSTATUS>, +) -> Result<(), RenameFailure> { + match preferred() { + Ok(()) => Ok(()), + Err(error) + if win32_error_is( + &error, + &[ + ERROR_FILE_EXISTS, + ERROR_ALREADY_EXISTS, + ERROR_ACCESS_DENIED, + ERROR_SHARING_VIOLATION, + ], + ) => + { + Err(RenameFailure::Win32(error)) + } + Err(win32) => fallback().map_err(|status| RenameFailure::Native { win32, status }), + } +} + +fn rename_file_handle(file: &File, root: &File, new_name: &OsStr) -> Result<(), RenameFailure> { + let information = RenameInformation::new(HANDLE(root.as_raw_handle()), new_name); + rename_with_fallback( + || set_file_rename_information(file, &information), + || nt_set_file_rename_information(file, &information), + ) +} + +struct RenameInformation { + buffer: Vec, + length: u32, +} + +impl RenameInformation { + #[expect( + clippy::multiple_unsafe_ops_per_block, + reason = "initializing one variable-length Win32 structure is one logical unsafe operation" + )] + fn new(root: HANDLE, new_name: &OsStr) -> Self { + let name: Vec = new_name.encode_wide().collect(); + let name_bytes = name.len().checked_mul(2).expect("file name byte length fits usize"); + let name_offset = std::mem::offset_of!(FILE_RENAME_INFO, FileName); + let buffer_len = size_of::() + .checked_add(name_bytes) + .expect("rename information length fits usize"); + let mut buffer = vec![0usize; buffer_len.div_ceil(size_of::())]; + let info = buffer.as_mut_ptr().cast::(); + // SAFETY: The aligned buffer is sized for FILE_RENAME_INFO plus the complete UTF-16 name. + unsafe { + (*info).Anonymous = FILE_RENAME_INFO_0 { Flags: 0 }; + (*info).RootDirectory = root; + (*info).FileNameLength = u32::try_from(name_bytes).expect("Windows file name length fits u32"); + std::ptr::copy_nonoverlapping(name.as_ptr(), info.cast::().add(name_offset).cast(), name.len()); + } + Self { + buffer, + length: u32::try_from(buffer_len).expect("rename buffer length fits u32"), + } + } + + fn as_ptr(&self) -> *const core::ffi::c_void { + self.buffer.as_ptr().cast() + } +} + +fn set_file_rename_information(file: &File, information: &RenameInformation) -> std::io::Result<()> { + // SAFETY: `information` contains a valid variable-length FILE_RENAME_INFO buffer. + let result = unsafe { + SetFileInformationByHandle( + HANDLE(file.as_raw_handle()), + FileRenameInfoEx, + information.as_ptr(), + information.length, + ) + }; + match result { + Ok(()) => Ok(()), + Err(error) if error.code() == windows::Win32::Foundation::E_INVALIDARG => { + // SAFETY: The same validated buffer is accepted by the older information class. + unsafe { + SetFileInformationByHandle( + HANDLE(file.as_raw_handle()), + FileRenameInfo, + information.as_ptr(), + information.length, + ) + .map_err(std::io::Error::from) + } + } + Err(error) => Err(std::io::Error::from(error)), + } +} + +fn nt_set_file_rename_information(file: &File, information: &RenameInformation) -> Result<(), NTSTATUS> { + let mut io_status = IoStatusBlock { + status_or_pointer: 0, + information: 0, + }; + + // SAFETY: `information` contains a valid variable-length FILE_RENAME_INFORMATION_EX buffer. + let status = unsafe { + NtSetInformationFile( + HANDLE(file.as_raw_handle()), + &mut io_status, + information.as_ptr(), + information.length, + FILE_RENAME_INFORMATION_EX_CLASS, + ) + }; + if status.0 >= 0 { Ok(()) } else { Err(status) } +} + +fn delete_file_handle(file: &File) -> anyhow::Result<()> { + let extended = FILE_DISPOSITION_INFO_EX { + Flags: FILE_DISPOSITION_INFO_EX_FLAGS( + FILE_DISPOSITION_FLAG_DELETE.0 + | FILE_DISPOSITION_FLAG_POSIX_SEMANTICS.0 + | FILE_DISPOSITION_FLAG_IGNORE_READONLY_ATTRIBUTE.0, + ), + }; + // SAFETY: The file handle is valid and extended points to a correctly sized input structure. + unsafe { + SetFileInformationByHandle( + HANDLE(file.as_raw_handle()), + FileDispositionInfoEx, + std::ptr::from_ref(&extended).cast(), + u32::try_from(size_of::()).expect("disposition structure size fits u32"), + ) + } + .context("failed to unlink transaction file by handle")?; + + Ok(()) +} + +fn prepublication_failure_after_cleanup( + file: &File, + error: anyhow::Error, + cleanup_failure_message: &str, +) -> WriteFailure { + match delete_file_handle(file) { + Ok(()) => WriteFailure::PrePublication(error), + Err(cleanup_error) => { + WriteFailure::PrePublication(error.context(format!("{cleanup_failure_message}: {cleanup_error:#}"))) + } + } +} + +fn recover_create_temporary_files(dir_path: &Path) -> anyhow::Result<()> { + let prefix = OsString::from(format!(".{POLICY_FILE_NAME}.tmp-")); + for entry in std::fs::read_dir(dir_path).context("failed to enumerate policy directory for create recovery")? { + let entry = entry.context("failed to enumerate policy create remnant")?; + let Some(remainder) = reserved_name_remainder(&entry.file_name(), &prefix)? else { + continue; + }; + uuid::Uuid::parse_str(&remainder).context("policy directory contains a malformed create remnant")?; + let path = entry.path(); + let file = open_transaction_file(&path)?; + verify_transaction_file_path(&file, &path)?; + policy_security::verify_managed_policy_file_security(&file) + .context("policy create remnant security is invalid")?; + delete_file_handle(&file).context("failed to retire policy create remnant")?; + drop(file); + } + Ok(()) +} + +fn reserved_name_remainder(name: &OsStr, prefix: &OsStr) -> anyhow::Result> { + let name_wide: Vec = name.encode_wide().collect(); + let prefix_wide: Vec = prefix.encode_wide().collect(); + if name_wide.len() < prefix_wide.len() { + return Ok(None); + } + let candidate_prefix = OsString::from_wide(&name_wide[..prefix_wide.len()]); + if !policy_security::os_strings_match_case_insensitive(&candidate_prefix, prefix) { + return Ok(None); + } + String::from_utf16(&name_wide[prefix_wide.len()..]) + .context("reserved policy remnant name is not Unicode") + .map(Some) +} + +fn recover_interrupted_transaction(dir: &File, dir_path: &Path, final_leaf: &OsStr) -> anyhow::Result<()> { + let final_leaf = final_leaf.to_str().context("policy leaf is not valid Unicode")?; + let transaction_prefix = OsString::from(format!(".{final_leaf}.txn-")); + let mut transaction_id = None; + let mut marker_staging_path = None; + let mut marker_path = None; + let mut old_path = None; + let mut new_path = None; + + for entry in std::fs::read_dir(dir_path).context("failed to enumerate policy directory for transaction recovery")? { + let entry = entry.context("failed to enumerate policy transaction remnant")?; + let name = entry.file_name(); + let Some(remainder) = reserved_name_remainder(&name, &transaction_prefix)? else { + continue; + }; + let Some((id, kind)) = remainder.split_once('.') else { + bail!("policy directory contains a malformed transaction remnant"); + }; + let id = uuid::Uuid::parse_str(id).context("policy directory contains a malformed transaction id")?; + if transaction_id.replace(id).is_some_and(|previous| previous != id) { + bail!("policy directory contains multiple interrupted transactions"); + } + let slot = match kind { + "marker.prepare" => &mut marker_staging_path, + "marker" => &mut marker_path, + "old" => &mut old_path, + "new" => &mut new_path, + _ => bail!("policy directory contains an unsupported transaction remnant"), + }; + ensure!( + slot.replace(entry.path()).is_none(), + "policy directory contains duplicate transaction remnants" + ); + } + + let Some(id) = transaction_id else { + return Ok(()); + }; + if let Some(marker_staging_path) = marker_staging_path { + ensure!( + marker_path.is_none() && old_path.is_none(), + "incomplete marker staging is mixed with published transaction remnants" + ); + let marker_staging = open_transaction_file(&marker_staging_path)?; + verify_transaction_file_path(&marker_staging, &marker_staging_path)?; + policy_security::verify_managed_policy_file_security(&marker_staging) + .context("transaction marker staging security is invalid")?; + let final_path = dir_path.join(final_leaf); + let new_file = if let Some(new_path) = new_path { + let file = open_transaction_file(&new_path)?; + verify_orphan_transaction_file(&file, &new_path)?; + Some(file) + } else { + None + }; + return recover_marker_staging(&final_path, marker_staging, new_file); + } + if marker_path.is_none() && old_path.is_none() { + let new_path = new_path.context("interrupted policy transaction has no durable state")?; + let new_file = open_transaction_file(&new_path)?; + verify_orphan_transaction_file(&new_file, &new_path)?; + let final_file = open_optional_final_policy(&dir_path.join(final_leaf))? + .context("orphan replacement has no original final")?; + verify_orphan_transaction_file(&final_file, &dir_path.join(final_leaf))?; + delete_file_handle(&new_file).context("failed to retire orphan replacement")?; + drop(new_file); + return Ok(()); + } + let marker_path = marker_path.context("interrupted policy transaction has no marker")?; + let paths = TransactionPaths { + id, + marker_staging: dir_path.join(format!(".{final_leaf}.txn-{id}.marker.prepare")), + marker: marker_path, + old: old_path.unwrap_or_else(|| dir_path.join(format!(".{final_leaf}.txn-{id}.old"))), + new: new_path.unwrap_or_else(|| dir_path.join(format!(".{final_leaf}.txn-{id}.new"))), + }; + let marker_file = open_transaction_file(&paths.marker)?; + verify_transaction_file_path(&marker_file, &paths.marker)?; + policy_security::verify_managed_policy_file_security(&marker_file) + .context("transaction marker security is invalid")?; + let marker_bytes = read_file_from_start(&marker_file)?; + let marker = TransactionMarker::from_bytes(&marker_bytes)?; + ensure!(marker.id == id, "transaction marker id does not match its name"); + ensure!( + policy_security::os_strings_match_case_insensitive(OsStr::new(&marker.final_leaf), OsStr::new(final_leaf)), + "transaction marker targets a different policy leaf" + ); + + let old_file = open_optional_transaction_file(&paths.old)?; + if let Some(old_file) = &old_file { + verify_transaction_file_path(old_file, &paths.old)?; + verify_transaction_file_state( + old_file, + marker.old_identity, + marker.old_content_digest, + marker.old_security_digest, + ) + .context("transaction tombstone does not match the observed policy")?; + } + + let new_file = open_optional_transaction_file(&paths.new)?; + if let Some(new_file) = &new_file { + verify_transaction_file_path(new_file, &paths.new)?; + verify_transaction_file_state( + new_file, + marker.new_identity, + marker.new_content_digest, + marker.new_security_digest, + ) + .context("transaction replacement does not match the prepared policy")?; + } + + recover_verified_transaction_with_evidence( + dir, + dir_path, + OsStr::new(final_leaf), + &marker, + marker_file, + old_file, + new_file, + ) +} + +fn recover_marker_staging(final_path: &Path, marker_staging: File, new_file: Option) -> anyhow::Result<()> { + let final_guard = open_optional_final_policy(final_path)? + .context("incomplete marker staging exists but the original policy is absent")?; + let marker_bytes = read_file_from_start(&marker_staging)?; + if let Ok(marker) = TransactionMarker::from_bytes(&marker_bytes) { + verify_transaction_file_state( + &final_guard, + marker.old_identity, + marker.old_content_digest, + marker.old_security_digest, + ) + .context("original policy changed during marker preparation")?; + if let Some(new_file) = &new_file { + verify_transaction_file_state( + new_file, + marker.new_identity, + marker.new_content_digest, + marker.new_security_digest, + ) + .context("prepared replacement changed during marker preparation")?; + } + } else { + // Before marker publication the original is untouched, but Repair may have started with invalid content. + verify_orphan_transaction_file(&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 recover_verified_transaction_with_evidence( + dir: &File, + dir_path: &Path, + final_leaf: &OsStr, + marker: &TransactionMarker, + marker_file: File, + mut old_file: Option, + new_file: Option, +) -> anyhow::Result<()> { + let final_path = dir_path.join(final_leaf); + let mut final_guard = open_optional_final_policy(&final_path)?; + let mut restored = false; + if final_guard.is_none() { + let old_file = old_file + .as_ref() + .context("interrupted transaction has neither a final policy nor a valid tombstone")?; + match rename_file_handle(old_file, dir, final_leaf) { + Ok(()) => restored = true, + Err(error) => { + final_guard = open_optional_final_policy(&final_path)?; + if final_guard.is_none() { + return Err(error).context("failed to restore interrupted policy transaction"); + } + tracing::warn!(%error, "An external policy appeared while transaction recovery restored the tombstone"); + } + } + } + + if !restored { + let final_file = final_guard + .as_ref() + .context("interrupted transaction has no final policy after recovery")?; + let final_state = verify_recovery_final(final_file, &final_path, marker)?; + if old_file.is_some() && final_state != RecoveryFinalState::PublishedReplacement { + bail!("raced final policy is not the intended published replacement; preserving recovery remnants"); + } + if let Some(old_file) = old_file.take() { + delete_file_handle(&old_file).context("failed to retire policy transaction tombstone")?; + drop(old_file); + } + } + if let Some(new_file) = new_file { + delete_file_handle(&new_file).context("failed to retire unpublished policy transaction replacement")?; + drop(new_file); + } + delete_file_handle(&marker_file).context("failed to retire policy transaction marker")?; + drop(marker_file); + drop(final_guard); + Ok(()) +} + +#[cfg(test)] +fn recover_verified_transaction( + dir: &File, + dir_path: &Path, + final_leaf: &OsStr, + marker_file: File, + old_file: Option, + new_file: Option, +) -> anyhow::Result<()> { + let final_path = dir_path.join(final_leaf); + let mut final_guard = open_optional_final_guard(&final_path)?; + let mut old_file = old_file; + let restored = if final_guard.is_none() { + let old = old_file + .as_ref() + .context("test transaction has neither a final policy nor a tombstone")?; + rename_file_handle(old, dir, final_leaf)?; + true + } else { + false + }; + if !restored && let Some(old) = old_file.take() { + delete_file_handle(&old)?; + } + if let Some(new_file) = new_file { + delete_file_handle(&new_file)?; + } + delete_file_handle(&marker_file)?; + drop(final_guard.take()); + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RecoveryFinalState { + ObservedOriginal, + PublishedReplacement, +} + +fn verify_recovery_final( + file: &File, + expected_path: &Path, + marker: &TransactionMarker, +) -> anyhow::Result { + verify_transaction_file_path(file, expected_path)?; + policy_security::verify_policy_file_path(file, expected_path)?; + let attributes = file.metadata()?.file_attributes(); + ensure!( + attributes & FILE_ATTRIBUTE_DIRECTORY.0 == 0, + "transaction final path is a directory" + ); + + if verify_transaction_file_state( + file, + marker.old_identity, + marker.old_content_digest, + marker.old_security_digest, + ) + .is_ok() + { + return Ok(RecoveryFinalState::ObservedOriginal); + } + + verify_transaction_file_state( + file, + marker.new_identity, + marker.new_content_digest, + marker.new_security_digest, + ) + .context("transaction final policy is not the prepared replacement")?; + let content = read_file_from_start(file)?; + let policy = serde_json::from_slice::(&content) + .context("transaction final replacement is not a policy document")?; + let validation = validation::validate_committed_policy(&policy); + ensure!( + validation.is_valid, + "transaction final replacement failed committed-policy validation" + ); + Ok(RecoveryFinalState::PublishedReplacement) +} + +fn open_optional_final_policy(path: &Path) -> anyhow::Result> { + match OpenOptions::new() + .access_mode(FILE_GENERIC_READ.0 | READ_CONTROL.0) + .share_mode(FILE_SHARE_READ.0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(path) + { + Ok(file) => Ok(Some(file)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("failed to retain raced policy {}", path.display())), + } +} + +fn open_optional_final_guard(path: &Path) -> anyhow::Result> { + match OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES.0) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE).0) + .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(path) + { + Ok(file) => Ok(Some(file)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("failed to retain raced policy {}", path.display())), + } +} + +fn open_optional_transaction_file(path: &Path) -> anyhow::Result> { + match open_transaction_file(path) { + Ok(file) => Ok(Some(file)), + Err(error) + if error + .root_cause() + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) => + { + Ok(None) + } + Err(error) => Err(error), + } +} + +fn verify_transaction_file_path(file: &File, expected: &Path) -> anyhow::Result<()> { + ensure!( + policy_security::file_link_count(file)? == 1, + "transaction file has multiple hard links" + ); + let resolved = policy_security::final_path_from_handle(file)?; + ensure!( + policy_security::paths_match_case_insensitive(&resolved, expected), + "transaction file resolved to an unexpected path" + ); + Ok(()) +} + +fn verify_transaction_file_state( + file: &File, + identity: FileIdentity, + content_digest: [u8; 32], + security_digest: [u8; 32], +) -> anyhow::Result<()> { + ensure!( + policy_security::file_identity(file)? == identity, + "transaction file identity changed" + ); + policy_security::verify_managed_policy_file_security(file)?; + ensure!( + policy_security::security_state_digest(file)? == security_digest, + "transaction file security changed" + ); + ensure!( + sha256_digest(&read_file_from_start(file)?) == content_digest, + "transaction file content changed" + ); + Ok(()) +} + +fn read_file_from_start(file: &File) -> anyhow::Result> { + use std::io::{Read as _, Seek as _, SeekFrom}; + let mut file = file; + file.seek(SeekFrom::Start(0))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + Ok(bytes) +} + +/// Atomically persist `bytes` to `final_path` only if nothing exists there yet: unlike +/// [`atomic_replace`], this never overwrites an existing destination. +/// +/// Used for `Create`, where the store already observed Missing under its write lock. If a +/// leaf has raced into existence between that observation and this call, the rename fails +/// (a [`WriteFailure::PrePublication`], since the destination was never touched) and the +/// caller must re-observe and report a stale token (see `PolicyStore::replace`) rather +/// than ever silently overwriting a file it never actually observed as absent. +pub(super) fn atomic_create( + hosting_dir: &VerifiedHostingDirectory, + expected_fingerprint: &DiskFingerprint, + final_path: &Path, + bytes: &[u8], +) -> Result { + use std::io::Write as _; + + verify_directory_evidence(hosting_dir, expected_fingerprint).map_err(WriteFailure::ConcurrentChange)?; + let dir_handle = hosting_dir + .handle + .as_ref() + .expect("real storage always retains the directory handle"); + let temp_path = hosting_dir + .canonical_path() + .join(format!(".{POLICY_FILE_NAME}.tmp-{}", uuid::Uuid::new_v4())); + let mut temp_file = + create_secure_transaction_file_in(dir_handle, &temp_path).map_err(WriteFailure::PrePublication)?; + if let Err(error) = temp_file + .write_all(bytes) + .and_then(|()| temp_file.sync_all()) + .context("failed to persist new policy") + .and_then(|()| { + policy_security::verify_managed_policy_file_security(&temp_file) + .context("new policy temporary file failed security verification") + }) + { + return Err(prepublication_failure_after_cleanup( + &temp_file, + error, + "temporary policy cleanup also failed", + )); + } + if let Err(failure) = publish_created_file(&temp_file, dir_handle, final_path, || { + verify_directory_evidence(hosting_dir, expected_fingerprint) + }) { + let cleanup_error = delete_file_handle(&temp_file).err(); + return Err(match (failure, cleanup_error) { + (WriteFailure::ConcurrentChange(error), Some(cleanup_error)) => WriteFailure::ConcurrentChange( + error.context(format!("temporary policy cleanup also failed: {cleanup_error:#}")), + ), + (WriteFailure::PrePublication(error), Some(cleanup_error)) => WriteFailure::PrePublication( + error.context(format!("temporary policy cleanup also failed: {cleanup_error:#}")), + ), + (WriteFailure::PostPublication(error), Some(cleanup_error)) => WriteFailure::PostPublication( + error.context(format!("temporary policy cleanup also failed: {cleanup_error:#}")), + ), + (failure, _) => failure, + }); + } + verify_persisted_handle(hosting_dir, &temp_file, final_path, bytes).map_err(WriteFailure::PostPublication) +} + +fn publish_created_file( + temp_file: &File, + dir_handle: &File, + final_path: &Path, + verify_before_publish: impl FnOnce() -> anyhow::Result<()>, +) -> Result<(), WriteFailure> { + verify_before_publish() + .context("policy directory evidence changed before create publication") + .map_err(WriteFailure::ConcurrentChange)?; + rename_file_handle( + temp_file, + dir_handle, + final_path.file_name().expect("policy path has leaf"), + ) + .map_err(|error| { + WriteFailure::PrePublication(anyhow::Error::new(error).context("failed to atomically create policy file")) + }) +} + +/// Verify the published handle, directory, ancestor chain, exact bytes, and parsed policy. +/// The returned [`PersistedPolicy`] reflects the object made active by the handle-relative rename. +/// This check repeats committed-policy validation instead of trusting the earlier draft validation. +/// +fn verify_persisted_handle( + hosting_dir: &VerifiedHostingDirectory, + final_file: &File, + final_path: &Path, + expected_bytes: &[u8], +) -> anyhow::Result { + let dir_security_digest = hosting_dir + .verify_unchanged() + .context("held policy directory changed during replacement")?; + let parent = hosting_dir.identity(); + let ancestor_security_digest = hosting_dir + .ancestor_digest() + .context("policy directory ancestor chain failed verification immediately after writing")?; + let resolved = + policy_security::final_path_from_handle(final_file).context("failed to resolve persisted policy handle")?; + ensure!( + policy_security::paths_match_case_insensitive(&resolved, final_path), + "persisted policy handle resolved to an unexpected path" + ); + let target = policy_security::file_identity(final_file) + .context("failed to query policy file identity for post-write verification")?; + + policy_security::verify_managed_policy_file_security(final_file) + .context("policy file failed security verification immediately after being written")?; + + let security_digest = policy_security::security_state_digest(final_file) + .context("failed to compute policy file security digest immediately after being written")?; + + let persisted = read_file_from_start(final_file).context("failed to re-read persisted policy file")?; + + if persisted != expected_bytes { + bail!("persisted policy file content does not match what was written"); + } + + let policy = serde_json::from_slice::(&persisted) + .context("failed to reparse the freshly persisted policy file")?; + + let committed_validation = validation::validate_committed_policy(&policy); + ensure!( + committed_validation.is_valid, + "freshly persisted policy file failed authoritative semantic validation: {:?}", + committed_validation.findings + ); + + let content_digest = sha256_digest(&persisted); + + Ok(PersistedPolicy { + policy, + fingerprint: DiskFingerprint::Active { + parent, + target, + content_digest, + security_digest, + dir_security_digest, + ancestor_security_digest, + }, + write_capability: PolicyWriteCapability::Writable, + read_only_reason: None, + canonical_path: final_path.to_owned(), + }) +} + +#[cfg(test)] +fn move_replace(from: &Path, to: &Path) -> anyhow::Result<()> { + let from = U16CString::from_os_str(from.as_os_str()).context("replacement path contains an interior NUL")?; + let to = U16CString::from_os_str(to.as_os_str()).context("target path contains an interior NUL")?; + // SAFETY: Both paths are valid, NUL-terminated UTF-16 strings live for the call. + unsafe { MoveFileExW(from.as_pcwstr(), to.as_pcwstr(), MOVEFILE_REPLACE_EXISTING) } + .context("MoveFileExW replacement failed") +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + fn temp_dir() -> tempfile::TempDir { + tempfile::tempdir().expect("create temp dir") + } + + fn committed_policy_bytes(default_decision: &str) -> Vec { + let draft: now_policy::PolicyDraftDocument = serde_json::from_value(serde_json::json!({ + "PolicyFormatVersion": "1.0.0", + "Metadata": { "Id": "recovery-test", "Publisher": "Test" }, + "Enforcement": { "DefaultDecision": default_decision }, + "Rules": [] + })) + .unwrap(); + let policy = draft.into_policy_document(1, chrono::Utc::now()).unwrap(); + serde_json::to_vec(&policy).unwrap() + } + + fn valid_committed_policy_bytes() -> Vec { + committed_policy_bytes("Deny") + } + + fn observe_test_content(content: &[u8]) -> DiskObservation { + observation_from_parts( + Path::new(r"C:\policy.json"), + content, + VerifiedIdentity { + parent: test_identity(1), + dir_security_digest: test_security_digest(1), + ancestor_security_digest: test_security_digest(1), + target: test_identity(2), + security_digest: test_security_digest(1), + }, + PolicyWriteCapability::Writable, + None, + None, + None, + ) + } + + #[test] + fn committed_policy_observation_preserves_compatible_format_and_exact_content_digest() { + let mut current: serde_json::Value = serde_json::from_slice(&valid_committed_policy_bytes()).unwrap(); + for version in ["1.0.0", "1.7.3"] { + current["PolicyFormatVersion"] = serde_json::json!(version); + let content = serde_json::to_vec(¤t).unwrap(); + let observation = observe_test_content(&content); + assert_eq!(observation.state, PolicyManagementState::Active); + let policy = observation.policy.unwrap(); + assert_eq!(serde_json::to_value(&policy).unwrap()["PolicyFormatVersion"], version); + let draft = serde_json::to_value(policy.to_draft()).unwrap(); + assert_eq!(draft["PolicyFormatVersion"], version); + assert_eq!( + observation.fingerprint.target_state().unwrap().1, + sha256_digest(&content) + ); + let pretty = serde_json::to_vec_pretty(¤t).unwrap(); + assert_ne!(observation.fingerprint, observe_test_content(&pretty).fingerprint); + } + for version in ["0.9.0", "2.0.0", "broken"] { + current["PolicyFormatVersion"] = serde_json::json!(version); + let observed = observe_test_content(&serde_json::to_vec(¤t).unwrap()); + assert_eq!(observed.state, PolicyManagementState::Invalid); + assert!(observed.policy.is_none()); + } + } + + fn open_deletable_test_file(path: &Path, content: &[u8]) -> File { + std::fs::write(path, content).unwrap(); + OpenOptions::new() + .access_mode(FILE_GENERIC_READ.0 | DELETE.0 | READ_CONTROL.0) + .share_mode(FILE_SHARE_READ.0) + .open(path) + .unwrap() + } + + #[test] + fn handle_rename_never_replaces_an_existing_destination() { + let dir = temp_dir(); + let source = dir.path().join("source.tmp"); + let destination = dir.path().join("destination.json"); + let source_file = open_deletable_test_file(&source, b"source"); + std::fs::write(&destination, b"destination").unwrap(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + + rename_file_handle(&source_file, &dir_file, destination.file_name().unwrap()).unwrap_err(); + + assert_eq!(std::fs::read(&source).unwrap(), b"source"); + assert_eq!(std::fs::read(&destination).unwrap(), b"destination"); + } + + #[test] + fn create_evidence_change_never_publishes_the_temporary_file() { + let dir = temp_dir(); + let temporary = dir.path().join("temporary.tmp"); + let final_path = dir.path().join("policy.json"); + let temporary_file = open_deletable_test_file(&temporary, b"new"); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + + let result = publish_created_file(&temporary_file, &dir_file, &final_path, || { + anyhow::bail!("simulated ancestor ACL change") + }); + + assert!(matches!(result, Err(WriteFailure::ConcurrentChange(_)))); + assert!(!final_path.exists()); + assert_eq!(std::fs::read(&temporary).unwrap(), b"new"); + } + + #[test] + fn relative_handle_rename_uses_retained_directory_root() { + let dir = temp_dir(); + let source = dir.path().join("source.tmp"); + let destination = dir.path().join("destination.json"); + let source_file = open_deletable_test_file(&source, b"source"); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + + rename_file_handle(&source_file, &dir_file, destination.file_name().unwrap()).unwrap(); + + assert!(!source.exists()); + assert_eq!(std::fs::read(&destination).unwrap(), b"source"); + } + + #[test] + fn retained_custom_directory_handle_supports_create_and_replace_renames() { + let dir = temp_dir(); + let mut handles = + policy_security::retain_policy_no_reparse_directory_chain(dir.path(), "custom policy directory").unwrap(); + let dir_handle = handles.pop().expect("retained custom directory handle"); + let first_path = dir.path().join("first.tmp"); + let replacement_path = dir.path().join("replacement.tmp"); + let final_path = dir.path().join("policy.json"); + let tombstone_path = dir.path().join("policy.old"); + let first = open_deletable_test_file(&first_path, b"first"); + let replacement = open_deletable_test_file(&replacement_path, b"replacement"); + + rename_file_handle(&first, &dir_handle, final_path.file_name().unwrap()).unwrap(); + rename_file_handle(&replacement, &dir_handle, final_path.file_name().unwrap()).unwrap_err(); + rename_file_handle(&first, &dir_handle, tombstone_path.file_name().unwrap()).unwrap(); + rename_file_handle(&replacement, &dir_handle, final_path.file_name().unwrap()).unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), b"replacement"); + assert_eq!(std::fs::read(&tombstone_path).unwrap(), b"first"); + } + + #[test] + fn native_relative_handle_rename_uses_retained_directory_root() { + let dir = temp_dir(); + let source = dir.path().join("source-native.tmp"); + let destination = dir.path().join("destination-native.json"); + let source_file = open_deletable_test_file(&source, b"source"); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + + let information = RenameInformation::new(HANDLE(dir_file.as_raw_handle()), destination.file_name().unwrap()); + nt_set_file_rename_information(&source_file, &information).unwrap(); + + assert!(!source.exists()); + assert_eq!(std::fs::read(&destination).unwrap(), b"source"); + } + + #[test] + fn retained_directory_prevents_path_retarget_during_handle_rename() { + let root = temp_dir(); + let dir = root.path().join("held"); + std::fs::create_dir(&dir).unwrap(); + let source = dir.join("source.tmp"); + let destination = dir.join("destination.json"); + let source_file = open_deletable_test_file(&source, b"source"); + let dir_file = open_directory_no_reparse(&dir).unwrap(); + + assert!(std::fs::rename(&dir, root.path().join("retargeted")).is_err()); + rename_file_handle(&source_file, &dir_file, destination.file_name().unwrap()).unwrap(); + + assert_eq!(std::fs::read(&destination).unwrap(), b"source"); + assert!(!root.path().join("retargeted").exists()); + } + + #[test] + fn retained_write_observation_blocks_external_target_changes() { + let dir = temp_dir(); + let path = dir.path().join("policy.json"); + let replacement = dir.path().join("replacement.json"); + let retained = open_deletable_test_file(&path, b"observed"); + std::fs::write(&replacement, b"replacement").unwrap(); + + assert!(std::fs::write(&path, b"edited").is_err()); + assert!(std::fs::remove_file(&path).is_err()); + assert!(std::fs::rename(&path, dir.path().join("replaced.json")).is_err()); + assert!(move_replace(&replacement, &path).is_err()); + assert_eq!(read_file_from_start(&retained).unwrap(), b"observed"); + assert_eq!(std::fs::read(&replacement).unwrap(), b"replacement"); + } + + #[test] + fn ordinary_reader_blocks_retention_but_not_read_observation() { + let dir = temp_dir(); + let path = dir.path().join("policy.json"); + std::fs::write(&path, b"observed").unwrap(); + let reader = OpenOptions::new() + .read(true) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE).0) + .open(&path) + .unwrap(); + + let ordinary_observation = open_policy_file(&path, false).unwrap(); + assert!(!ordinary_observation.retained_for_write); + let fallback_observation = open_policy_file(&path, true).unwrap(); + assert!(!fallback_observation.retained_for_write); + assert_eq!(read_file_from_start(&reader).unwrap(), b"observed"); + } + + #[test] + fn ordinary_observation_waits_for_preexisting_writer_or_deleter() { + let dir = temp_dir(); + let path = dir.path().join("policy.json"); + std::fs::write(&path, b"observed").unwrap(); + let writer = OpenOptions::new() + .read(true) + .write(true) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) + .open(&path) + .unwrap(); + + assert!(open_policy_file(&path, false).is_err()); + drop(writer); + assert!(open_policy_file(&path, false).is_ok()); + + let deleter = OpenOptions::new() + .access_mode(FILE_GENERIC_READ.0 | DELETE.0) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) + .open(&path) + .unwrap(); + assert!(open_policy_file(&path, false).is_err()); + drop(deleter); + assert!(open_policy_file(&path, false).is_ok()); + } + + #[test] + fn handle_cleanup_removes_read_only_transaction_files() { + let dir = temp_dir(); + let path = dir.path().join("readonly.old"); + std::fs::write(&path, b"old").unwrap(); + let mut permissions = std::fs::metadata(&path).unwrap().permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&path, permissions).unwrap(); + let file = open_transaction_file(&path).unwrap(); + + delete_file_handle(&file).unwrap(); + drop(file); + + assert!(!path.exists()); + } + + #[test] + fn conditional_publication_preserves_a_final_created_after_tombstoning() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let paths = TransactionPaths::new(dir.path(), &final_path).unwrap(); + let observed = RetainedPolicyFile::Real(open_deletable_test_file(&final_path, b"observed")); + let marker = open_deletable_test_file(&paths.marker, b"marker"); + let replacement = open_deletable_test_file(&paths.new, b"replacement"); + + let prepared = PreparedTransaction { + dir_handle: &dir_file, + observed_target: &observed, + temp_file: &replacement, + paths: &paths, + final_path: &final_path, + }; + let result = publish_prepared_transaction( + &prepared, + || { + std::fs::write(&final_path, b"external")?; + Ok(()) + }, + || Ok(()), + || Ok(()), + ); + + assert!(matches!(result, Err(WriteFailure::ConcurrentChange(_)))); + let old = observed.into_handle(); + recover_verified_transaction( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + marker, + Some(old), + Some(replacement), + ) + .unwrap(); + assert_eq!(std::fs::read(&final_path).unwrap(), b"external"); + assert!(!paths.old.exists()); + assert!(!paths.new.exists()); + assert!(!paths.marker.exists()); + } + + #[test] + fn interrupted_publication_recovers_the_exact_observed_target() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let paths = TransactionPaths::new(dir.path(), &final_path).unwrap(); + let observed = RetainedPolicyFile::Real(open_deletable_test_file(&final_path, b"observed")); + let marker = open_deletable_test_file(&paths.marker, b"marker"); + let replacement = open_deletable_test_file(&paths.new, b"replacement"); + + let prepared = PreparedTransaction { + dir_handle: &dir_file, + observed_target: &observed, + temp_file: &replacement, + paths: &paths, + final_path: &final_path, + }; + let result = publish_prepared_transaction( + &prepared, + || anyhow::bail!("simulated crash after tombstone"), + || Ok(()), + || Ok(()), + ); + assert!(matches!(result, Err(WriteFailure::PrePublication(_)))); + assert!(!final_path.exists()); + assert!(paths.old.exists()); + + let old = observed.into_handle(); + recover_verified_transaction( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + marker, + Some(old), + Some(replacement), + ) + .unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), b"observed"); + assert!(!paths.new.exists()); + assert!(!paths.marker.exists()); + } + + #[test] + fn changed_post_tombstone_evidence_restores_observed_target_before_conflict() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let paths = TransactionPaths::new(dir.path(), &final_path).unwrap(); + let observed = RetainedPolicyFile::Real(open_deletable_test_file(&final_path, b"observed")); + let marker = open_deletable_test_file(&paths.marker, b"marker"); + let replacement = open_deletable_test_file(&paths.new, b"replacement"); + let prepared = PreparedTransaction { + dir_handle: &dir_file, + observed_target: &observed, + temp_file: &replacement, + paths: &paths, + final_path: &final_path, + }; + + let result = publish_prepared_transaction( + &prepared, + || Ok(()), + || anyhow::bail!("simulated retained target mutation"), + || Ok(()), + ); + + assert!(matches!(result, Err(WriteFailure::ConcurrentChange(_)))); + assert_eq!(read_file_from_start(observed.handle()).unwrap(), b"observed"); + assert!(final_path.exists()); + assert!(!paths.old.exists()); + + drop(observed); + recover_verified_transaction( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + marker, + None, + Some(replacement), + ) + .unwrap(); + assert_eq!(std::fs::read(&final_path).unwrap(), b"observed"); + assert!(!paths.new.exists()); + assert!(!paths.marker.exists()); + } + + #[test] + fn recovery_preserves_published_replacement_after_interruption() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let paths = TransactionPaths::new(dir.path(), &final_path).unwrap(); + let observed = RetainedPolicyFile::Real(open_deletable_test_file(&final_path, b"observed")); + let marker = open_deletable_test_file(&paths.marker, b"marker"); + let replacement = open_deletable_test_file(&paths.new, b"replacement"); + + let prepared = PreparedTransaction { + dir_handle: &dir_file, + observed_target: &observed, + temp_file: &replacement, + paths: &paths, + final_path: &final_path, + }; + let result = publish_prepared_transaction( + &prepared, + || Ok(()), + || Ok(()), + || anyhow::bail!("simulated crash after publication"), + ); + assert!(matches!(result, Err(WriteFailure::PostPublication(_)))); + assert_eq!(read_file_from_start(&replacement).unwrap(), b"replacement"); + assert!(paths.old.exists()); + + let old = observed.into_handle(); + drop(replacement); + recover_verified_transaction( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + marker, + Some(old), + None, + ) + .unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), b"replacement"); + assert!(!paths.old.exists()); + assert!(!paths.marker.exists()); + } + + #[test] + fn recovery_restores_exact_tombstone_when_final_is_absent() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let marker_path = dir.path().join("marker"); + let old_path = dir.path().join("old"); + let new_path = dir.path().join("new"); + let marker = open_deletable_test_file(&marker_path, b"marker"); + let old = open_deletable_test_file(&old_path, b"old"); + let new = open_deletable_test_file(&new_path, b"new"); + + recover_verified_transaction( + &dir_file, + dir.path(), + OsStr::new("policy.json"), + marker, + Some(old), + Some(new), + ) + .unwrap(); + + assert_eq!(std::fs::read(dir.path().join("policy.json")).unwrap(), b"old"); + assert!(!marker_path.exists()); + assert!(!old_path.exists()); + assert!(!new_path.exists()); + + recover_interrupted_transaction(&dir_file, dir.path(), OsStr::new("policy.json")) + .expect("recovery is idempotent after cleanup"); + assert_eq!(std::fs::read(dir.path().join("policy.json")).unwrap(), b"old"); + } + + #[test] + fn recovery_discards_incomplete_marker_staging_only_when_final_is_present() { + use std::io::Write as _; + + let dir = temp_dir(); + let final_path = dir.path().join("policy.json"); + let staging_path = dir.path().join("marker.prepare"); + let mut final_file = match create_secure_transaction_file(&final_path) { + Ok(file) => file, + Err(_) => return, + }; + let original = valid_committed_policy_bytes(); + final_file.write_all(&original).unwrap(); + final_file.sync_all().unwrap(); + drop(final_file); + let staging = open_deletable_test_file(&staging_path, b"partial marker"); + + recover_marker_staging(&final_path, staging, None).unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), original); + assert!(!staging_path.exists()); + } + + #[test] + fn recovery_retains_incomplete_marker_staging_when_final_is_absent() { + let dir = temp_dir(); + let final_path = dir.path().join("policy.json"); + let staging_path = dir.path().join("marker.prepare"); + let staging = open_deletable_test_file(&staging_path, b"partial marker"); + + recover_marker_staging(&final_path, staging, None).unwrap_err(); + + assert_eq!(std::fs::read(&staging_path).unwrap(), b"partial marker"); + assert!(staging_path.exists()); + } + + #[test] + fn recovery_retires_pre_marker_replacement_when_original_is_safe() { + use std::io::Write as _; + + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let paths = TransactionPaths::new(dir.path(), &final_path).unwrap(); + let original = valid_committed_policy_bytes(); + let mut final_file = match create_secure_transaction_file(&final_path) { + Ok(file) => file, + Err(_) => return, + }; + final_file.write_all(&original).unwrap(); + final_file.sync_all().unwrap(); + drop(final_file); + let mut replacement = create_secure_transaction_file(&paths.new).unwrap(); + replacement.write_all(b"partial replacement").unwrap(); + replacement.sync_all().unwrap(); + drop(replacement); + + recover_interrupted_transaction(&dir_file, dir.path(), final_path.file_name().unwrap()).unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), original); + assert!(!paths.new.exists()); + } + + #[test] + fn recovery_retires_marker_staging_and_replacement_when_original_is_safe() { + use std::io::Write as _; + + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let paths = TransactionPaths::new(dir.path(), &final_path).unwrap(); + let original = valid_committed_policy_bytes(); + let mut final_file = match create_secure_transaction_file(&final_path) { + Ok(file) => file, + Err(_) => return, + }; + final_file.write_all(&original).unwrap(); + final_file.sync_all().unwrap(); + drop(final_file); + let marker_staging = create_secure_transaction_file(&paths.marker_staging).unwrap(); + drop(marker_staging); + let replacement = create_secure_transaction_file(&paths.new).unwrap(); + drop(replacement); + + recover_interrupted_transaction(&dir_file, dir.path(), final_path.file_name().unwrap()).unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), original); + assert!(!paths.marker_staging.exists()); + assert!(!paths.new.exists()); + } + + #[test] + fn interrupted_repair_preserves_invalid_original_and_retires_prepublication_remnants() { + use std::io::Write as _; + + let original = b"malformed policy"; + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let mut final_file = match create_secure_transaction_file(&final_path) { + Ok(file) => file, + Err(error) => { + tracing::warn!( + error = %format!("{error:#}"), + "Skipping administrator-owned repair recovery fixtures" + ); + return; + } + }; + final_file.write_all(original).unwrap(); + final_file.sync_all().unwrap(); + drop(final_file); + for stage_marker in [false, true] { + let paths = TransactionPaths::new(dir.path(), &final_path).unwrap(); + let mut replacement = create_secure_transaction_file(&paths.new).unwrap(); + replacement.write_all(b"partial replacement").unwrap(); + replacement.sync_all().unwrap(); + drop(replacement); + if stage_marker { + let mut staging = create_secure_transaction_file(&paths.marker_staging).unwrap(); + staging.write_all(br#"{"Version":"#).unwrap(); + staging.sync_all().unwrap(); + } + + 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()); + assert!(!paths.marker_staging.exists()); + let observed = observe_test_content(original); + assert_eq!(observed.state, PolicyManagementState::Invalid); + assert_eq!(observed.write_capability, PolicyWriteCapability::Writable); + assert!(observed.policy.is_none()); + } + } + + #[test] + fn recovery_preserves_untrusted_pre_marker_collision() { + use std::io::Write as _; + + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let paths = TransactionPaths::new(dir.path(), &final_path).unwrap(); + let mut final_file = match create_secure_transaction_file(&final_path) { + Ok(file) => file, + Err(_) => return, + }; + final_file.write_all(&valid_committed_policy_bytes()).unwrap(); + final_file.sync_all().unwrap(); + drop(final_file); + std::fs::write(&paths.new, b"external collision").unwrap(); + + recover_interrupted_transaction(&dir_file, dir.path(), final_path.file_name().unwrap()).unwrap_err(); + + assert_eq!(std::fs::read(&paths.new).unwrap(), b"external collision"); + assert!(final_path.exists()); + } + + #[test] + fn recovery_preserves_original_before_tombstoning() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let marker_path = dir.path().join("marker"); + let new_path = dir.path().join("new"); + std::fs::write(&final_path, b"original").unwrap(); + let marker = open_deletable_test_file(&marker_path, b"marker"); + let new = open_deletable_test_file(&new_path, b"new"); + + recover_verified_transaction( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + marker, + None, + Some(new), + ) + .unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), b"original"); + assert!(!marker_path.exists()); + assert!(!new_path.exists()); + } + + #[test] + fn recovery_preserves_final_created_after_tombstoning() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let marker_path = dir.path().join("marker"); + let old_path = dir.path().join("old"); + let new_path = dir.path().join("new"); + std::fs::write(&final_path, b"external").unwrap(); + let marker = open_deletable_test_file(&marker_path, b"marker"); + let old = open_deletable_test_file(&old_path, b"old"); + let new = open_deletable_test_file(&new_path, b"new"); + + recover_verified_transaction( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + marker, + Some(old), + Some(new), + ) + .unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), b"external"); + assert!(!marker_path.exists()); + assert!(!old_path.exists()); + assert!(!new_path.exists()); + } + + #[test] + fn recovery_preserves_verified_tombstone_when_raced_final_is_unsafe() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let marker_path = dir.path().join("marker"); + let old_path = dir.path().join("old"); + std::fs::write(&final_path, b"malicious").unwrap(); + let marker_file = open_deletable_test_file(&marker_path, b"marker"); + let old_file = open_deletable_test_file(&old_path, b"verified-old"); + let marker = TransactionMarker { + id: uuid::Uuid::new_v4(), + final_leaf: "policy.json".to_owned(), + old_identity: policy_security::file_identity(&old_file).unwrap(), + old_content_digest: sha256_digest(b"verified-old"), + old_security_digest: policy_security::security_state_digest(&old_file).unwrap(), + new_identity: test_identity(99), + new_content_digest: sha256_digest(b"intended-new"), + new_security_digest: test_security_digest(99), + }; + + recover_verified_transaction_with_evidence( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + &marker, + marker_file, + Some(old_file), + None, + ) + .unwrap_err(); + + assert_eq!(std::fs::read(&old_path).unwrap(), b"verified-old"); + assert_eq!(std::fs::read(&final_path).unwrap(), b"malicious"); + assert!(marker_path.exists(), "failed recovery must preserve its marker"); + } + + #[test] + fn recovery_preserves_all_evidence_for_distinct_same_byte_final_and_new() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let marker_path = dir.path().join("marker"); + let old_path = dir.path().join("old"); + let new_path = dir.path().join("new"); + std::fs::write(&final_path, b"intended-new").unwrap(); + let marker_file = open_deletable_test_file(&marker_path, b"marker"); + let old_file = open_deletable_test_file(&old_path, b"verified-old"); + let new_file = open_deletable_test_file(&new_path, b"intended-new"); + let marker = TransactionMarker { + id: uuid::Uuid::new_v4(), + final_leaf: "policy.json".to_owned(), + old_identity: policy_security::file_identity(&old_file).unwrap(), + old_content_digest: sha256_digest(b"verified-old"), + old_security_digest: policy_security::security_state_digest(&old_file).unwrap(), + new_identity: policy_security::file_identity(&new_file).unwrap(), + new_content_digest: sha256_digest(b"intended-new"), + new_security_digest: policy_security::security_state_digest(&new_file).unwrap(), + }; + + recover_verified_transaction_with_evidence( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + &marker, + marker_file, + Some(old_file), + Some(new_file), + ) + .unwrap_err(); + + assert_eq!(std::fs::read(&old_path).unwrap(), b"verified-old"); + assert_eq!(std::fs::read(&new_path).unwrap(), b"intended-new"); + assert_eq!(std::fs::read(&final_path).unwrap(), b"intended-new"); + assert!(marker_path.exists()); + } + + #[test] + fn recovery_rejects_same_byte_substitute_when_prepared_file_is_absent() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let marker_path = dir.path().join("marker"); + let old_path = dir.path().join("old"); + let prepared_path = dir.path().join("prepared"); + let prepared = open_deletable_test_file(&prepared_path, b"intended-new"); + let marker = TransactionMarker { + id: uuid::Uuid::new_v4(), + final_leaf: "policy.json".to_owned(), + old_identity: test_identity(1), + old_content_digest: sha256_digest(b"verified-old"), + old_security_digest: test_security_digest(1), + new_identity: policy_security::file_identity(&prepared).unwrap(), + new_content_digest: sha256_digest(b"intended-new"), + new_security_digest: policy_security::security_state_digest(&prepared).unwrap(), + }; + drop(prepared); + std::fs::remove_file(prepared_path).unwrap(); + std::fs::write(&final_path, b"intended-new").unwrap(); + let marker_file = open_deletable_test_file(&marker_path, b"marker"); + let old_file = open_deletable_test_file(&old_path, b"verified-old"); + + recover_verified_transaction_with_evidence( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + &marker, + marker_file, + Some(old_file), + None, + ) + .unwrap_err(); + + assert_eq!(std::fs::read(&old_path).unwrap(), b"verified-old"); + assert_eq!(std::fs::read(&final_path).unwrap(), b"intended-new"); + assert!(marker_path.exists()); + } + + #[test] + fn recovery_accepts_the_exact_prepared_file_after_genuine_rename() { + use std::io::Write as _; + + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let prepared_path = dir.path().join("prepared"); + let marker_path = dir.path().join("marker"); + let old_path = dir.path().join("old"); + let mut prepared = match create_secure_transaction_file(&prepared_path) { + Ok(file) => file, + Err(_) => return, + }; + let bytes = valid_committed_policy_bytes(); + prepared.write_all(&bytes).unwrap(); + prepared.sync_all().unwrap(); + let marker = TransactionMarker { + id: uuid::Uuid::new_v4(), + final_leaf: "policy.json".to_owned(), + old_identity: test_identity(1), + old_content_digest: sha256_digest(b"verified-old"), + old_security_digest: test_security_digest(1), + new_identity: policy_security::file_identity(&prepared).unwrap(), + new_content_digest: sha256_digest(&bytes), + new_security_digest: policy_security::security_state_digest(&prepared).unwrap(), + }; + rename_file_handle(&prepared, &dir_file, final_path.file_name().unwrap()).unwrap(); + drop(prepared); + let marker_file = open_deletable_test_file(&marker_path, b"marker"); + let old_file = open_deletable_test_file(&old_path, b"verified-old"); + + recover_verified_transaction_with_evidence( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + &marker, + marker_file, + Some(old_file), + None, + ) + .unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), bytes); + assert!(!old_path.exists()); + assert!(!marker_path.exists()); + } + + #[test] + fn recovery_accepts_exact_warning_bearing_replacement() { + use std::io::Write as _; + + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let final_path = dir.path().join("policy.json"); + let prepared_path = dir.path().join("prepared"); + let marker_path = dir.path().join("marker"); + let old_path = dir.path().join("old"); + let mut prepared = match create_secure_transaction_file(&prepared_path) { + Ok(file) => file, + Err(_) => return, + }; + let bytes = committed_policy_bytes("Allow"); + let policy: PolicyDocument = serde_json::from_slice(&bytes).unwrap(); + let validation = validation::validate_committed_policy(&policy); + assert!(validation.is_valid); + assert!( + !validation.findings.is_empty(), + "test policy must exercise warning recovery" + ); + prepared.write_all(&bytes).unwrap(); + prepared.sync_all().unwrap(); + let marker = TransactionMarker { + id: uuid::Uuid::new_v4(), + final_leaf: "policy.json".to_owned(), + old_identity: test_identity(1), + old_content_digest: sha256_digest(b"verified-old"), + old_security_digest: test_security_digest(1), + new_identity: policy_security::file_identity(&prepared).unwrap(), + new_content_digest: sha256_digest(&bytes), + new_security_digest: policy_security::security_state_digest(&prepared).unwrap(), + }; + rename_file_handle(&prepared, &dir_file, final_path.file_name().unwrap()).unwrap(); + drop(prepared); + let marker_file = open_deletable_test_file(&marker_path, b"marker"); + let old_file = open_deletable_test_file(&old_path, b"verified-old"); + + recover_verified_transaction_with_evidence( + &dir_file, + dir.path(), + final_path.file_name().unwrap(), + &marker, + marker_file, + Some(old_file), + None, + ) + .unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), bytes); + assert!(!old_path.exists()); + assert!(!marker_path.exists()); + } + + #[test] + fn recovery_failure_never_discards_the_only_tombstone() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let marker_path = dir.path().join("marker"); + let old_path = dir.path().join("old"); + let marker = open_deletable_test_file(&marker_path, b"marker"); + let old = open_deletable_test_file(&old_path, b"old"); + + recover_verified_transaction( + &dir_file, + dir.path(), + OsStr::new(r"missing\policy.json"), + marker, + Some(old), + None, + ) + .unwrap_err(); + + assert_eq!(std::fs::read(&old_path).unwrap(), b"old"); + assert!(marker_path.exists()); + assert!(old_path.exists()); + } + + #[test] + fn recovery_rejects_multiple_or_untrusted_remnants() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let id_a = uuid::Uuid::new_v4(); + let id_b = uuid::Uuid::new_v4(); + std::fs::write(dir.path().join(format!(".policy.json.txn-{id_a}.marker")), b"untrusted").unwrap(); + std::fs::write(dir.path().join(format!(".policy.json.txn-{id_b}.marker")), b"untrusted").unwrap(); + assert!( + recover_interrupted_transaction(&dir_file, dir.path(), OsStr::new("policy.json")) + .unwrap_err() + .to_string() + .contains("multiple") + ); + + std::fs::remove_file(dir.path().join(format!(".policy.json.txn-{id_b}.marker"))).unwrap(); + assert!(recover_interrupted_transaction(&dir_file, dir.path(), OsStr::new("policy.json")).is_err()); + } + + #[test] + fn create_recovery_rejects_malformed_or_untrusted_remnants() { + let dir = temp_dir(); + let malformed = dir.path().join(format!(".{POLICY_FILE_NAME}.tmp-not-a-uuid")); + std::fs::write(&malformed, b"partial").unwrap(); + assert!(recover_create_temporary_files(dir.path()).is_err()); + + std::fs::remove_file(&malformed).unwrap(); + let untrusted = dir + .path() + .join(format!(".{POLICY_FILE_NAME}.tmp-{}", uuid::Uuid::new_v4())); + std::fs::write(&untrusted, b"partial").unwrap(); + assert!(recover_create_temporary_files(dir.path()).is_err()); + } + + #[test] + fn transaction_marker_round_trips_exact_state() { + let expected = DiskFingerprint::test_active(b"old", 2, 3, 4, 5); + let dir = temp_dir(); + let new_file = open_deletable_test_file(&dir.path().join("new"), b"new"); + let marker = TransactionMarker::from_observation( + uuid::Uuid::new_v4(), + Path::new(r"C:\policy.json"), + &expected, + &new_file, + b"new", + ) + .unwrap(); + + let decoded = TransactionMarker::from_bytes(&marker.to_bytes()).unwrap(); + + assert_eq!(decoded.id, marker.id); + assert_eq!(decoded.final_leaf, marker.final_leaf); + assert_eq!(decoded.old_identity, marker.old_identity); + assert_eq!(decoded.old_content_digest, marker.old_content_digest); + assert_eq!(decoded.old_security_digest, marker.old_security_digest); + assert_eq!(decoded.new_identity, marker.new_identity); + assert_eq!(decoded.new_content_digest, marker.new_content_digest); + assert_eq!(decoded.new_security_digest, marker.new_security_digest); + + let mut older_version: serde_json::Value = serde_json::from_slice(&marker.to_bytes()).unwrap(); + older_version["Version"] = 1.into(); + assert!(TransactionMarker::from_bytes(&serde_json::to_vec(&older_version).unwrap()).is_err()); + + older_version["Version"] = 2.into(); + assert!(TransactionMarker::from_bytes(&serde_json::to_vec(&older_version).unwrap()).is_err()); + + let mut missing_identity = older_version; + missing_identity["Version"] = 3.into(); + missing_identity.as_object_mut().unwrap().remove("NewFileId"); + assert!(TransactionMarker::from_bytes(&serde_json::to_vec(&missing_identity).unwrap()).is_err()); + } + + // ─── probe_write_capability / volume_filesystem_name ────────────────────── + // + // No elevation required: these never touch `admin_only_security_attributes`. + + #[test] + fn volume_filesystem_name_reports_a_known_filesystem_for_a_temp_directory() { + let dir = temp_dir(); + let filesystem = volume_filesystem_name(dir.path()).expect("query temp directory filesystem"); + assert!(!filesystem.is_empty()); + } + + #[test] + fn probe_write_capability_succeeds_on_an_ordinary_writable_temp_directory() { + let dir = temp_dir(); + probe_write_capability(dir.path()) + .expect("an ordinary user-writable NTFS temp directory must probe as capable"); + + // Nondestructive: the probe must never leave stray files behind. + let leftover: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|entry| entry.ok()) + .collect(); + assert!(leftover.is_empty(), "probe left files behind: {leftover:?}"); + } + + #[test] + fn occupied_no_replace_probe_preserves_both_retained_files() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let source_path = dir.path().join("source.tmp"); + let target_path = dir.path().join("target.tmp"); + let source = create_probe_file(&source_path, b"source", false).unwrap(); + let target = create_probe_file(&target_path, b"target", true).unwrap(); + let source_identity = policy_security::file_identity(&source).unwrap(); + let target_identity = policy_security::file_identity(&target).unwrap(); + + verify_no_replace_collision(&source, &target, &dir_file, &source_path, &target_path).unwrap(); + + assert_eq!(policy_security::file_identity(&source).unwrap(), source_identity); + assert_eq!(policy_security::file_identity(&target).unwrap(), target_identity); + assert_eq!(std::fs::read(&source_path).unwrap(), b"source"); + assert_eq!(std::fs::read(&target_path).unwrap(), b"target"); + cleanup_probe_file(source, &source_path, "source").unwrap(); + cleanup_probe_file(target, &target_path, "target").unwrap(); + } + + #[test] + fn occupied_no_replace_probe_accepts_verbatim_path_representation() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let source_path = dir.path().join("source.tmp"); + let target_path = dir.path().join("target.tmp"); + let source = create_probe_file(&source_path, b"source", false).unwrap(); + let target = create_probe_file(&target_path, b"target", true).unwrap(); + let verbatim = |path: &Path| { + let mut wide: Vec = r"\\?\".encode_utf16().collect(); + wide.extend(path.as_os_str().encode_wide()); + PathBuf::from(OsString::from_wide(&wide)) + }; + + verify_no_replace_collision( + &source, + &target, + &dir_file, + &verbatim(&source_path), + &verbatim(&target_path), + ) + .unwrap(); + + assert_eq!(std::fs::read(&source_path).unwrap(), b"source"); + assert_eq!(std::fs::read(&target_path).unwrap(), b"target"); + cleanup_probe_file(source, &source_path, "source").unwrap(); + cleanup_probe_file(target, &target_path, "target").unwrap(); + } + + #[test] + fn probe_directory_entry_rejects_a_different_identity() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let source_path = dir.path().join("source.tmp"); + let other_path = dir.path().join("other.tmp"); + let source = create_probe_file(&source_path, b"source", false).unwrap(); + let other = create_probe_file(&other_path, b"other", false).unwrap(); + let other_identity = policy_security::file_identity(&other).unwrap(); + + let error = verify_probe_directory_entry(&dir_file, source_path.file_name().unwrap(), other_identity) + .expect_err("a directory entry retargeted to a different file must be rejected"); + assert!(format!("{error:#}").contains("no longer names the retained file")); + + cleanup_probe_file(source, &source_path, "source").unwrap(); + cleanup_probe_file(other, &other_path, "other").unwrap(); + } + + fn windows_io_error(code: WIN32_ERROR) -> std::io::Error { + windows::core::Error::from_hresult(code.to_hresult()).into() + } + + #[test] + fn preferred_collision_is_accepted_without_native_fallback() { + for code in [ERROR_FILE_EXISTS, ERROR_ALREADY_EXISTS] { + let fallback_called = std::cell::Cell::new(false); + let error = rename_with_fallback( + || Err(windows_io_error(code)), + || { + fallback_called.set(true); + Ok(()) + }, + ) + .unwrap_err(); + + assert!(error.is_collision()); + assert!(!fallback_called.get()); + } + } + + #[test] + fn rename_status_classification_rejects_permission_and_unsupported_failures() { + for code in [ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION] { + let error = rename_with_fallback( + || Err(windows_io_error(code)), + || panic!("permission failures must not invoke the native fallback"), + ) + .unwrap_err(); + assert!(error.is_permission_failure()); + assert!(!error.is_collision()); + } + + for code in [ERROR_INVALID_FUNCTION, ERROR_NOT_SUPPORTED] { + let fallback_called = std::cell::Cell::new(false); + let unsupported = rename_with_fallback( + || Err(windows_io_error(code)), + || { + fallback_called.set(true); + Err(NTSTATUS(RenameFailure::STATUS_NOT_SUPPORTED.cast_signed())) + }, + ) + .unwrap_err(); + assert!(unsupported.is_unsupported()); + assert!(!unsupported.is_collision()); + assert!(fallback_called.get()); + } + + let native_collision = rename_with_fallback( + || Err(windows_io_error(ERROR_INVALID_FUNCTION)), + || Err(NTSTATUS(RenameFailure::STATUS_OBJECT_NAME_COLLISION.cast_signed())), + ) + .unwrap_err(); + assert!(native_collision.is_collision()); + } + + #[test] + fn failed_atomicity_probe_is_cached_until_directory_state_changes() { + let dir = temp_dir(); + let missing = dir.path().join("missing"); + let cache = AtomicityProbeCache::new(); + + let first = cache.get_or_probe(&missing, test_identity(1), test_security_digest(1)); + assert!(first.is_err()); + std::fs::create_dir(&missing).unwrap(); + + let cached = cache.get_or_probe(&missing, test_identity(1), test_security_digest(1)); + assert!(cached.is_err(), "unchanged directory state must reuse the failed probe"); + cache + .get_or_probe(&missing, test_identity(2), test_security_digest(1)) + .expect("changed directory identity must trigger a fresh probe"); + } + + #[test] + fn cleared_probe_collision_retries_after_bounded_delay() { + let dir = temp_dir(); + let collision = dir.path().join(".package-broker-write-probe-a.tmp"); + std::fs::write(&collision, b"external").unwrap(); + let cache = AtomicityProbeCache::new(); + let now = std::time::Instant::now(); + let identity = test_identity(1); + let security = test_security_digest(1); + + assert!(cache.get_or_probe_at(dir.path(), identity, security, now).is_err()); + std::fs::remove_file(collision).unwrap(); + assert!( + cache + .get_or_probe_at( + dir.path(), + identity, + security, + now + AtomicityProbeCache::FAILURE_RETRY_INTERVAL / 2, + ) + .is_err(), + "failure must remain cached before the retry deadline" + ); + cache + .get_or_probe_at( + dir.path(), + identity, + security, + now + AtomicityProbeCache::FAILURE_RETRY_INTERVAL, + ) + .expect("cleared collision must recover without restart"); + } + + #[test] + fn unsupported_filesystem_uses_unsupported_capability() { + assert_eq!( + probe_failure_capability(PolicyReadOnlyReason::UnsupportedFileSystem), + PolicyWriteCapability::Unsupported + ); + assert_eq!( + probe_failure_capability(PolicyReadOnlyReason::InsufficientPermissions), + PolicyWriteCapability::ReadOnly + ); + } + + // ─── DiskFingerprint rotation/stability semantics ───────────────────────── + + #[test] + fn active_fingerprint_is_stable_for_identical_inputs() { + let a = DiskFingerprint::test_active(b"same bytes", 1, 1, 1, 1); + let b = DiskFingerprint::test_active(b"same bytes", 1, 1, 1, 1); + assert_eq!(a, b); + } + + #[test] + fn active_fingerprint_rotates_on_same_byte_target_replacement() { + // Same content, but a different target generation (the file object itself was + // replaced, e.g. deleted and recreated with identical bytes). + let before = DiskFingerprint::test_active(b"same bytes", 1, 1, 1, 1); + let after = DiskFingerprint::test_active(b"same bytes", 2, 1, 1, 1); + assert_ne!(before, after); + } + + #[test] + fn active_fingerprint_rotates_on_acl_change() { + let before = DiskFingerprint::test_active(b"same bytes", 1, 1, 1, 1); + let after = DiskFingerprint::test_active(b"same bytes", 1, 1, 2, 1); + assert_ne!(before, after); + } + + #[test] + fn active_fingerprint_rotates_on_parent_replacement() { + let before = DiskFingerprint::test_active(b"same bytes", 1, 1, 1, 1); + let after = DiskFingerprint::test_active(b"same bytes", 1, 2, 1, 1); + assert_ne!(before, after); + } + + #[test] + fn active_fingerprint_rotates_on_same_acl_ancestor_replacement() { + let security = test_security_digest(1); + let before_ancestor = policy_security::test_ancestor_digest(test_identity(1), security); + let after_ancestor = policy_security::test_ancestor_digest(test_identity(2), security); + let fingerprint = |ancestor_security_digest| DiskFingerprint::Active { + parent: test_identity(10), + target: test_identity(11), + content_digest: sha256_digest(b"same bytes"), + security_digest: security, + dir_security_digest: security, + ancestor_security_digest, + }; + + assert_ne!(fingerprint(before_ancestor), fingerprint(after_ancestor)); + } + + #[test] + fn active_fingerprint_rotates_on_hosting_directory_acl_change() { + let before = DiskFingerprint::test_active(b"same bytes", 1, 1, 1, 1); + let after = DiskFingerprint::test_active(b"same bytes", 1, 1, 1, 2); + assert_ne!(before, after); + } + + #[test] + fn missing_fingerprints_differ_for_different_parents() { + let a = DiskFingerprint::test_missing(1, 1); + let b = DiskFingerprint::test_missing(2, 1); + assert_ne!(a, b); + } + + #[test] + fn missing_fingerprint_is_stable_for_the_same_parent() { + let a = DiskFingerprint::test_missing(7, 1); + let b = DiskFingerprint::test_missing(7, 1); + assert_eq!(a, b); + } + + // ─── Real, privilege-sensitive Windows behavior ─────────────────────────── + // + // The Agent service runs as LocalSystem in production, so setting a newly created + // object's owner to SYSTEM is unprivileged there; a non-elevated developer/CI shell + // cannot assign an owner it does not itself hold an enabling privilege for. Mirrors the + // existing `winget_app_exec_alias_passes_elevated_verification` pattern: attempt the + // real operation, and require the failure (when one occurs) to be exactly the + // anticipated privilege limitation rather than silently skipping the test. + #[test] + fn missing_component_is_created_secured_or_fails_on_the_expected_privilege_limitation() { + let dir = temp_dir(); + let parent = open_directory_no_reparse(dir.path()).unwrap(); + let security_attributes = policy_security::admin_only_security_attributes(true).unwrap(); + + match ensure_secure_directory_component( + &parent, + OsStr::new("PackageBroker"), + &security_attributes, + DirectorySecurityRole::DedicatedPolicy, + |_| Ok(()), + ) { + Ok(handle) => { + // Elevated/SYSTEM test host: verify the directory really is admin-only and + // that a *second* call (existing-directory path) does not need to (and does + // not) fail. + policy_security::verify_policy_directory_security(&handle) + .expect("freshly created directory must already be admin-only secured"); + drop(handle); + ensure_secure_directory_component( + &parent, + OsStr::new("PackageBroker"), + &security_attributes, + DirectorySecurityRole::DedicatedPolicy, + |_| Ok(()), + ) + .expect("re-verifying an already-secured directory succeeds"); + } + Err(error) => { + let message = format!("{error:#}"); + assert!( + message.contains("owner") || message.contains("privilege") || message.contains("Owner"), + "unexpected error creating the default directory: {message}" + ); + } + } + } + + // ─── validate_configured_path_shape (item 18/22) ────────────────────────── + + #[test] + fn relative_path_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"relative\policy.json")).unwrap_err(); + assert!(error.to_string().contains("absolute"), "{error}"); + } + + #[test] + fn trailing_separator_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\")).unwrap_err(); + assert!(error.to_string().contains("separator"), "{error}"); + } + + #[test] + fn dot_component_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\.\policy.json")).unwrap_err(); + assert!(error.to_string().contains("'.'"), "{error}"); + } + + #[test] + fn dotdot_component_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\..\policy.json")).unwrap_err(); + assert!(error.to_string().contains("'..'"), "{error}"); + } + + #[test] + fn yaml_extension_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\policy.yaml")).unwrap_err(); + assert!(error.to_string().contains(".json"), "{error}"); + } + + #[test] + fn yml_extension_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\policy.yml")).unwrap_err(); + assert!(error.to_string().contains(".json"), "{error}"); + } + + #[test] + fn extensionless_path_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\policy")).unwrap_err(); + assert!(error.to_string().contains(".json"), "{error}"); + } + + #[test] + fn other_extension_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\policy.txt")).unwrap_err(); + assert!(error.to_string().contains(".json"), "{error}"); + } + + #[test] + fn uppercase_json_extension_is_accepted() { + validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\policy.JSON")) + .expect("extension check is case-insensitive"); + } + + #[test] + fn well_formed_absolute_json_path_is_accepted() { + validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\PackageBroker\policy.json")) + .expect("well-formed absolute .json path must be accepted"); + } + + /// End-to-end (item 18/31): a configured path with an unsupported extension must be + /// reported through the *real* `observe` with the shared contract's dedicated + /// [`PolicyReadOnlyReason::UnsupportedFormat`], and the file must never even be + /// opened, whatever it (if anything) actually contains at that path. + fn assert_unsupported_format_is_reported_invalid_end_to_end(file_name: &str) { + let dir = temp_dir(); + let path = dir.path().join(file_name); + // If shape validation were ever skipped, this well-formed JSON content would + // make the file parse as Active; its presence proves the rejection is really + // about the extension, not a coincidentally-unreadable/absent file. + std::fs::write(&path, br#"{"not": "even close to a policy, but that's not the point"}"#).unwrap(); + + let probe_cache = AtomicityProbeCache::new(); + let observation = observe(PolicyConfigurationSource::ConfiguredPath, &path, &probe_cache); + + assert_eq!(observation.state, PolicyManagementState::Invalid); + assert_eq!(observation.write_capability, PolicyWriteCapability::Unsupported); + assert_eq!( + observation.read_only_reason, + Some(PolicyReadOnlyReason::UnsupportedFormat) + ); + assert!(observation.policy.is_none()); + } + + #[test] + fn yaml_extension_is_reported_invalid_end_to_end() { + assert_unsupported_format_is_reported_invalid_end_to_end("policy.yaml"); + } + + #[test] + fn yml_extension_is_reported_invalid_end_to_end() { + assert_unsupported_format_is_reported_invalid_end_to_end("policy.yml"); + } + + #[test] + fn extensionless_path_is_reported_invalid_end_to_end() { + assert_unsupported_format_is_reported_invalid_end_to_end("policy"); + } + + #[test] + fn other_extension_is_reported_invalid_end_to_end() { + assert_unsupported_format_is_reported_invalid_end_to_end("policy.txt"); + } + + // ─── Strict policy ancestor walk: reparse rejection (item 16) ───────────── + // + // Directory junctions (unlike symlinks) require no special privilege to create, so + // this exercises the real reparse-point rejection without needing an elevated shell. + + #[test] + fn junction_standing_in_for_an_ancestor_is_rejected() { + let root = temp_dir(); + let real_ancestor = root.path().join("real-ancestor"); + std::fs::create_dir(&real_ancestor).unwrap(); + let junction = root.path().join("junction-ancestor"); + create_directory_junction(&junction, &real_ancestor); + + let candidate_dir = junction.join("policy-dir"); + std::fs::create_dir(&candidate_dir).unwrap(); + + let error = + policy_security::retain_policy_no_reparse_directory_chain(&candidate_dir, "policy directory").unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("reparse point"), "unexpected error: {message}"); + } + + #[test] + fn default_directory_creation_rejects_junction_before_side_effects() { + let root = temp_dir(); + let attacker_target = root.path().join("attacker-target"); + std::fs::create_dir(&attacker_target).unwrap(); + let junction = root.path().join("Devolutions"); + create_directory_junction(&junction, &attacker_target); + let security_attributes = policy_security::admin_only_security_attributes(true).unwrap(); + let parent = open_directory_no_reparse(root.path()).unwrap(); + + let error = ensure_secure_directory_component( + &parent, + OsStr::new("Devolutions"), + &security_attributes, + DirectorySecurityRole::SharedAncestor, + |_| Ok(()), + ) + .unwrap_err(); + + assert!(format!("{error:#}").contains("reparse point")); + assert!( + !attacker_target.join("PackageBroker").exists(), + "rejected junction must not receive a privileged directory" + ); + } + + #[test] + fn hostile_component_creation_races_are_reopened_and_rejected() { + let root = temp_dir(); + let security_attributes = policy_security::admin_only_security_attributes(true).unwrap(); + let attacker_target = root.path().join("attacker-target"); + std::fs::create_dir(&attacker_target).unwrap(); + let parent = open_directory_no_reparse(root.path()).unwrap(); + + let error = ensure_secure_directory_component( + &parent, + OsStr::new("raced-junction"), + &security_attributes, + DirectorySecurityRole::SharedAncestor, + |path| { + create_directory_junction(path, &attacker_target); + Ok(()) + }, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("reparse point")); + + let error = ensure_secure_directory_component( + &parent, + OsStr::new("raced-insecure"), + &security_attributes, + DirectorySecurityRole::DedicatedPolicy, + |path| { + std::fs::create_dir(path)?; + Ok(()) + }, + ) + .unwrap_err(); + assert!( + format!("{error:#}").contains("required directory security"), + "unexpected error: {error:#}" + ); + } + + #[test] + fn preacquired_delete_handle_blocks_default_tree_creation_without_side_effects() { + let root = temp_dir(); + let attacker_handle = OpenOptions::new() + .access_mode(DELETE.0 | FILE_READ_ATTRIBUTES.0) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) + .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(root.path()) + .unwrap(); + let candidate = root.path().join("Devolutions").join("PackageBroker"); + + ensure_default_directory_secured(&candidate).unwrap_err(); + + assert!(!root.path().join("Devolutions").exists()); + drop(attacker_handle); + } + + /// Create a directory junction (`mklink /J`) without requiring elevation. + fn create_directory_junction(link: &Path, target: &Path) { + let status = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(link) + .arg(target) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .expect("spawn mklink"); + assert!( + status.success(), + "failed to create junction {} -> {}", + link.display(), + target.display() + ); + } + + // ─── Hard-link alias rejection for the leaf file (item 22) ──────────────── + // + // Hard links (unlike symlinks) require no special privilege to create on the same + // volume, so this exercises the real alias-rejection path directly. + + #[test] + fn policy_leaf_with_multiple_hard_links_is_rejected() { + let dir = temp_dir(); + let real_file = dir.path().join("real-policy.json"); + std::fs::write(&real_file, b"{}").unwrap(); + let alias = dir.path().join("alias-policy.json"); + std::fs::hard_link(&real_file, &alias).expect("create hard link"); + + let handle = OpenOptions::new() + .read(true) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(&alias) + .unwrap(); + assert_eq!(policy_security::file_link_count(&handle).unwrap(), 2); + } + + #[test] + fn dangling_reparse_leaf_is_unsafe_not_missing() { + let dir = temp_dir(); + let link = dir.path().join("policy.json"); + if std::os::windows::fs::symlink_file(dir.path().join("missing.json"), &link).is_err() { + return; + } + + let error = verify_policy_leaf_type_if_present(&link).unwrap_err(); + + assert!(format!("{error:#}").contains("reparse point")); + } + + #[test] + fn unverifiable_directory_classifies_dangling_reparse_as_invalid() { + let dir = temp_dir(); + let link = dir.path().join("policy.json"); + create_directory_junction(&link, &dir.path().join("missing-target")); + + let observation = observe_leaf_under_unverifiable_directory( + &link, + PolicyWriteCapability::ReadOnly, + PolicyReadOnlyReason::UnsafePath, + ); + + assert_eq!(observation.state, PolicyManagementState::Invalid); + assert_eq!(observation.write_capability, PolicyWriteCapability::ReadOnly); + assert_eq!(observation.read_only_reason, Some(PolicyReadOnlyReason::UnsafePath)); + } + + #[test] + fn unverifiable_directory_classifies_existing_file_as_invalid() { + let dir = temp_dir(); + let path = dir.path().join("policy.json"); + std::fs::write(&path, b"untrusted").unwrap(); + + let observation = observe_leaf_under_unverifiable_directory( + &path, + PolicyWriteCapability::ReadOnly, + PolicyReadOnlyReason::UnsafePath, + ); + + assert_eq!(observation.state, PolicyManagementState::Invalid); + assert_eq!(observation.write_capability, PolicyWriteCapability::ReadOnly); + assert_eq!(observation.read_only_reason, Some(PolicyReadOnlyReason::UnsafePath)); + } + + #[test] + fn unverifiable_directory_classifies_true_absence_as_missing_but_read_only() { + let dir = temp_dir(); + let path = dir.path().join("missing-policy.json"); + + let observation = observe_leaf_under_unverifiable_directory( + &path, + PolicyWriteCapability::ReadOnly, + PolicyReadOnlyReason::UnsafePath, + ); + + assert_eq!(observation.state, PolicyManagementState::Missing); + assert_eq!(observation.write_capability, PolicyWriteCapability::ReadOnly); + assert_eq!(observation.read_only_reason, Some(PolicyReadOnlyReason::UnsafePath)); + assert!(observation.hosting_dir.is_none()); + } + + #[test] + fn directory_leaf_is_unsafe_not_missing() { + let dir = temp_dir(); + let leaf = dir.path().join("policy.json"); + std::fs::create_dir(&leaf).unwrap(); + + let error = verify_policy_leaf_type_if_present(&leaf).unwrap_err(); + + assert!(format!("{error:#}").contains("directory")); + } + + #[test] + fn resolved_parent_alias_and_leaf_casing_are_compared_independently() { + let configured = Path::new(r"C:\RUNNER~1\AppData\Local\Temp\policy.json"); + let resolved_parent = Path::new(r"C:\actions\runneradmin\AppData\Local\Temp"); + let resolved_file = resolved_parent.join("Policy.JSON"); + + assert!(resolved_policy_path_matches( + &resolved_file, + resolved_parent, + configured.file_name().unwrap() + )); + assert!(!resolved_policy_path_matches( + &resolved_parent.join("other.json"), + resolved_parent, + configured.file_name().unwrap() + )); + } + + #[test] + fn resolved_policy_path_accepts_windows_unicode_case_mapping() { + let configured = Path::new(r"C:\DÉVOLUTIONS\PackageBroker\policé.json"); + let resolved_parent = Path::new(r"c:\dévolutions\packagebroker"); + let resolved_file = resolved_parent.join("POLICÉ.JSON"); + + assert!(resolved_policy_path_matches( + &resolved_file, + resolved_parent, + configured.file_name().unwrap() + )); + assert!(!resolved_policy_path_matches( + &resolved_file, + Path::new(r"c:\dévolutions\other"), + configured.file_name().unwrap() + )); + assert!(!resolved_policy_path_matches( + &resolved_parent.join("different.json"), + resolved_parent, + configured.file_name().unwrap() + )); + } + + // ─── DiskFingerprint::Invalid enrichment (item 15) ──────────────────────── + + fn invalid_fingerprint_for_path(path: &str, reason: validation::DiskFailureReason) -> DiskFingerprint { + DiskFingerprint::Invalid { + path: PathBuf::from(path), + parent: None, + dir_security_digest: None, + ancestor_security_digest: None, + target: None, + content_digest: None, + security_digest: None, + reason: format!("{reason:?}"), + } + } + + #[test] + fn invalid_fingerprints_for_distinct_paths_never_collide() { + // Two different configured paths that both fail identically (e.g. neither + // parent could even be opened, so no identity is available to distinguish them) + // must still never be mistaken for each other. + let a = invalid_fingerprint_for_path(r"C:\a\policy.json", validation::DiskFailureReason::Unreadable); + let b = invalid_fingerprint_for_path(r"C:\b\policy.json", validation::DiskFailureReason::Unreadable); + assert_ne!(a, b); + } + + #[test] + fn invalid_fingerprint_is_stable_for_the_same_path_and_reason() { + let a = invalid_fingerprint_for_path(r"C:\a\policy.json", validation::DiskFailureReason::Unreadable); + let b = invalid_fingerprint_for_path(r"C:\a\policy.json", validation::DiskFailureReason::Unreadable); + assert_eq!(a, b); + } + + /// Build a fully-populated `DiskFingerprint::Invalid` for the rotation/stability + /// tests below, so each test only has to vary the one field it is proving rotates + /// (or, for the "unchanged" test, none at all). + fn full_invalid_fingerprint( + path: &str, + parent_generation: u32, + ancestor_marker: &[u8], + target_generation: u32, + content: &[u8], + security_marker: &[u8], + reason: validation::DiskFailureReason, + ) -> DiskFingerprint { + DiskFingerprint::Invalid { + path: PathBuf::from(path), + parent: Some(test_identity(parent_generation)), + dir_security_digest: Some(sha256_digest(b"directory-security")), + ancestor_security_digest: Some(sha256_digest(ancestor_marker)), + target: Some(test_identity(target_generation)), + content_digest: Some(sha256_digest(content)), + security_digest: Some(sha256_digest(security_marker)), + reason: format!("{reason:?}"), + } + } + + #[test] + fn invalid_fingerprint_rotates_on_parent_replacement() { + let before = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + let after = full_invalid_fingerprint( + r"C:\a\policy.json", + 2, // only the parent generation differs + b"ancestors", + 1, + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + assert_ne!(before, after); + } + + #[test] + fn invalid_fingerprint_rotates_on_same_content_target_replacement() { + // Same path and same byte-for-byte content digest, but a different target + // identity (the invalid file object itself was replaced, e.g. deleted and + // recreated with identical bytes): must still rotate. + let before = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + let after = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 2, // only the target generation differs + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + assert_ne!(before, after); + } + + #[test] + fn invalid_fingerprint_rotates_on_acl_change() { + let before = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security-a", + validation::DiskFailureReason::MalformedContent, + ); + let after = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security-b", // only the security digest marker differs + validation::DiskFailureReason::MalformedContent, + ); + assert_ne!(before, after); + } + + #[test] + fn invalid_fingerprint_is_stable_when_truly_unchanged() { + let a = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + let b = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + assert_eq!(a, b); + } + + #[test] + fn probe_file_creation_never_truncates_an_existing_path() { + let dir = temp_dir(); + let path = dir.path().join("probe.tmp"); + std::fs::write(&path, b"external").unwrap(); + + let error = create_probe_file(&path, b"probe", false).unwrap_err(); + assert!(!format!("{error:#}").is_empty()); + assert_eq!(std::fs::read(&path).unwrap(), b"external"); + } +} diff --git a/crates/now-package-broker/src/policy_watcher.rs b/crates/now-package-broker/src/policy_watcher.rs index 04d83a128..812969da4 100644 --- a/crates/now-package-broker/src/policy_watcher.rs +++ b/crates/now-package-broker/src/policy_watcher.rs @@ -59,8 +59,8 @@ 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. + /// Watches the canonical policy parent directory non-recursively. + /// Relevant policy modifications, creations, and removals reload the policy. /// The task runs until the shutdown notify is triggered. pub(crate) async fn watch( self, @@ -68,7 +68,7 @@ impl PolicyWatcher { ready: tokio::sync::oneshot::Sender>, ) { let store = self.0; - let path = store.configured_path(); + let path = store.watched_path(); let dir = path.parent().unwrap_or_else(|| Path::new(".")).to_owned(); let (change_tx, mut changes) = tokio::sync::mpsc::channel(1); @@ -89,13 +89,11 @@ impl PolicyWatcher { return; } }; - if let Err(error) = watcher.watch(&dir, RecursiveMode::NonRecursive) { error!(%error, path = %dir.display(), "Failed to watch policy directory"); let _ = ready.send(Err(WatcherFailure::Registration)); return; } - let _ = ready.send(Ok(())); let _ = watcher_stop_rx.recv(); }); @@ -183,6 +181,16 @@ mod tests { )); } + #[test] + fn canonical_watcher_ignores_other_policy_paths() { + let canonical = Path::new(r"C:\ProgramData\Devolutions\PackageBroker\package-broker-policy.json"); + let other = Path::new(r"C:\ProgramData\Devolutions\Agent\package-broker-policy.json"); + let event = |path| notify::Event::new(EventKind::Modify(ModifyKind::Any)).add_path(path); + + assert!(affects_policy(&event(canonical.to_owned()), canonical)); + assert!(!affects_policy(&event(other.to_owned()), canonical)); + } + #[tokio::test] async fn watcher_task_exit_fails_closed_but_shutdown_does_not() { let store = PolicyStore::for_tests(None); diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index c873a3408..61664d16c 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -1,13 +1,15 @@ //! Runtime implementation of the shared NOW package broker server facade. use std::collections::HashMap; +use std::fmt; use std::sync::Arc; use std::time::{Duration, Instant}; use async_trait::async_trait; use axum::Json; +use axum::body::{Body, to_bytes}; use axum::extract::{Extension, Request, State}; -use axum::http::{Method, StatusCode}; +use axum::http::{HeaderMap, Method, StatusCode}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; use chrono::{DateTime, Utc}; @@ -20,7 +22,9 @@ use now_policy_api::{ PolicyReplacementResponse, PolicyResponse, PolicyResponseKind, PolicyValidationRequest, PolicyValidationResponse, Scope, StatusRequest, StatusResponse, StatusResponseKind, Transport, }; -use now_policy_server_template::{MAX_REQUEST_BODY_BYTES, PackageBrokerServer, SharedPackageBrokerServer}; +use now_policy_server_template::{ + MAX_POLICY_MANAGEMENT_BODY_BYTES, MAX_REQUEST_BODY_BYTES, PackageBrokerServer, SharedPackageBrokerServer, +}; use tracing::{info, trace, warn}; use win_api_wrappers::identity::sid::Sid; @@ -112,10 +116,177 @@ pub(crate) fn build_router_for_client(state: Arc, client: PipeClien client: client.clone(), }); axum::Router::from(now_policy_server_template::api_router_from_shared(server)) + .layer(middleware::from_fn(reject_duplicate_policy_json_members)) .layer(middleware::from_fn_with_state(state, authenticate_policy_management)) .layer(Extension(client)) } +async fn reject_duplicate_policy_json_members(request: Request, next: Next) -> Response { + let is_policy_write = matches!( + (request.method(), request.uri().path()), + (&Method::POST, "/v1/policy/validate") | (&Method::PUT, "/v1/policy") + ); + if !is_policy_write || !is_json_content_type(request.headers()) { + return next.run(request).await; + } + + let (parts, body) = request.into_parts(); + let bytes = match to_bytes(body, MAX_POLICY_MANAGEMENT_BODY_BYTES).await { + Ok(bytes) => bytes, + Err(error) if body_size_limit_exceeded(&error) => { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(error_response( + ErrorCode::PayloadTooLarge, + "request body exceeds the broker limit", + )), + ) + .into_response(); + } + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(error_response( + ErrorCode::MalformedDraft, + "request body is not a valid broker document", + )), + ) + .into_response(); + } + }; + if reject_duplicate_json_members(&bytes).is_err() { + return ( + StatusCode::BAD_REQUEST, + Json(error_response( + ErrorCode::MalformedDraft, + "policy request contains duplicate JSON members", + )), + ) + .into_response(); + } + + fn body_size_limit_exceeded(error: &axum::Error) -> bool { + std::error::Error::source(error).is_some_and(|source| source.is::()) + } + + next.run(Request::from_parts(parts, Body::from(bytes))).await +} + +fn is_json_content_type(headers: &HeaderMap) -> bool { + headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .is_some_and(|value| { + value.type_() == mime::APPLICATION && (value.subtype() == mime::JSON || value.suffix() == Some(mime::JSON)) + }) +} + +fn reject_duplicate_json_members(bytes: &[u8]) -> Result<(), serde_json::Error> { + struct UniqueJson; + + impl<'de> serde::de::DeserializeSeed<'de> for UniqueJson { + type Value = (); + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(self) + } + } + + impl<'de> serde::de::Visitor<'de> for UniqueJson { + type Value = (); + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON value without duplicate object members") + } + + fn visit_bool(self, _: bool) -> Result<(), E> + where + E: serde::de::Error, + { + Ok(()) + } + + fn visit_i64(self, _: i64) -> Result<(), E> + where + E: serde::de::Error, + { + Ok(()) + } + + fn visit_u64(self, _: u64) -> Result<(), E> + where + E: serde::de::Error, + { + Ok(()) + } + + fn visit_f64(self, _: f64) -> Result<(), E> + where + E: serde::de::Error, + { + Ok(()) + } + + fn visit_str(self, _: &str) -> Result<(), E> + where + E: serde::de::Error, + { + Ok(()) + } + + fn visit_none(self) -> Result<(), E> + where + E: serde::de::Error, + { + Ok(()) + } + + fn visit_unit(self) -> Result<(), E> + where + E: serde::de::Error, + { + Ok(()) + } + + fn visit_some(self, deserializer: D) -> Result<(), D::Error> + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(self) + } + + fn visit_seq(self, mut sequence: A) -> Result<(), A::Error> + where + A: serde::de::SeqAccess<'de>, + { + while sequence.next_element_seed(UniqueJson)?.is_some() {} + Ok(()) + } + + fn visit_map(self, mut map: A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + let mut keys = std::collections::HashSet::new(); + while let Some(key) = map.next_key::()? { + if !keys.insert(key.clone()) { + return Err(serde::de::Error::custom(format!("duplicate JSON member '{key}'"))); + } + map.next_value_seed(UniqueJson)?; + } + Ok(()) + } + } + + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + serde::de::DeserializeSeed::deserialize(UniqueJson, &mut deserializer)?; + deserializer.end() +} + async fn authenticate_policy_management( State(state): State>, Extension(client): Extension, @@ -385,7 +556,7 @@ impl BrokerState { post_command: request.options.post_operation_command.clone(), effective_user: request.client.effective_user.clone(), user_sid: user_sid.clone(), - elevation: request.client.requested_elevation, + elevation: evaluator::effective_execution_elevation(&request), scope: request.options.scope, capture_output: request.capture_output, cancel_token: tokio_util::sync::CancellationToken::new(), @@ -659,9 +830,7 @@ mod tests { use axum::http::{Method, Request, StatusCode}; use axum::response::Response; use chrono::Utc; - use now_policy::{ - PackageBrokerPolicy, PolicyEnforcement, PolicyFormatVersion, PolicyMetadata, ResourceId, RulePrecedence, - }; + use now_policy::{PolicyEnforcement, PolicyFormatVersion, PolicyMetadata, ResourceId}; use now_policy_api as api; use tower_service::Service as _; @@ -706,7 +875,6 @@ mod tests { fn permissive_policy() -> PolicyDocument { PolicyDocument { policy_format_version: PolicyFormatVersion::current(), - policy_type: PackageBrokerPolicy, metadata: PolicyMetadata { id: ResourceId::from("test-policy"), publisher: "Test".to_owned(), @@ -719,7 +887,6 @@ mod tests { }, enforcement: PolicyEnforcement { default_decision: now_policy::Decision::Allow, - rule_precedence: RulePrecedence::PriorityThenDeny, audit_mode: Some(true), }, rules: Vec::new(), @@ -774,6 +941,118 @@ mod tests { serde_json::from_slice(&body).expect("response is valid JSON") } + #[test] + fn duplicate_json_members_are_rejected_before_value_deserialization() { + for body in [ + br#"{"Interactive":false,"Interactive":null}"#.as_slice(), + br#"{"Match":{"Managers":["Winget"],"Managers":["Choco"]}}"#.as_slice(), + br#"{"Rules":[{"Match":{"SourceNames":["winget"],"SourceNames":["store"]}}]}"#.as_slice(), + ] { + assert!(reject_duplicate_json_members(body).is_err()); + } + assert!(reject_duplicate_json_members(br#"{"Interactive":null,"Match":{"Managers":["Winget"]}}"#).is_ok()); + } + + #[test] + fn policy_json_content_types_match_the_json_extractor() { + for content_type in [ + "application/json", + "Application/JSON; charset=utf-8", + "application/vnd.now-policy+json", + ] { + let mut headers = HeaderMap::new(); + headers.insert(axum::http::header::CONTENT_TYPE, content_type.parse().unwrap()); + assert!(is_json_content_type(&headers), "{content_type}"); + } + let mut headers = HeaderMap::new(); + headers.insert(axum::http::header::CONTENT_TYPE, "application/jsonp".parse().unwrap()); + assert!(!is_json_content_type(&headers)); + } + + #[cfg(feature = "dev-skip-broker-signature")] + #[tokio::test] + async fn policy_write_routes_reject_duplicate_members_before_draft_conversion() { + let client = PipeClient::test_with_authority(true, true).expect("create elevated test client"); + let draft = serde_json::json!({ + "PolicyFormatVersion": "1.0.0", + "Metadata": { "Id": "created", "Publisher": "Test" }, + "Enforcement": { "DefaultDecision": "Deny" }, + "Rules": [{ "Id": "rule", "Priority": 0, "Decision": "Deny", "Match": { "Managers": ["Winget"], "Interactive": null }}] + }); + let validation_state = shared_state(None); + let valid_validation = serde_json::json!({ + "RequestKind": "PolicyValidationRequest", + "RequestVersion": "1.0", + "Draft": draft, + }); + let response = route_json( + Arc::clone(&validation_state), + client.clone(), + Method::POST, + "/v1/policy/validate", + valid_validation, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let replacement_state = shared_state(None); + let replacement_draft = serde_json::json!({ + "PolicyFormatVersion": "1.0.0", + "Metadata": { "Id": "replacement", "Publisher": "Test" }, + "Enforcement": { "DefaultDecision": "Deny" }, + "Rules": [{ "Id": "rule", "Priority": 0, "Decision": "Deny", "Match": { "Managers": ["Winget"], "Interactive": null }}] + }); + let validation = replacement_state.policy_store.validate_draft(&replacement_draft); + let valid_replacement = serde_json::json!({ + "RequestKind": "PolicyReplacementRequest", + "RequestVersion": "1.0", + "ExpectedStoreToken": replacement_state.policy_store.management_snapshot().store_token, + "Operation": "Create", + "ConflictHandling": "Reject", + "WarningsAcknowledged": false, + "Draft": replacement_draft, + "ValidationReceipt": validation.validation_receipt.expect("valid receipt"), + }); + let response = route_json( + Arc::clone(&replacement_state), + client.clone(), + Method::PUT, + "/v1/policy", + valid_replacement, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + for (state, method, path, content_type, body) in [ + ( + validation_state, + Method::POST, + "/v1/policy/validate", + "application/vnd.now-policy+json", + r#"{"RequestKind":"PolicyValidationRequest","RequestVersion":"1.0","Draft":{"PolicyFormatVersion":"1.0.0","Metadata":{"Id":"created","Publisher":"Test","Publisher":"Test"},"Enforcement":{"DefaultDecision":"Deny"},"Rules":[]}}"#, + ), + ( + replacement_state, + Method::PUT, + "/v1/policy", + "Application/JSON; charset=utf-8", + r#"{"RequestKind":"PolicyReplacementRequest","RequestVersion":"1.0","ExpectedStoreToken":"invalid","Operation":"Create","ConflictHandling":"Reject","WarningsAcknowledged":false,"ValidationReceipt":"invalid","Draft":{"PolicyFormatVersion":"1.0.0","Metadata":{"Id":"created","Publisher":"Test","Publisher":"Test"},"Enforcement":{"DefaultDecision":"Deny"},"Rules":[]}}"#, + ), + ] { + let response = route_raw( + state, + client.clone(), + method, + path, + Some(content_type), + Body::from(body), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response_json(response).await["Code"], "MalformedDraft"); + } + } + #[cfg(feature = "dev-skip-broker-signature")] async fn route_json( state: Arc, @@ -832,10 +1111,7 @@ mod tests { ( Method::POST, "/v1/policy/validate", - Body::from(vec![ - b'x'; - now_policy_server_template::MAX_POLICY_MANAGEMENT_BODY_BYTES + 1 - ]), + Body::from(vec![b'x'; MAX_POLICY_MANAGEMENT_BODY_BYTES + 1]), ), (Method::PUT, "/v1/policy", Body::from("{")), ] { @@ -909,9 +1185,8 @@ mod tests { let draft = serde_json::json!({ "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "created", "Publisher": "Test" }, - "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Enforcement": { "DefaultDecision": "Deny" }, "Rules": [] }); let validated = route_json(