diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs index f4e9723..8181b26 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs @@ -14,6 +14,8 @@ public static class BrokerSerializer /// Serialization options matching the broker wire format: PascalCase property names /// (via explicit [JsonPropertyName] attributes), PascalCase enum values, and /// null optionals omitted (mirroring the Rust skip_serializing_if = "Option::is_none"). + /// Deserialization rejects duplicate property names throughout the input using ordinal, + /// case-sensitive name comparison. /// public static readonly JsonSerializerOptions Options = CreateOptions(writeIndented: false); @@ -27,6 +29,7 @@ public static string Serialize(T value) public static T? Deserialize(string json) { + PolicyJsonInput.RejectDuplicatePropertyNames(json, BrokerSerializerContext.Default.Options); var value = JsonSerializer.Deserialize(json, TypeInfo()); ValidateSemanticValue(value); return value; @@ -34,6 +37,7 @@ public static string Serialize(T value) public static T? DeserializeStrict(string json) { + PolicyJsonInput.RejectDuplicatePropertyNames(json, BrokerStrictSerializerContext.Default.Options); var value = JsonSerializer.Deserialize(json, StrictTypeInfo()); ValidateSemanticValue(value); return value; @@ -49,14 +53,24 @@ private static void ValidateSemanticValue(object? value) case PolicyDraftDocument draft: PolicySerializer.ValidateRequiredCollectionElements(draft); break; + case PolicyMetadata + or PolicyDraftMetadata + or PolicyRule + or PolicyMatch + or PackageIdentifierCondition + or VersionCondition + or VersionRange + or PolicyConstraints: + PolicySerializer.ValidateSemanticValue(value); + break; case PolicyResponse response: - PolicySerializer.ValidateRequiredCollectionElements(response.Policy); + PolicySerializer.ValidateRequiredCollectionElements(response.Policy, "$.Policy"); break; case PolicyManagementResponse response: - ValidateManagement(response.Management); + ValidateManagement(response.Management, "$.Management"); break; case PolicyValidationResponse response: - ValidateValidation(response.Validation); + ValidateValidation(response.Validation, "$.Validation"); break; case PolicyReplacementResponse response: ValidateReplacement(response); @@ -73,24 +87,30 @@ private static void ValidateSemanticValue(object? value) } } - private static JsonTypeInfo TypeInfo() => - typeof(T) == typeof(PackageRequest) ? Cast(BrokerSerializerContext.Default.PackageRequest) : - typeof(T) == typeof(StatusRequest) ? Cast(BrokerSerializerContext.Default.StatusRequest) : - typeof(T) == typeof(CancelRequest) ? Cast(BrokerSerializerContext.Default.CancelRequest) : - typeof(T) == typeof(PolicyValidationRequest) ? Cast(BrokerPolicySerializerContext.Default.PolicyValidationRequest) : - typeof(T) == typeof(PolicyReplacementRequest) ? Cast(BrokerPolicySerializerContext.Default.PolicyReplacementRequest) : - typeof(T) == typeof(HealthResponse) ? Cast(BrokerSerializerContext.Default.HealthResponse) : - typeof(T) == typeof(CapabilitiesResponse) ? Cast(BrokerSerializerContext.Default.CapabilitiesResponse) : - typeof(T) == typeof(PolicyResponse) ? Cast(BrokerPolicySerializerContext.Default.PolicyResponse) : - typeof(T) == typeof(PolicyManagementResponse) ? Cast(BrokerPolicySerializerContext.Default.PolicyManagementResponse) : - typeof(T) == typeof(PolicyValidationResponse) ? Cast(BrokerPolicySerializerContext.Default.PolicyValidationResponse) : - typeof(T) == typeof(PolicyReplacementResponse) ? Cast(BrokerPolicySerializerContext.Default.PolicyReplacementResponse) : - typeof(T) == typeof(EvaluationResponse) ? Cast(BrokerSerializerContext.Default.EvaluationResponse) : - typeof(T) == typeof(ExecutionResponse) ? Cast(BrokerSerializerContext.Default.ExecutionResponse) : - typeof(T) == typeof(StatusResponse) ? Cast(BrokerSerializerContext.Default.StatusResponse) : - typeof(T) == typeof(CancelResponse) ? Cast(BrokerSerializerContext.Default.CancelResponse) : - typeof(T) == typeof(ErrorResponse) ? Cast(BrokerErrorSerializerContext.Default.ErrorResponse) : - throw new NotSupportedException($"Broker JSON serialization for {typeof(T).FullName} is not source-generated."); + private static JsonTypeInfo TypeInfo() + { + _ = typeof(T) == typeof(PackageRequest) + || typeof(T) == typeof(StatusRequest) + || typeof(T) == typeof(CancelRequest) + || typeof(T) == typeof(PolicyValidationRequest) + || typeof(T) == typeof(PolicyReplacementRequest) + || typeof(T) == typeof(HealthResponse) + || typeof(T) == typeof(CapabilitiesResponse) + || typeof(T) == typeof(PolicyResponse) + || typeof(T) == typeof(PolicyManagementResponse) + || typeof(T) == typeof(PolicyValidationResponse) + || typeof(T) == typeof(PolicyReplacementResponse) + || typeof(T) == typeof(EvaluationResponse) + || typeof(T) == typeof(ExecutionResponse) + || typeof(T) == typeof(StatusResponse) + || typeof(T) == typeof(CancelResponse) + || typeof(T) == typeof(ErrorResponse) + ? true + : throw new NotSupportedException( + $"Broker JSON serialization for {typeof(T).FullName} is not source-generated."); + + return Cast(Options.GetTypeInfo(typeof(T))); + } private static JsonTypeInfo StrictTypeInfo() => typeof(T) == typeof(PackageRequest) ? Cast(BrokerStrictSerializerContext.Default.PackageRequest) : @@ -114,7 +134,9 @@ private static JsonTypeInfo StrictTypeInfo() => private static JsonTypeInfo Cast(JsonTypeInfo jsonTypeInfo) => (JsonTypeInfo)jsonTypeInfo; - private static void ValidateManagement(PolicyManagementSnapshot management) + private static void ValidateManagement( + PolicyManagementSnapshot management, + string path = "$") { switch (management.State) { @@ -150,7 +172,7 @@ private static void ValidateManagement(PolicyManagementSnapshot management) if (management.Policy is { } policy) { - PolicySerializer.ValidateRequiredCollectionElements(policy); + PolicySerializer.ValidateRequiredCollectionElements(policy, $"{path}.Policy"); } } @@ -163,16 +185,18 @@ private static void ValidateError(ErrorResponse error) if (error.Management is { } management) { - ValidateManagement(management); + ValidateManagement(management, "$.Management"); } if (error.Validation is { } validation) { - ValidateValidation(validation); + ValidateValidation(validation, "$.Validation"); } } - private static void ValidateValidation(PolicyValidationResult validation) + private static void ValidateValidation( + PolicyValidationResult validation, + string path = "$") { RejectNullElements(validation.Findings, "Validation.Findings"); var hasError = validation.Findings.Any(finding => finding.Severity == PolicyFindingSeverity.Error); @@ -188,7 +212,9 @@ private static void ValidateValidation(PolicyValidationResult validation) throw new JsonException("Valid policy validation results must not contain Error findings."); } - PolicySerializer.ValidateRequiredCollectionElements(validation.CanonicalDraft); + PolicySerializer.ValidateRequiredCollectionElements( + validation.CanonicalDraft, + $"{path}.CanonicalDraft"); } else { @@ -207,9 +233,9 @@ private static void ValidateValidation(PolicyValidationResult validation) private static void ValidateReplacement(PolicyReplacementResponse response) { - PolicySerializer.ValidateRequiredCollectionElements(response.Policy); - ValidateValidation(response.Validation); - ValidateManagement(response.Management); + PolicySerializer.ValidateRequiredCollectionElements(response.Policy, "$.Policy"); + ValidateValidation(response.Validation, "$.Validation"); + ValidateManagement(response.Management, "$.Management"); if (!response.Validation.IsValid) { @@ -264,11 +290,114 @@ private static JsonSerializerOptions CreateOptions(bool writeIndented) BrokerErrorSerializerContext.Default) .WithAddedModifier(AttachSemanticValidation); - return new JsonSerializerOptions(BrokerSerializerContext.Default.Options) + var options = new JsonSerializerOptions(BrokerSerializerContext.Default.Options) { TypeInfoResolver = resolver, WriteIndented = writeIndented, }; + + AddDuplicateRejectingConverters(options); + return options; + } + + private static void AddDuplicateRejectingConverters(JsonSerializerOptions options) + { + // Explicit closed-world registrations are Native AOT safe. The client test suite enumerates + // all public object metadata from these source-generated contexts to prevent omissions. + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.PackageRequest)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.RequestSource)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.RequestPackage)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.RequestOptions)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.ClientContext)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.StatusRequest)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.CancelRequest)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyValidationRequest)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyReplacementRequest)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.HealthResponse)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.CapabilitiesResponse)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.ManagerCapability)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyResponse)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyManagementResponse)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyValidationResponse)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyReplacementResponse)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.EvaluationResponse)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.ExecutionResponse)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.ServerContext)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.RequestSummary)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.DecisionInfo)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.ResponsePolicyInfo)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.OperationDiagnostics)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.OperationSubmission)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.EventChannel)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.StatusResponse)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.CancelResponse)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerErrorSerializerContext.Default.ErrorResponse)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerErrorSerializerContext.Default.ErrorDetail)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyDocument, + static value => ValidateSemanticValue(value))); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyDraftDocument, + static value => ValidateSemanticValue(value))); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyMetadata, + static value => ValidateSemanticValue(value))); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyDraftMetadata, + static value => ValidateSemanticValue(value))); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyEnforcement)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyRule)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyMatch)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PackageIdentifierCondition)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.VersionCondition)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.VersionRange)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyConstraints)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyFinding)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyValidationResult, + static value => ValidateSemanticValue(value))); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.InvalidPolicyDiagnostics)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyManagementSnapshot, + static value => ValidateSemanticValue(value))); } private static void AttachSemanticValidation(JsonTypeInfo typeInfo) @@ -278,6 +407,20 @@ private static void AttachSemanticValidation(JsonTypeInfo typeInfo) return; } + if (typeInfo.Type.Assembly == typeof(PolicyDocument).Assembly) + { + typeInfo.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow; + } + PolicySerializer.ConfigureCanonicalSerialization(typeInfo); + if (typeInfo.Type == typeof(PolicyDocument) + || typeInfo.Type == typeof(PolicyDraftDocument) + || typeInfo.Type == typeof(PolicyMetadata) + || typeInfo.Type == typeof(PolicyDraftMetadata) + || typeInfo.Type == typeof(PolicyManagementSnapshot) + || typeInfo.Type == typeof(PolicyValidationResult)) + { + return; + } typeInfo.OnSerializing = ValidateSemanticValue; typeInfo.OnDeserialized = ValidateSemanticValue; } diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs index 6875840..61626b1 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs @@ -129,13 +129,11 @@ public enum PolicyFindingCode InvalidFieldType, InvalidFieldValue, DuplicateRuleId, - IneffectiveBooleanMatch, InvalidVersionRange, EmptyVersionRange, InvalidWildcardPattern, ContradictoryConstraints, InvalidValidityInterval, - UnsupportedPolicyType, UnsupportedPolicyFormatVersion, AuditModeEnabled, DefaultAllow, diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/README.md b/policies/dotnet/Devolutions.Now.Policy.Api/README.md index 9dc5edd..4ba702d 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Api/README.md @@ -33,6 +33,7 @@ Architecture - `MetaModels.cs` defines health, capabilities, manager capability, and error DTOs. - `Enums.cs` defines package broker API enums and JSON string enum converters. - `BrokerSerializer.cs` defines source-generated serializer options for the broker wire format. Public `BrokerSerializer.Options` and `BrokerSerializer.PrettyOptions` support every broker DTO, including the embedded policy model, without reflection and reject JSON null for non-nullable contract members. +- Both strict and non-strict broker deserialization reject duplicate property names before typed deserialization, including duplicates inside opaque draft JSON and embedded policy/management response objects. Embedded policy models also reject unknown members on every broker input path so removed restrictions cannot be discarded. Escaped names are decoded and compared with ordinal, case-sensitive equality. - `PolicyCompatibility.cs` maps compatible API enums to and from `Devolutions.Now.Policy.Model` enums. Opaque policy store tokens and validation receipts are restricted to safe printable ASCII (`A-Z`, `a-z`, `0-9`, `.`, `_`, `~`, `:`, `-`) beginning with an ASCII alphanumeric character, so Rust and .NET enforce identical bounds. diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs index 3eb6b8c..95952ee 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs @@ -333,6 +333,42 @@ public async Task GetPolicy_rejects_unmapped_property(string objectPath) await AssertInvalidPolicyResponse(document); } + [Fact] + public async Task GetPolicy_rejects_duplicate_nested_policy_property_before_deserialization() + { + var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json"); + var body = await File.ReadAllTextAsync(path); + body = body.Replace( + "\"PolicyFormatVersion\": \"1.0.0\",", + "\"PolicyFormatVersion\": \"1.0.0\",\n\"PolicyFormatVersi\\u006fn\": \"1.0.0\",", + StringComparison.Ordinal); + var client = CreateClient(new FakeBrokerTransport(body)); + + var exception = await Assert.ThrowsAsync(() => client.GetPolicy()); + + Assert.Equal(BrokerClientErrorKind.InvalidResponse, exception.Kind); + Assert.Equal("/v1/policy", exception.Endpoint); + Assert.IsAssignableFrom(exception.InnerException); + } + + [Fact] + public async Task GetPolicy_wraps_invalid_surrogate_property_names() + { + var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json"); + var body = await File.ReadAllTextAsync(path); + body = body.Replace( + "\"PolicyFormatVersion\": \"1.0.0\",", + "\"PolicyFormatVersion\": \"1.0.0\",\n\"\\uD800\": true,", + StringComparison.Ordinal); + var client = CreateClient(new FakeBrokerTransport(body)); + + var exception = await Assert.ThrowsAsync(() => client.GetPolicy()); + + Assert.Equal(BrokerClientErrorKind.InvalidResponse, exception.Kind); + Assert.Equal("/v1/policy", exception.Endpoint); + Assert.IsAssignableFrom(exception.InnerException); + } + [Fact] public async Task GetPolicy_rejects_integer_policy_enum_token() { @@ -347,7 +383,6 @@ public async Task GetPolicy_rejects_integer_policy_enum_token() [Theory] [InlineData("Server.Transport", "httpnamedpipe")] [InlineData("Policy.Enforcement.DefaultDecision", "deny")] - [InlineData("Policy.Enforcement.RulePrecedence", "prioritythendeny")] [InlineData("Policy.Rules.0.Decision", "deny")] [InlineData("Policy.Rules.0.Match.Operations.0", "install")] public async Task GetPolicy_rejects_noncanonical_enum_casing(string propertyPath, string value) @@ -362,7 +397,7 @@ public async Task GetPolicy_rejects_noncanonical_enum_casing(string propertyPath [Theory] [InlineData("Policy.Rules.0")] - [InlineData("Policy.Rules.3.Match.Sources.0")] + [InlineData("Policy.Rules.3.Match.SourceNames.0")] public async Task GetPolicy_rejects_null_collection_element(string elementPath) { var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json"); @@ -373,6 +408,18 @@ public async Task GetPolicy_rejects_null_collection_element(string elementPath) await AssertInvalidPolicyResponse(document); } + [Fact] + public async Task GetPolicy_rejects_constraints_on_deny_rule() + { + var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json"); + var document = JsonNode.Parse(await File.ReadAllTextAsync(path)) + ?? throw new InvalidOperationException("policy response sample should parse"); + document["Policy"]!["Rules"]![0]!["Constraints"] = + new JsonObject { ["AllowInteractive"] = false }; + + await AssertInvalidPolicyResponse(document); + } + [Fact] public async Task GetPolicy_propagates_cancellation() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs index 158592d..c02e7da 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs @@ -86,7 +86,7 @@ public void PolicyResponse_rejects_null_non_nullable_property(string propertyPat [Theory] [InlineData("Policy.Rules.0")] - [InlineData("Policy.Rules.3.Match.Sources.0")] + [InlineData("Policy.Rules.3.Match.SourceNames.0")] public void Strict_policy_response_rejects_null_collection_element(string elementPath) { var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json"); @@ -142,6 +142,8 @@ public void Public_json_options_source_generate_all_broker_dtos() typeof(PolicyEnforcement), typeof(PolicyRule), typeof(PolicyMatch), + typeof(PackageIdentifierCondition), + typeof(VersionCondition), typeof(VersionRange), typeof(PolicyConstraints), ]; @@ -169,6 +171,23 @@ public void Public_json_options_round_trip_policy_response_without_reflection() Assert.Contains(Environment.NewLine, pretty); } + [Fact] + public void Non_strict_broker_policy_inputs_reject_removed_policy_members() + { + var document = JsonNode.Parse( + File.ReadAllText(Path.Combine(TestData.SamplesDir, "responses", "policy.response.json")))!; + document["Policy"]!["Rules"]![3]!["Match"]!["PackageNames"] = + new JsonArray("Visual Studio Code"); + var json = document.ToJsonString(); + + Assert.Throws(() => BrokerSerializer.Deserialize(json)); + foreach (var options in new[] { BrokerSerializer.Options, BrokerSerializer.PrettyOptions }) + { + Assert.Throws( + () => JsonSerializer.Deserialize(json, options)); + } + } + [Fact] public void Strict_policy_response_rejects_schema_member_and_unsupported_format_version() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs index ec91b92..fdf2e06 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs @@ -1,13 +1,22 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Devolutions.Now.Policy.Api; using Devolutions.Now.Policy.Client; using Xunit; +using PackageIdentifierCondition = Devolutions.Now.Policy.Model.PackageIdentifierCondition; +using PolicyConstraints = Devolutions.Now.Policy.Model.PolicyConstraints; using PolicyDocument = Devolutions.Now.Policy.Model.PolicyDocument; using PolicyDraftDocument = Devolutions.Now.Policy.Model.PolicyDraftDocument; +using PolicyDraftMetadata = Devolutions.Now.Policy.Model.PolicyDraftMetadata; +using PolicyMetadata = Devolutions.Now.Policy.Model.PolicyMetadata; +using VersionCondition = Devolutions.Now.Policy.Model.VersionCondition; +using VersionRange = Devolutions.Now.Policy.Model.VersionRange; namespace Devolutions.Now.Policy.Client.Tests; @@ -223,6 +232,375 @@ await ReadFixture("requests", "policy-replacement.update.request.json"))!)), Assert.Equal(BrokerClientErrorKind.InvalidResponse, exception.Kind); } + [Fact] + public async Task Policy_contract_serializers_reject_duplicates_in_embedded_documents() + { + var policyResponse = WithEscapedPolicyFormatVersionDuplicate( + await ReadFixture("responses", "policy.response.json")); + var managementResponse = WithEscapedPolicyFormatVersionDuplicate( + await ReadFixture("responses", "policy-management.active.response.json")); + var validationRequest = WithEscapedPolicyFormatVersionDuplicate( + await ReadFixture("requests", "policy-validation.request.json")); + var validationResponse = WithEscapedPolicyFormatVersionDuplicate( + await ReadFixture("responses", "policy-validation.valid.response.json")); + var replacementRequest = WithEscapedPolicyFormatVersionDuplicate( + await ReadFixture("requests", "policy-replacement.update.request.json")); + var replacementResponse = WithEscapedPolicyFormatVersionDuplicate( + await ReadFixture("responses", "policy-replacement.response.json")); + var errorResponse = WithEscapedPolicyFormatVersionDuplicate( + await ReadFixture("responses", "policy-stale-token.error.json")); + + Assert.False(JsonSerializer.IsReflectionEnabledByDefault); + Assert.Throws(() => BrokerSerializer.Deserialize(policyResponse)); + Assert.Throws(() => BrokerSerializer.DeserializeStrict(policyResponse)); + Assert.Throws( + () => BrokerSerializer.DeserializeStrict(managementResponse)); + Assert.Throws( + () => BrokerSerializer.DeserializeStrict(validationRequest)); + Assert.Throws( + () => BrokerSerializer.DeserializeStrict(validationResponse)); + Assert.Throws( + () => BrokerSerializer.DeserializeStrict(replacementRequest)); + Assert.Throws( + () => BrokerSerializer.DeserializeStrict(replacementResponse)); + Assert.Throws(() => BrokerSerializer.Deserialize(errorResponse)); + + foreach (var options in new[] { BrokerSerializer.Options, BrokerSerializer.PrettyOptions }) + { + Assert.Throws( + () => JsonSerializer.Deserialize(policyResponse, options)); + Assert.Throws( + () => JsonSerializer.Deserialize(managementResponse, options)); + Assert.Throws( + () => JsonSerializer.Deserialize(validationRequest, options)); + Assert.Throws( + () => JsonSerializer.Deserialize(validationResponse, options)); + Assert.Throws( + () => JsonSerializer.Deserialize(replacementRequest, options)); + Assert.Throws( + () => JsonSerializer.Deserialize(replacementResponse, options)); + Assert.Throws( + () => JsonSerializer.Deserialize(errorResponse, options)); + } + } + + [Fact] + public void Public_broker_options_reject_duplicates_in_direct_policy_management_types() + { + const string Metadata = """ + { + "Id": "first", + "\u0049d": "second", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + } + """; + const string Finding = """ + { + "FindingVersion": "1.0", + "Severity": "Warning", + "Code": "DefaultAllow", + "Path": "", + "Message": "first", + "Message": "second" + } + """; + const string Diagnostics = """ + { + "DiagnosticsVersion": "1.0", + "DiagnosticsVersion": "2.0", + "Findings": [] + } + """; + + foreach (var options in new[] { BrokerSerializer.Options, BrokerSerializer.PrettyOptions }) + { + Assert.Throws( + () => JsonSerializer.Deserialize(Metadata, options)); + Assert.Throws( + () => JsonSerializer.Deserialize(Finding, options)); + Assert.Throws( + () => JsonSerializer.Deserialize(Diagnostics, options)); + } + } + + [Fact] + public void Public_broker_options_enforce_standalone_policy_condition_invariants() + { + foreach (var options in new[] { BrokerSerializer.Options, BrokerSerializer.PrettyOptions }) + { + foreach (var invalid in new[] + { + "{}", + """{"Exact":["Microsoft.*"]}""", + """{"Exact":["Git.Git"],"Patterns":["Git.*"]}""", + """{"Exact":["Git.Git"],"Patterns":null}""", + }) + { + Assert.Throws( + () => JsonSerializer.Deserialize(invalid, options)); + } + + foreach (var invalid in new[] + { + "{}", + """{"Exact":[]}""", + """{"Exact":["1.0.0"],"Range":{"MinVersion":"1.0.0"}}""", + """{"Exact":["1.0.0"],"Range":null}""", + }) + { + Assert.Throws( + () => JsonSerializer.Deserialize(invalid, options)); + } + + Assert.Throws( + () => JsonSerializer.Serialize(new PackageIdentifierCondition(), options)); + Assert.Throws( + () => JsonSerializer.Serialize(new VersionCondition(), options)); + foreach (var invalid in new[] + { + "{}", + """{"MinVersion":"not-semver"}""", + }) + { + Assert.Throws( + () => JsonSerializer.Deserialize(invalid, options)); + } + Assert.Throws( + () => JsonSerializer.Serialize(new VersionRange(), options)); + } + } + + [Fact] + public async Task Public_broker_options_enforce_standalone_validity_windows() + { + const string Metadata = """ + { + "Id": "validity.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z", + "ValidFrom": "2026-01-01T01:00:00+01:00", + "ValidUntil": "2026-01-01T00:00:00Z" + } + """; + const string DraftMetadata = """ + { + "Id": "validity.test", + "Publisher": "Test", + "ValidFrom": "2026-01-01T00:30:00Z", + "ValidUntil": "2026-01-01T01:00:00+01:00" + } + """; + + foreach (var options in new[] { BrokerSerializer.Options, BrokerSerializer.PrettyOptions }) + { + var exception = Assert.Throws( + () => JsonSerializer.Deserialize(Metadata, options)); + Assert.Equal("$.ValidUntil", exception.Path); + exception = Assert.Throws( + () => JsonSerializer.Deserialize(DraftMetadata, options)); + Assert.Equal("$.ValidUntil", exception.Path); + Assert.Throws( + () => JsonSerializer.Serialize( + new PolicyDraftMetadata + { + Id = "validity.test", + Publisher = "Test", + ValidFrom = DateTimeOffset.Parse("2026-01-01T00:00:00Z"), + ValidUntil = DateTimeOffset.Parse("2026-01-01T00:00:00Z"), + }, + options)); + } + + var response = JsonNode.Parse(await ReadFixture("responses", "policy.response.json"))!; + response["Policy"]!["Metadata"]![nameof(PolicyMetadata.ValidFrom)] = "2026-01-01T00:00:00Z"; + response["Policy"]!["Metadata"]![nameof(PolicyMetadata.ValidUntil)] = "2026-01-01T00:00:00Z"; + foreach (var options in new[] { BrokerSerializer.Options, BrokerSerializer.PrettyOptions }) + { + var exception = Assert.Throws( + () => JsonSerializer.Deserialize(response.ToJsonString(), options)); + Assert.Equal("$.Policy.Metadata.ValidUntil", exception.Path); + } + + static void Invalidate(JsonNode metadata) + { + metadata[nameof(PolicyMetadata.ValidFrom)] = "2026-01-01T00:00:00Z"; + metadata[nameof(PolicyMetadata.ValidUntil)] = "2026-01-01T00:00:00Z"; + } + + var management = JsonNode.Parse( + await ReadFixture("responses", "policy-management.active.response.json"))!; + Invalidate(management["Management"]!["Policy"]!["Metadata"]!); + var validation = JsonNode.Parse( + await ReadFixture("responses", "policy-validation.valid.response.json"))!; + Invalidate(validation["Validation"]!["CanonicalDraft"]!["Metadata"]!); + var replacement = JsonNode.Parse( + await ReadFixture("responses", "policy-replacement.response.json"))!; + Invalidate(replacement["Validation"]!["CanonicalDraft"]!["Metadata"]!); + var error = JsonNode.Parse( + await ReadFixture("responses", "policy-stale-token.error.json"))!; + Invalidate(error["Management"]!["Policy"]!["Metadata"]!); + + foreach (var (document, type, expectedPath) in new[] + { + (management, typeof(PolicyManagementResponse), "$.Management.Policy.Metadata.ValidUntil"), + (validation, typeof(PolicyValidationResponse), "$.Validation.CanonicalDraft.Metadata.ValidUntil"), + (replacement, typeof(PolicyReplacementResponse), "$.Validation.CanonicalDraft.Metadata.ValidUntil"), + (error, typeof(ErrorResponse), "$.Management.Policy.Metadata.ValidUntil"), + }) + { + foreach (var options in new[] { BrokerSerializer.Options, BrokerSerializer.PrettyOptions }) + { + var exception = Assert.Throws( + () => JsonSerializer.Deserialize(document.ToJsonString(), type, options)); + Assert.Equal(expectedPath, exception.Path); + } + } + } + + [Fact] + public void Public_broker_options_reject_duplicates_in_every_direct_broker_object_type() + { + var contractAssemblies = new[] + { + typeof(BrokerSerializer).Assembly, + typeof(PolicyDocument).Assembly, + }; + + foreach (var options in new[] { BrokerSerializer.Options, BrokerSerializer.PrettyOptions }) + { + var metadataOptions = new JsonSerializerOptions(options); + metadataOptions.Converters.Clear(); + var resolver = Assert.IsAssignableFrom( + metadataOptions.TypeInfoResolver); + var publicObjectTypes = contractAssemblies + .SelectMany(assembly => assembly.ExportedTypes) + .Where(type => type.IsClass && !type.IsAbstract) + .Where(type => resolver.GetTypeInfo(type, metadataOptions)?.Kind == JsonTypeInfoKind.Object) + .ToList(); + + Assert.Contains(typeof(RequestSource), publicObjectTypes); + Assert.Contains(typeof(ServerContext), publicObjectTypes); + Assert.Contains(typeof(ManagerCapability), publicObjectTypes); + Assert.Contains(typeof(ErrorDetail), publicObjectTypes); + + foreach (var type in publicObjectTypes) + { + AssertDirectOptionsRejectDuplicates(type, options); + } + } + } + + [Fact] + public void Duplicate_rejecting_broker_options_preserve_caller_strictness() + { + const string Request = """ + { + "RequestKind": "PolicyValidationRequest", + "RequestVersion": "1.0", + "Draft": {}, + "Unexpected": true + } + """; + var options = new JsonSerializerOptions(BrokerSerializer.Options) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; + + Assert.Throws( + () => JsonSerializer.Deserialize(Request, options)); + } + + [Fact] + public void Invalid_surrogate_in_opaque_draft_remains_a_json_error() + { + const string Request = """ + { + "RequestKind": "PolicyValidationRequest", + "RequestVersion": "1.0", + "Draft": { + "\uD800": true + } + } + """; + + Assert.ThrowsAny( + () => BrokerSerializer.DeserializeStrict(Request)); + Assert.ThrowsAny( + () => JsonSerializer.Deserialize( + Request, + BrokerSerializer.Options)); + } + + [Theory] + [InlineData("management")] + [InlineData("validation")] + [InlineData("replacement")] + [InlineData("error")] + public async Task BrokerClient_rejects_duplicate_policy_properties_in_management_responses( + string operation) + { + var responseFixture = operation switch + { + "management" => "policy-management.active.response.json", + "validation" => "policy-validation.valid.response.json", + "replacement" => "policy-replacement.response.json", + _ => "policy-stale-token.error.json", + }; + var statusCode = operation == "error" ? 409 : 200; + var body = WithEscapedPolicyFormatVersionDuplicate( + await ReadFixture("responses", responseFixture)); + var client = CreateClient(new FakeBrokerTransport( + new BrokerTransportResponse { StatusCode = statusCode, Body = body })); + + var exception = operation switch + { + "management" => await Assert.ThrowsAsync( + () => client.GetPolicyManagement()), + "validation" => await Assert.ThrowsAsync( + () => client.ValidatePolicy(JsonDocument.Parse("{}").RootElement)), + "replacement" => await Assert.ThrowsAsync( + async () => await client.ReplacePolicy( + BrokerSerializer.DeserializeStrict( + await ReadFixture("requests", "policy-replacement.update.request.json"))!)), + _ => await Assert.ThrowsAsync( + async () => await client.ReplacePolicy( + BrokerSerializer.DeserializeStrict( + await ReadFixture("requests", "policy-replacement.update.request.json"))!)), + }; + + Assert.Equal( + operation == "error" + ? BrokerClientErrorKind.BrokerError + : BrokerClientErrorKind.InvalidResponse, + exception.Kind); + Assert.IsAssignableFrom(exception.InnerException); + Assert.Null(exception.BrokerError); + } + + [Fact] + public async Task BrokerClient_wraps_invalid_surrogate_names_in_structured_error_responses() + { + var body = await ReadFixture("responses", "policy-stale-token.error.json"); + body = body.Replace( + "\"Message\": \"The configured policy changed after it was read.\",", + "\"Message\": \"The configured policy changed after it was read.\",\n\"\\uD800\": true,", + StringComparison.Ordinal); + var client = CreateClient(new FakeBrokerTransport( + new BrokerTransportResponse { StatusCode = 409, Body = body })); + var request = BrokerSerializer.DeserializeStrict( + await ReadFixture("requests", "policy-replacement.update.request.json"))!; + + var exception = await Assert.ThrowsAsync( + () => client.ReplacePolicy(request)); + + Assert.Equal(BrokerClientErrorKind.BrokerError, exception.Kind); + Assert.IsAssignableFrom(exception.InnerException); + Assert.Null(exception.BrokerError); + } + [Fact] public async Task Management_methods_propagate_cancellation() { @@ -544,13 +922,21 @@ public async Task Public_serializer_options_enforce_policy_root_semantic_invaria committedDto.Metadata.Revision = (uint)int.MaxValue + 1; Assert.Throws(() => JsonSerializer.Serialize(committedDto, options)); + var draftDto = JsonSerializer.Deserialize(draft.ToJsonString(), options)!; + draftDto.Rules[0].Match.SkipHashCheck = null; + draftDto.Rules[0].Match.Operations.Clear(); + Assert.DoesNotContain( + "\"SkipHashCheck\"", + JsonSerializer.Serialize(draftDto, options), + StringComparison.Ordinal); + Assert.DoesNotContain( + "\"Operations\"", + JsonSerializer.Serialize(draftDto, options), + StringComparison.Ordinal); + committedDto = JsonSerializer.Deserialize(committed.ToJsonString(), options)!; - committedDto.Rules[0].Match.SkipHashCheck = [false, true]; + committedDto.Rules[0].Constraints = new PolicyConstraints { AllowInteractive = false }; Assert.Throws(() => JsonSerializer.Serialize(committedDto, options)); - - var draftDto = JsonSerializer.Deserialize(draft.ToJsonString(), options)!; - draftDto.Rules[0].Match.SkipHashCheck = [false, true]; - Assert.Throws(() => JsonSerializer.Serialize(draftDto, options)); } } @@ -615,6 +1001,35 @@ public async Task GetPolicyManagement_preserves_legacy_route_not_found(string bo private static async Task ReadFixture(string directory, string file) => await File.ReadAllTextAsync(Path.Combine(TestData.SamplesDir, directory, file)); + private static string WithEscapedPolicyFormatVersionDuplicate(string json) => + json.Replace( + "\"PolicyFormatVersion\": \"1.0.0\",", + "\"PolicyFormatVersion\": \"1.0.0\",\n\"PolicyFormatVersi\\u006fn\": \"1.0.0\",", + StringComparison.Ordinal); + + private static void AssertDirectOptionsRejectDuplicates( + Type type, + JsonSerializerOptions options) + { + foreach (var json in new[] + { + """{"Duplicate":true,"\u0044uplicate":true}""", + """{"Container":{"Duplicate":true,"\u0044uplicate":false}}""", + }) + { + var exception = Record.Exception( + () => JsonSerializer.Deserialize(json, type, options)); + Assert.True( + exception is JsonException, + $"{type.FullName} did not reject duplicate properties: {exception}"); + var jsonException = (JsonException)exception; + Assert.Contains( + "Duplicate JSON property name", + jsonException.Message, + StringComparison.Ordinal); + } + } + private static JsonNode ReplaceProperty(JsonNode source, string propertyName, JsonNode value) { var copy = source.DeepClone(); @@ -649,6 +1064,10 @@ private static IEnumerable InvalidCommittedPolicies(JsonNode committed var mixedBooleanMatch = committed.DeepClone(); mixedBooleanMatch["Rules"]![0]!["Match"]!["SkipHashCheck"] = new JsonArray(false, true); yield return mixedBooleanMatch; + + var constraintsOnDeny = committed.DeepClone(); + constraintsOnDeny["Rules"]![0]!["Constraints"] = new JsonObject { ["AllowInteractive"] = false }; + yield return constraintsOnDeny; } private static IEnumerable InvalidDraftPolicies(JsonNode draft) diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs index 9e5d7ec..9e08cce 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Nodes; + using NJsonSchema; using Xunit; @@ -87,6 +89,47 @@ public async Task Capabilities_response_samples_are_schema_valid(string path) public async Task Policy_response_samples_are_schema_valid(string path) => await AssertValid(path, await TestData.SchemaAsync("PolicyResponse")); + [Fact] + public async Task OpenApi_policy_match_boolean_characteristics_are_optional_nullable_scalars() + { + var schema = await TestData.SchemaAsync("PolicyResponse"); + var template = JsonNode.Parse( + await File.ReadAllTextAsync( + Path.Combine(TestData.SamplesDir, "responses", "policy.response.json")))!; + var propertyNames = new[] + { + "Interactive", + "SkipHashCheck", + "PreRelease", + "HasCustomParameters", + "HasCustomInstallLocation", + "HasPrePostCommands", + "HasKillBeforeOperation", + "HasUninstallPrevious", + }; + + foreach (var propertyName in propertyNames) + { + foreach (var validValue in new JsonNode?[] { null, false, true }) + { + var document = template.DeepClone(); + document["Policy"]!["Rules"]![0]!["Match"]![propertyName] = validValue?.DeepClone(); + Assert.Empty(schema.Validate(document.ToJsonString())); + } + + foreach (var invalidValue in new[] { "[]", "[false]", "[true]", "[false,true]", "\"true\"", "0", "{}" }) + { + var document = template.DeepClone(); + document["Policy"]!["Rules"]![0]!["Match"]![propertyName] = JsonNode.Parse(invalidValue); + Assert.NotEmpty(schema.Validate(document.ToJsonString())); + } + + var omitted = template.DeepClone(); + omitted["Policy"]!["Rules"]![0]!["Match"]!.AsObject().Remove(propertyName); + Assert.Empty(schema.Validate(omitted.ToJsonString())); + } + } + [Fact] public async Task Invalid_request_is_rejected_by_schema() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index ce0fa99..3c419de 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using NJsonSchema; @@ -22,6 +23,12 @@ public class PolicyTests public static IEnumerable PolicySamples() => Directory.GetFiles(SamplesDir, "*.policy.json").Select(f => new object[] { f }); + public static IEnumerable DuplicatePropertySamples() => + Directory.GetFiles( + Path.Combine(SamplesDir, "invalid", "duplicates"), + "*.policy.json") + .Select(f => new object[] { f }); + public static TheoryData ConstraintTextCollections() => new() { { nameof(PolicyConstraints.AllowedInstallLocationPatterns), 256 }, @@ -30,6 +37,28 @@ public static IEnumerable PolicySamples() => { nameof(PolicyConstraints.DeniedCustomParameters), 512 }, }; + public static TheoryData BooleanMatchProperties() => new() + { + nameof(PolicyMatch.Interactive), + nameof(PolicyMatch.SkipHashCheck), + nameof(PolicyMatch.PreRelease), + nameof(PolicyMatch.HasCustomParameters), + nameof(PolicyMatch.HasCustomInstallLocation), + nameof(PolicyMatch.HasPrePostCommands), + nameof(PolicyMatch.HasKillBeforeOperation), + nameof(PolicyMatch.HasUninstallPrevious), + }; + + public static TheoryData CollectionMatchProperties() => new() + { + { nameof(PolicyMatch.Operations), "\"Install\"" }, + { nameof(PolicyMatch.Managers), "\"Winget\"" }, + { nameof(PolicyMatch.SourceNames), "\"winget\"" }, + { nameof(PolicyMatch.Scopes), "\"User\"" }, + { nameof(PolicyMatch.Architectures), "\"X64\"" }, + { nameof(PolicyMatch.ExecutionElevation), "\"Standard\"" }, + }; + [Fact] public void Tests_run_with_reflection_json_disabled() { @@ -63,13 +92,16 @@ public async Task Created_policy_validates_against_rust_schema() { Operations = [Operation.Install], Managers = [ManagerName.Winget], - PackageIdentifiers = ["Microsoft.VisualStudioCode"], + PackageIdentifiers = new PackageIdentifierCondition + { + Exact = ["Microsoft.VisualStudioCode"], + }, }, }); var schema = await JsonSchema.FromFileAsync(PolicySchema); var json = policy.ToJson(); - var reparsed = PolicySerializer.DeserializeStrict(json); + var reparsed = PolicySerializer.Deserialize(json); var errors = schema.Validate(json); Assert.NotNull(reparsed); @@ -103,7 +135,267 @@ public void Policy_and_draft_parsers_reject_schema_member() var draftJson = JsonNode.Parse(policy.ToDraft().ToJson())!; draftJson["$schema"] = "https://example.invalid/policy-draft.schema.json"; Assert.Throws( - () => PolicySerializer.DeserializePolicyDraftDocumentStrict(draftJson.ToJsonString())); + () => PolicySerializer.Deserialize(draftJson.ToJsonString())); + } + + [Theory] + [MemberData(nameof(DuplicatePropertySamples))] + public void All_policy_deserialization_entry_points_reject_duplicate_properties(string path) + { + var json = File.ReadAllText(path); + + Assert.False(JsonSerializer.IsReflectionEnabledByDefault); + Assert.Throws(() => PolicyDocument.ParseJson(json)); + Assert.Throws(() => PolicySerializer.Deserialize(json)); + Assert.Throws( + () => JsonSerializer.Deserialize(json, PolicySerializer.Options)); + } + + [Fact] + public void All_draft_deserialization_entry_points_reject_nested_escaped_duplicate_properties() + { + const string Json = """ + { + "PolicyFormatVersion": "1.0.0", + "Metadata": { + "Id": "duplicate.test", + "\u0049d": "duplicate.test", + "Publisher": "Test" + }, + "Enforcement": { + "DefaultDecision": "Deny" + }, + "Rules": [] + } + """; + + Assert.Throws(() => PolicyDraftDocument.ParseJson(Json)); + Assert.Throws(() => PolicySerializer.Deserialize(Json)); + Assert.Throws( + () => JsonSerializer.Deserialize(Json, PolicySerializer.Options)); + } + + [Fact] + public void Duplicate_property_comparison_is_ordinal_and_case_sensitive() + { + var json = MinimalPolicyJson( + """ + "Revision": 1, + """, + """ + "policyFormatVersion": "1.1.0", + "Rules": [] + """); + + var exception = Assert.Throws( + () => PolicySerializer.Deserialize(json)); + Assert.DoesNotContain("Duplicate JSON property name", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Escaped_surrogate_pairs_match_literal_unicode_property_names() + { + var json = MinimalPolicyJson( + """ + "Revision": 1, + """, + """ + "Extension": { + "😀": true, + "\uD83D\uDE00": false + }, + "Rules": [] + """); + + var exception = Assert.Throws( + () => PolicySerializer.Deserialize(json)); + Assert.Contains("Duplicate JSON property name", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Duplicate_preprocessing_observes_the_serializer_depth_limit() + { + var nested = string.Concat(Enumerable.Repeat("""{"Nested":""", 65)) + + "true" + + new string('}', 65); + var json = MinimalPolicyJson( + """ + "Revision": 1, + """, + $$""" + "Extension": {{nested}}, + "Rules": [] + """); + + Assert.ThrowsAny(() => PolicySerializer.Deserialize(json)); + } + + [Theory] + [InlineData("PolicyType")] + [InlineData("RulePrecedence")] + [InlineData("PackageNames")] + [InlineData("Elevation")] + [InlineData("Sources")] + [InlineData("Versions")] + [InlineData("VersionRange")] + public void Removed_policy_members_are_rejected_as_unknown(string member) + { + var policy = PolicyDocument.ParseJson( + File.ReadAllText(Path.Combine(SamplesDir, "corporate-allowlist.policy.json"))); + var documents = new (JsonNode Document, Action Parse)[] + { + ( + JsonNode.Parse(policy.ToJson())!, + json => PolicySerializer.Deserialize(json)), + ( + JsonNode.Parse(policy.ToDraft().ToJson())!, + json => PolicySerializer.Deserialize(json)), + }; + + foreach (var (document, parse) in documents) + { + switch (member) + { + case "PolicyType": + document[member] = "PackageBrokerPolicy"; + break; + case "RulePrecedence": + document["Enforcement"]![member] = "PriorityThenDeny"; + break; + case "VersionRange": + document["Rules"]![0]!["Match"]![member] = new JsonObject { ["MinVersion"] = "1.0.0" }; + break; + default: + document["Rules"]![0]!["Match"]![member] = new JsonArray("Friendly package name"); + break; + } + + Assert.Throws(() => parse(document.ToJsonString())); + } + } + + [Fact] + public void All_policy_inputs_reject_removed_members_instead_of_broadening_rules() + { + var committed = JsonNode.Parse( + File.ReadAllText(Path.Combine(SamplesDir, "corporate-allowlist.policy.json")))!; + committed["Rules"]![3]!["Match"]!["PackageNames"] = + new JsonArray("Visual Studio Code"); + committed["Rules"]![3]!["Match"]!["Sources"] = + new JsonArray("winget"); + committed["Rules"]![3]!["Match"]!["Versions"] = + new JsonArray("1.0.0"); + var committedJson = committed.ToJsonString(); + + Assert.Throws( + () => PolicySerializer.Deserialize(committedJson)); + Assert.Throws( + () => JsonSerializer.Deserialize( + committedJson, + PolicySerializer.Options)); + + var draft = committed.DeepClone(); + draft["Metadata"]!.AsObject().Remove("Revision"); + draft["Metadata"]!.AsObject().Remove("PublishedAt"); + Assert.Throws( + () => JsonSerializer.Deserialize( + draft.ToJsonString(), + PolicySerializer.Options)); + } + + [Theory] + [InlineData("PolicyType")] + [InlineData("RulePrecedence")] + [InlineData("PackageNames")] + [InlineData("Elevation")] + [InlineData("Sources")] + [InlineData("Versions")] + [InlineData("VersionRange")] + public async Task Rust_schemas_reject_removed_policy_members(string member) + { + var policy = PolicyDocument.ParseJson( + File.ReadAllText(Path.Combine(SamplesDir, "corporate-allowlist.policy.json"))); + var documents = new[] + { + ( + Document: JsonNode.Parse(policy.ToJson())!, + Schema: await JsonSchema.FromFileAsync(PolicySchema)), + ( + Document: JsonNode.Parse(policy.ToDraft().ToJson())!, + Schema: await JsonSchema.FromFileAsync(PolicyDraftSchema)), + }; + + foreach (var (document, schema) in documents) + { + switch (member) + { + case "PolicyType": + document[member] = "PackageBrokerPolicy"; + break; + case "RulePrecedence": + document["Enforcement"]![member] = "PriorityThenDeny"; + break; + case "PackageNames": + document["Rules"]![0]!["Match"]![member] = new JsonArray("Friendly package name"); + break; + case "VersionRange": + document["Rules"]![0]!["Match"]![member] = new JsonObject { ["MinVersion"] = "1.0.0" }; + break; + default: + document["Rules"]![0]!["Match"]![member] = new JsonArray("Elevated"); + break; + } + + Assert.NotEmpty(schema.Validate(document.ToJsonString())); + } + } + + [Fact] + public void Canonical_policy_json_omits_removed_members() + { + var policy = PolicyDocument.ParseJson( + File.ReadAllText(Path.Combine(SamplesDir, "boolean-characteristics.policy.json"))); + var json = policy.ToJson(); + + Assert.DoesNotContain("\"PolicyType\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"RulePrecedence\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"PackageNames\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"Elevation\":", json, StringComparison.Ordinal); + Assert.Contains("\"ExecutionElevation\"", json, StringComparison.Ordinal); + Assert.Contains("\"SourceNames\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"Sources\":", json, StringComparison.Ordinal); + } + + [Fact] + public void Invalid_surrogate_property_names_remain_json_errors() + { + var json = MinimalPolicyJson( + """ + "Revision": 1, + "\uD800": true, + """, + """ + "Rules": [] + """); + + Assert.ThrowsAny(() => PolicySerializer.Deserialize(json)); + Assert.ThrowsAny( + () => JsonSerializer.Deserialize(json, PolicySerializer.Options)); + } + + [Fact] + public void Duplicate_rejecting_options_preserve_caller_serialization_settings() + { + var options = new JsonSerializerOptions(PolicySerializer.Options) + { + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + }; + var json = JsonSerializer.Serialize( + PolicyDocument.Create("custom.options", "Test"), + options); + + Assert.Contains("\"ValidFrom\": null", json, StringComparison.Ordinal); + Assert.Contains("\"ValidUntil\": null", json, StringComparison.Ordinal); } [Fact] @@ -165,7 +457,6 @@ public void Negative_priority_is_rejected_by_parser() [Theory] [InlineData("PolicyFormatVersion")] - [InlineData("PolicyType")] [InlineData("Metadata")] [InlineData("Enforcement")] [InlineData("Rules")] @@ -174,7 +465,6 @@ public void Negative_priority_is_rejected_by_parser() [InlineData("Metadata.Revision")] [InlineData("Metadata.PublishedAt")] [InlineData("Enforcement.DefaultDecision")] - [InlineData("Enforcement.RulePrecedence")] [InlineData("Rules.0.Id")] [InlineData("Rules.0.Priority")] [InlineData("Rules.0.Decision")] @@ -192,7 +482,6 @@ public void Missing_rust_required_property_is_rejected_by_parser(string property [Theory] [InlineData("PolicyFormatVersion")] - [InlineData("PolicyType")] [InlineData("Metadata")] [InlineData("Enforcement")] [InlineData("Rules")] @@ -201,7 +490,6 @@ public void Missing_rust_required_property_is_rejected_by_parser(string property [InlineData("Metadata.Revision")] [InlineData("Metadata.PublishedAt")] [InlineData("Enforcement.DefaultDecision")] - [InlineData("Enforcement.RulePrecedence")] [InlineData("Rules.0.Id")] [InlineData("Rules.0.Priority")] [InlineData("Rules.0.Decision")] @@ -219,8 +507,8 @@ public void Null_rust_required_property_is_rejected_by_parser(string propertyPat [Theory] [InlineData("Rules.0")] - [InlineData("Rules.3.Match.Sources.0")] - [InlineData("Rules.3.Match.PackageIdentifiers.0")] + [InlineData("Rules.3.Match.SourceNames.0")] + [InlineData("Rules.3.Match.PackageIdentifiers.Exact.0")] public void Null_policy_collection_element_is_rejected_by_parser(string elementPath) { var path = Path.Combine(SamplesDir, "corporate-allowlist.policy.json"); @@ -316,52 +604,983 @@ public void Draft_conversion_enforces_revision_bounds() } [Fact] - public void Mixed_boolean_match_values_are_rejected() + public void Validity_windows_accept_absent_one_sided_and_ordered_instants() { - var document = JsonNode.Parse( - File.ReadAllText(Path.Combine(SamplesDir, "corporate-allowlist.policy.json")))!; - document["Rules"]![0]!["Match"]!["Interactive"] = new JsonArray(false, true); + foreach (var validity in new[] + { + "", + """ + ,"ValidFrom": null, "ValidUntil": null + """, + """ + ,"ValidFrom": "2026-01-01T00:00:00Z" + """, + """ + ,"ValidUntil": "2026-01-01T00:00:00Z" + """, + """ + ,"ValidFrom": "2026-01-01T01:00:00+01:00", + "ValidUntil": "2026-01-01T00:30:00Z" + """, + }) + { + var metadataJson = $$""" + { + "Id": "validity.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + {{validity}} + } + """; + var draftMetadataJson = $$""" + { + "Id": "validity.test", + "Publisher": "Test" + {{validity}} + } + """; + + Assert.NotNull(PolicySerializer.Deserialize(metadataJson)); + Assert.NotNull(PolicySerializer.Deserialize(draftMetadataJson)); + Assert.NotNull(JsonSerializer.Deserialize(metadataJson, PolicySerializer.Options)); + Assert.NotNull(JsonSerializer.Deserialize( + draftMetadataJson, + PolicySerializer.Options)); + } - Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); + var explicitNull = PolicySerializer.Deserialize( + """ + { + "Id": "validity.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z", + "ValidFrom": null, + "ValidUntil": null + } + """)!; + var canonical = JsonNode.Parse(PolicySerializer.Serialize(explicitNull))!; + Assert.False(canonical.AsObject().ContainsKey(nameof(PolicyMetadata.ValidFrom))); + Assert.False(canonical.AsObject().ContainsKey(nameof(PolicyMetadata.ValidUntil))); + } + + [Theory] + [InlineData("2026-01-01T01:00:00+01:00", "2026-01-01T00:00:00Z")] + [InlineData("2026-01-01T00:30:00Z", "2026-01-01T01:00:00+01:00")] + public void Validity_windows_reject_equal_and_inverted_instants(string validFrom, string validUntil) + { + var metadataJson = $$""" + { + "Id": "validity.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z", + "ValidFrom": "{{validFrom}}", + "ValidUntil": "{{validUntil}}" + } + """; + var draftMetadataJson = $$""" + { + "Id": "validity.test", + "Publisher": "Test", + "ValidFrom": "{{validFrom}}", + "ValidUntil": "{{validUntil}}" + } + """; + var policyJson = MinimalPolicyJson( + $$""" + "Revision": 1, + "ValidFrom": "{{validFrom}}", + "ValidUntil": "{{validUntil}}", + """, + """ + "Rules": [] + """); + var draftJson = $$""" + { + "PolicyFormatVersion": "1.0.0", + "Metadata": {{draftMetadataJson}}, + "Enforcement": { "DefaultDecision": "Deny" }, + "Rules": [] + } + """; + + var exception = Assert.Throws( + () => PolicySerializer.Deserialize(metadataJson)); + Assert.Equal("$.ValidUntil", exception.Path); + Assert.Contains("$.ValidUntil", exception.Message, StringComparison.Ordinal); + Assert.Contains("$.ValidFrom", exception.Message, StringComparison.Ordinal); + Assert.Throws( + () => PolicySerializer.Deserialize(draftMetadataJson)); + exception = Assert.Throws( + () => JsonSerializer.Deserialize(metadataJson, PolicySerializer.Options)); + Assert.Equal("$.ValidUntil", exception.Path); + Assert.Throws( + () => JsonSerializer.Deserialize( + draftMetadataJson, + PolicySerializer.Options)); + + exception = Assert.Throws(() => PolicyDocument.ParseJson(policyJson)); + Assert.Equal("$.Metadata.ValidUntil", exception.Path); + Assert.Contains("$.Metadata.ValidUntil", exception.Message, StringComparison.Ordinal); + Assert.Contains("$.Metadata.ValidFrom", exception.Message, StringComparison.Ordinal); + Assert.Throws(() => PolicySerializer.Deserialize(policyJson)); + exception = Assert.Throws( + () => JsonSerializer.Deserialize(policyJson, PolicySerializer.Options)); + Assert.Equal("$.Metadata.ValidUntil", exception.Path); + Assert.Throws(() => PolicyDraftDocument.ParseJson(draftJson)); + Assert.Throws( + () => JsonSerializer.Deserialize( + draftJson, + PolicySerializer.Options)); + } + + [Fact] + public void Draft_conversions_reject_invalid_union_conditions_before_cloning() + { + var committed = PolicyDocument.Create("invalid.clone", "Test"); + committed.Rules.Add(new PolicyRule + { + Id = "allow.invalid", + Priority = 1, + Decision = Decision.Allow, + Match = new PolicyMatch + { + Managers = [ManagerName.Winget], + PackageIdentifiers = new PackageIdentifierCondition(), + }, + }); + Assert.Throws(() => committed.ToDraft()); + + committed.Rules[0].Match.PackageIdentifiers = new PackageIdentifierCondition + { + Exact = ["Git.Git"], + Patterns = ["Git.*"], + }; + Assert.Throws(() => committed.ToDraft()); + + committed.Rules[0].Match.PackageIdentifiers = new PackageIdentifierCondition + { + Exact = ["Git.Git"], + Patterns = null, + }; + Assert.Throws(() => committed.ToDraft()); + + var draft = PolicyDraftDocument.Create("invalid.clone", "Test"); + draft.Rules.Add(new PolicyRule + { + Id = "allow.invalid", + Priority = 1, + Decision = Decision.Allow, + Match = new PolicyMatch + { + Managers = [ManagerName.Winget], + Version = new VersionCondition(), + }, + }); + Assert.Throws( + () => draft.ToPolicyDocument(1, DateTimeOffset.UtcNow)); + + draft.Rules[0].Match.Version = new VersionCondition + { + Exact = ["1.0.0"], + Range = new VersionRange { MinVersion = "1.0.0" }, + }; + Assert.Throws( + () => draft.ToPolicyDocument(1, DateTimeOffset.UtcNow)); + + draft.Rules[0].Match.Version = new VersionCondition + { + Exact = ["1.0.0"], + Range = null, + }; + Assert.Throws( + () => draft.ToPolicyDocument(1, DateTimeOffset.UtcNow)); + } + + [Theory] + [MemberData(nameof(BooleanMatchProperties))] + public void Boolean_match_characteristics_accept_omitted_null_false_and_true(string propertyName) + { + var omitted = PolicySerializer.Deserialize("{}")!; + Assert.Null(GetBooleanMatch(omitted, propertyName)); + Assert.DoesNotContain($"\"{propertyName}\"", PolicySerializer.Serialize(omitted)); + + var explicitNull = PolicySerializer.Deserialize( + $$"""{"{{propertyName}}":null}""")!; + Assert.Null(GetBooleanMatch(explicitNull, propertyName)); + Assert.DoesNotContain($"\"{propertyName}\"", PolicySerializer.Serialize(explicitNull)); + + foreach (var expected in new[] { false, true }) + { + var match = PolicySerializer.Deserialize( + $$"""{"{{propertyName}}":{{expected.ToString().ToLowerInvariant()}}}""")!; + Assert.Equal(expected, GetBooleanMatch(match, propertyName)); + + var serialized = JsonNode.Parse(PolicySerializer.Serialize(match))!; + Assert.Equal(expected, serialized[propertyName]!.GetValue()); + } + } + + [Theory] + [MemberData(nameof(BooleanMatchProperties))] + public void Boolean_match_characteristics_reject_legacy_arrays_and_wrong_types(string propertyName) + { + foreach (var invalidValue in new[] { "[]", "[false]", "[true]", "[false,true]", "\"true\"", "0", "{}" }) + { + var matchJson = $$"""{"{{propertyName}}":{{invalidValue}}}"""; + var ruleJson = + $$"""{"Id":"test.rule","Priority":1,"Decision":"Allow","Match":{{matchJson}}}"""; + + Assert.Throws(() => PolicySerializer.Deserialize(matchJson)); + Assert.Throws(() => PolicySerializer.Deserialize(ruleJson)); + Assert.Throws( + () => JsonSerializer.Deserialize(matchJson, PolicySerializer.Options)); + Assert.Throws( + () => JsonSerializer.Deserialize(matchJson, PolicySerializer.Options)); + } + } + + [Theory] + [MemberData(nameof(BooleanMatchProperties))] + public void Null_only_boolean_match_is_not_an_effective_rule_criterion(string propertyName) + { + var rule = new JsonObject + { + ["Id"] = "test.rule", + ["Priority"] = 1, + ["Decision"] = "Allow", + ["Match"] = new JsonObject { [propertyName] = null }, + }; + Assert.Throws( + () => PolicySerializer.Deserialize(rule.ToJsonString())); + + rule["Match"] = new JsonObject + { + ["Operations"] = new JsonArray("Install"), + [propertyName] = null, + }; + Assert.NotNull(PolicySerializer.Deserialize(rule.ToJsonString())); + } + + [Fact] + public void Boolean_match_characteristics_round_trip_in_representative_mixed_match() + { + const string Json = """ + { + "Operations": ["Install"], + "Interactive": false, + "SkipHashCheck": true, + "HasCustomParameters": false, + "HasUninstallPrevious": true + } + """; + + var match = PolicySerializer.Deserialize(Json)!; + Assert.Equal([Operation.Install], match.Operations); + Assert.False(match.Interactive); + Assert.True(match.SkipHashCheck); + Assert.False(match.HasCustomParameters); + Assert.True(match.HasUninstallPrevious); + Assert.Null(match.PreRelease); + Assert.Null(match.HasCustomInstallLocation); + Assert.Null(match.HasPrePostCommands); + Assert.Null(match.HasKillBeforeOperation); + + var serialized = JsonNode.Parse(PolicySerializer.Serialize(match))!; + Assert.False(serialized["Interactive"]!.GetValue()); + Assert.True(serialized["SkipHashCheck"]!.GetValue()); + Assert.Null(serialized["PreRelease"]); + Assert.Null(serialized["HasCustomInstallLocation"]); + } + + [Theory] + [MemberData(nameof(CollectionMatchProperties))] + public void Collection_match_filters_accept_empty_input_and_canonicalize_to_omitted( + string propertyName, + string elementJson) + { + var omitted = PolicySerializer.Deserialize("{}")!; + Assert.Equal(0, GetCollectionMatchCount(omitted, propertyName)); + Assert.DoesNotContain($"\"{propertyName}\"", PolicySerializer.Serialize(omitted)); + + var empty = PolicySerializer.Deserialize( + $$"""{"{{propertyName}}":[]}""")!; + Assert.Equal(0, GetCollectionMatchCount(empty, propertyName)); + Assert.DoesNotContain($"\"{propertyName}\"", PolicySerializer.Serialize(empty)); + Assert.DoesNotContain( + $"\"{propertyName}\"", + JsonSerializer.Serialize(empty, PolicySerializer.Options), + StringComparison.Ordinal); + + var populatedJson = new JsonObject + { + [propertyName] = new JsonArray(JsonNode.Parse(elementJson)), + }; + if (propertyName == nameof(PolicyMatch.SourceNames)) + { + populatedJson[nameof(PolicyMatch.Managers)] = new JsonArray("Winget"); + } + var populated = PolicySerializer.Deserialize(populatedJson.ToJsonString())!; + Assert.Equal(1, GetCollectionMatchCount(populated, propertyName)); + var serialized = JsonNode.Parse(PolicySerializer.Serialize(populated))!; + Assert.Single(serialized[propertyName]!.AsArray()); + } + + [Theory] + [MemberData(nameof(CollectionMatchProperties))] + public void Empty_collection_only_match_is_not_an_effective_rule_criterion( + string propertyName, + string elementJson) + { + var emptyOnly = new JsonObject + { + ["Id"] = "test.rule", + ["Priority"] = 1, + ["Decision"] = "Allow", + ["Match"] = new JsonObject { [propertyName] = new JsonArray() }, + }; + Assert.Throws( + () => PolicySerializer.Deserialize(emptyOnly.ToJsonString())); + + emptyOnly["Match"] = new JsonObject + { + [propertyName] = new JsonArray(), + ["Interactive"] = false, + }; + var rule = PolicySerializer.Deserialize(emptyOnly.ToJsonString())!; + var serialized = JsonNode.Parse(PolicySerializer.Serialize(rule))!; + Assert.Null(serialized["Match"]![propertyName]); + Assert.False(serialized["Match"]!["Interactive"]!.GetValue()); + + emptyOnly["Match"] = new JsonObject + { + [propertyName] = new JsonArray(JsonNode.Parse(elementJson)), + }; + if (propertyName == nameof(PolicyMatch.SourceNames)) + { + emptyOnly["Match"]!["Managers"] = new JsonArray("Winget"); + } + Assert.NotNull(PolicySerializer.Deserialize(emptyOnly.ToJsonString())); + } + + [Theory] + [MemberData(nameof(CollectionMatchProperties))] + public void Collection_match_filters_reject_duplicate_values( + string propertyName, + string elementJson) + { + var match = new JsonObject + { + [propertyName] = new JsonArray( + JsonNode.Parse(elementJson), + JsonNode.Parse(elementJson)), + }; + if (propertyName == nameof(PolicyMatch.SourceNames)) + { + match[nameof(PolicyMatch.Managers)] = new JsonArray("Winget"); + } + + Assert.Throws( + () => PolicySerializer.Deserialize(match.ToJsonString())); } [Fact] - public void Direct_policy_match_and_rule_deserialization_reject_mixed_boolean_values() + public void Source_names_require_managers_and_preserve_exact_literal_names() { - const string MatchJson = """{"Interactive":[false,true]}"""; - const string RuleJson = - """{"Id":"test.rule","Priority":1,"Decision":"Allow","Match":{"Interactive":[false,true]}}"""; + const string WithoutManager = """ + { + "Id": "source.rule", + "Priority": 1, + "Decision": "Allow", + "Match": { "SourceNames": ["corp*"] } + } + """; + var exception = Assert.Throws( + () => PolicySerializer.Deserialize(WithoutManager)); + Assert.Contains("$.Match.SourceNames", exception.Message, StringComparison.Ordinal); + + const string WithManager = """ + { + "Id": "source.rule", + "Priority": 1, + "Decision": "Allow", + "Match": { + "Managers": ["Winget"], + "SourceNames": ["corp*", "PSGallery"] + } + } + """; + var rule = PolicySerializer.Deserialize(WithManager)!; + Assert.Equal([ManagerName.Winget], rule.Match.Managers); + Assert.Equal(["corp*", "PSGallery"], rule.Match.SourceNames); + + var serialized = JsonNode.Parse(PolicySerializer.Serialize(rule))!; + Assert.Equal("corp*", serialized["Match"]!["SourceNames"]![0]!.GetValue()); - Assert.Throws(() => PolicySerializer.DeserializeStrict(MatchJson)); - Assert.Throws(() => PolicySerializer.DeserializeStrict(RuleJson)); - Assert.NotNull(PolicySerializer.DeserializeStrict("""{"Interactive":[]}""")); + rule.Match.Managers.Clear(); + exception = Assert.Throws(() => PolicySerializer.Serialize(rule)); + Assert.Contains("$.Match.SourceNames", exception.Message, StringComparison.Ordinal); + + const string MultipleManagers = """ + { + "Id": "source.rule", + "Priority": 1, + "Decision": "Allow", + "Match": { + "Managers": ["Winget", "PowerShell"], + "SourceNames": ["corp"] + } + } + """; + exception = Assert.Throws( + () => PolicySerializer.Deserialize(MultipleManagers)); + Assert.Contains("$.Match.SourceNames", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Managers_enforce_schema_collection_bound_on_input_and_output() + { + var managerNames = Enum.GetNames(); + Assert.Equal(17, managerNames.Length); + + static string MatchJson(IEnumerable managers) => + new JsonObject + { + [nameof(PolicyMatch.Managers)] = new JsonArray( + managers.Select(name => JsonValue.Create(name)).ToArray()), + }.ToJsonString(); + + var maximum = PolicySerializer.Deserialize(MatchJson(managerNames.Take(16)))!; + Assert.Equal(16, maximum.Managers.Count); + Assert.NotEmpty(PolicySerializer.Serialize(maximum)); - var match = new PolicyMatch { Interactive = [false, true] }; - Assert.Throws(() => PolicySerializer.Serialize(match)); - Assert.Throws(() => JsonSerializer.Serialize(match, PolicySerializer.Options)); Assert.Throws( - () => JsonSerializer.Deserialize(MatchJson, PolicySerializer.Options)); + () => PolicySerializer.Deserialize(MatchJson(managerNames))); + + maximum.Managers.Add(ManagerName.Vcpkg); + Assert.Throws(() => PolicySerializer.Serialize(maximum)); + } + + [Fact] + public void Source_names_enforce_schema_collection_bound_on_input_and_output() + { + static string MatchJson(int count) => + new JsonObject + { + [nameof(PolicyMatch.Managers)] = new JsonArray("Winget"), + [nameof(PolicyMatch.SourceNames)] = new JsonArray( + Enumerable.Range(0, count).Select(index => JsonValue.Create($"source-{index}")).ToArray()), + }.ToJsonString(); + + var maximum = PolicySerializer.Deserialize(MatchJson(128))!; + Assert.Equal(128, maximum.SourceNames.Count); + Assert.NotEmpty(PolicySerializer.Serialize(maximum)); + Assert.Throws( - () => JsonSerializer.Deserialize(MatchJson, PolicySerializer.StrictOptions)); + () => PolicySerializer.Deserialize(MatchJson(129))); + + maximum.SourceNames.Add("source-128"); + Assert.Throws(() => PolicySerializer.Serialize(maximum)); + } + + [Fact] + public void Rust_schemas_require_exactly_one_manager_for_source_names() + { + foreach (var schemaPath in new[] { PolicySchema, PolicyDraftSchema }) + { + var schema = JsonNode.Parse(File.ReadAllText(schemaPath))!; + var match = schema["definitions"]!["PolicyRule"]!["properties"]!["Match"]!; + + Assert.Contains( + "SourceNames", + match["if"]!["required"]!.AsArray().Select(value => value!.GetValue())); + Assert.Contains( + "Managers", + match["then"]!["required"]!.AsArray().Select(value => value!.GetValue())); + Assert.Equal(1, match["then"]!["properties"]!["Managers"]!["minItems"]!.GetValue()); + Assert.Equal(1, match["then"]!["properties"]!["Managers"]!["maxItems"]!.GetValue()); + } + } + + [Fact] + public async Task Rust_schemas_document_the_runtime_validity_window_invariant() + { + foreach (var (schemaPath, metadataName) in new[] + { + (PolicySchema, nameof(PolicyMetadata)), + (PolicyDraftSchema, nameof(PolicyDraftMetadata)), + }) + { + var schema = JsonNode.Parse(File.ReadAllText(schemaPath))!; + var metadata = schema["definitions"]![metadataName]!; + Assert.False(metadata["additionalProperties"]!.GetValue()); + Assert.Contains( + "ValidFrom` must be strictly earlier", + metadata["description"]!.GetValue(), + StringComparison.Ordinal); + Assert.Contains( + "strictly later than `ValidFrom`", + metadata["properties"]!["ValidUntil"]!["description"]!.GetValue(), + StringComparison.Ordinal); + + var document = metadataName == nameof(PolicyMetadata) + ? JsonNode.Parse(PolicyDocument.Create("validity.schema", "Test").ToJson())! + : JsonNode.Parse(PolicyDraftDocument.Create("validity.schema", "Test").ToJson())!; + document["Metadata"]!["Unexpected"] = true; + if (metadataName == nameof(PolicyDraftMetadata)) + { + document["Metadata"]!["Revision"] = 1; + document["Metadata"]!["PublishedAt"] = "2026-01-01T00:00:00Z"; + } + var jsonSchema = await JsonSchema.FromFileAsync(schemaPath); + Assert.NotEmpty(jsonSchema.Validate(document.ToJsonString())); + } + } + + [Fact] + public void Package_identifier_condition_requires_exactly_one_nonempty_mode() + { + const string Exact = """{"Exact":["Microsoft.VisualStudioCode"]}"""; + var exact = PolicySerializer.Deserialize(Exact)!; + Assert.Equal(["Microsoft.VisualStudioCode"], exact.Exact); + Assert.Null(exact.Patterns); + + const string Patterns = """{"Patterns":["Microsoft.*"]}"""; + var patterns = PolicySerializer.Deserialize(Patterns)!; + Assert.Equal(["Microsoft.*"], patterns.Patterns); + Assert.Null(patterns.Exact); + + exact.UsePatterns(["Microsoft.*"]); + var switched = JsonNode.Parse(PolicySerializer.Serialize(exact))!; + Assert.Null(switched[nameof(PackageIdentifierCondition.Exact)]); + Assert.Equal("Microsoft.*", switched[nameof(PackageIdentifierCondition.Patterns)]![0]!.GetValue()); + exact.UseExact(["Microsoft.VisualStudioCode"]); + switched = JsonNode.Parse(PolicySerializer.Serialize(exact))!; + Assert.Null(switched[nameof(PackageIdentifierCondition.Patterns)]); + Assert.Equal( + "Microsoft.VisualStudioCode", + switched[nameof(PackageIdentifierCondition.Exact)]![0]!.GetValue()); + + foreach (var invalid in new[] + { + "{}", + """{"Exact":[]}""", + """{"Patterns":[]}""", + """{"Exact":["Microsoft.VisualStudioCode"],"Patterns":["Microsoft.*"]}""", + """{"Exact":["Microsoft.VisualStudioCode"],"Patterns":null}""", + """{"Patterns":null,"Exact":["Microsoft.VisualStudioCode"]}""", + """{"Patterns":["Microsoft.*"],"Exact":null}""", + """{"Exact":null,"Patterns":["Microsoft.*"]}""", + """{"Exact":["Microsoft.*"]}""", + """{"Exact":["Git.Git","Git.Git"]}""", + """{"Patterns":["Git.*","Git.*"]}""", + """{"Exact":["Microsoft.VisualStudioCode"],"\u0045xact":["Git.Git"]}""", + }) + { + Assert.Throws( + () => PolicySerializer.Deserialize(invalid)); + } + + const string OldFlatList = """{"PackageIdentifiers":["Microsoft.VisualStudioCode"]}"""; + Assert.Throws(() => PolicySerializer.Deserialize(OldFlatList)); + + var absent = PolicySerializer.Deserialize( + """{"PackageIdentifiers":null}""")!; + Assert.DoesNotContain("\"PackageIdentifiers\"", PolicySerializer.Serialize(absent)); + + var match = PolicySerializer.Deserialize( + """{"PackageIdentifiers":{"Patterns":["Microsoft.*"]}}""")!; + Assert.Equal(["Microsoft.*"], match.PackageIdentifiers!.Patterns); + Assert.Contains("\"Patterns\"", PolicySerializer.Serialize(match), StringComparison.Ordinal); + } + + [Fact] + public void Version_condition_requires_exactly_one_nonempty_mode() + { + const string Exact = """{"Exact":["5.6.0.0","2026.09-preview"]}"""; + var exact = PolicySerializer.Deserialize(Exact)!; + Assert.Equal(["5.6.0.0", "2026.09-preview"], exact.Exact); + Assert.Null(exact.Range); + + const string Range = """{"Range":{"MinVersion":"1.0.0","MaxVersion":"2.0.0"}}"""; + var range = PolicySerializer.Deserialize(Range)!; + Assert.Equal("1.0.0", range.Range!.MinVersion); + Assert.Null(range.Exact); + Assert.NotNull(PolicySerializer.Deserialize( + """{"Range":{"MinVersion":"1.0.0-beta.1","IncludePrerelease":true}}""")); + + exact.UseRange(new VersionRange { MinVersion = "1.0.0", MaxVersion = "2.0.0" }); + var switched = JsonNode.Parse(PolicySerializer.Serialize(exact))!; + Assert.Null(switched[nameof(VersionCondition.Exact)]); + Assert.Equal( + "1.0.0", + switched[nameof(VersionCondition.Range)]![nameof(VersionRange.MinVersion)]!.GetValue()); + exact.UseExact(["5.6.0.0", "2026.09-preview"]); + switched = JsonNode.Parse(PolicySerializer.Serialize(exact))!; + Assert.Null(switched[nameof(VersionCondition.Range)]); + Assert.Equal("5.6.0.0", switched[nameof(VersionCondition.Exact)]![0]!.GetValue()); + + foreach (var invalid in new[] + { + "{}", + """{"Exact":[]}""", + """{"Range":{}}""", + """{"Range":{"MinVersion":null,"MaxVersion":null}}""", + """{"Range":{"MinVersion":"not-semver"}}""", + """{"Range":{"MinVersion":"1.18446744073709551616.0"}}""", + """{"Range":{"MinVersion":"1.0.0-١a"}}""", + """{"Range":{"MaxVersion":"1.0.0\n"}}""", + """{"Exact":["1.0.0"],"Range":{"MinVersion":"1.0.0"}}""", + """{"Exact":["1.0.0"],"Range":null}""", + """{"Range":null,"Exact":["1.0.0"]}""", + """{"Range":{"MinVersion":"1.0.0"},"Exact":null}""", + """{"Exact":null,"Range":{"MinVersion":"1.0.0"}}""", + """{"Exact":["1.0.0","1.0.0"]}""", + """{"Exact":["1.0.0"],"\u0045xact":["2.0.0"]}""", + }) + { + Assert.Throws( + () => PolicySerializer.Deserialize(invalid)); + } + + foreach (var old in new[] + { + """{"Versions":["1.0.0"]}""", + """{"VersionRange":{"MinVersion":"1.0.0"}}""", + }) + { + Assert.Throws(() => PolicySerializer.Deserialize(old)); + } + + var absent = PolicySerializer.Deserialize("""{"Version":null}""")!; + Assert.DoesNotContain("\"Version\"", PolicySerializer.Serialize(absent)); + + Assert.Throws( + () => PolicySerializer.Serialize(new VersionRange())); + Assert.Throws( + () => PolicySerializer.Serialize(new VersionRange { MinVersion = "not-semver" })); + } + + [Fact] + public async Task Rust_schemas_enforce_package_identifier_and_version_modes() + { + var policy = PolicyDocument.ParseJson( + File.ReadAllText(Path.Combine(SamplesDir, "boolean-characteristics.policy.json"))); + var documents = new[] + { + ( + Document: JsonNode.Parse(policy.ToJson())!, + Schema: await JsonSchema.FromFileAsync(PolicySchema)), + ( + Document: JsonNode.Parse(policy.ToDraft().ToJson())!, + Schema: await JsonSchema.FromFileAsync(PolicyDraftSchema)), + }; + + foreach (var (document, schema) in documents) + { + var match = document["Rules"]![0]!["Match"]!; + foreach (var invalidIdentifiers in new JsonNode[] + { + new JsonArray("Microsoft.VisualStudioCode"), + new JsonObject(), + new JsonObject { ["Exact"] = new JsonArray() }, + new JsonObject { ["Exact"] = new JsonArray("Microsoft.*") }, + new JsonObject { ["Exact"] = new JsonArray("Git.Git\n") }, + new JsonObject + { + ["Exact"] = new JsonArray("Microsoft.VisualStudioCode"), + ["Patterns"] = new JsonArray("Microsoft.*"), + }, + }) + { + match["PackageIdentifiers"] = invalidIdentifiers.DeepClone(); + Assert.NotEmpty(schema.Validate(document.ToJsonString())); + } + + match["PackageIdentifiers"] = new JsonObject + { + ["Patterns"] = new JsonArray("Microsoft.*"), + }; + Assert.Empty(schema.Validate(document.ToJsonString())); + + foreach (var invalidVersion in new JsonNode[] + { + new JsonObject(), + new JsonObject { ["Exact"] = new JsonArray() }, + new JsonObject { ["Range"] = new JsonObject() }, + new JsonObject + { + ["Range"] = new JsonObject { ["MinVersion"] = "not-semver" }, + }, + new JsonObject + { + ["Range"] = new JsonObject + { + ["MinVersion"] = "1.18446744073709551616.0", + }, + }, + new JsonObject + { + ["Range"] = new JsonObject { ["MinVersion"] = "1.0.0-١a" }, + }, + new JsonObject + { + ["Exact"] = new JsonArray("1.0.0"), + ["Range"] = new JsonObject { ["MinVersion"] = "1.0.0" }, + }, + }) + { + match["Version"] = invalidVersion.DeepClone(); + Assert.NotEmpty(schema.Validate(document.ToJsonString())); + } + + match["Version"] = new JsonObject + { + ["Range"] = new JsonObject { ["MinVersion"] = "1.0.0" }, + }; + Assert.Empty(schema.Validate(document.ToJsonString())); + } + } + + [Fact] + public void Shared_boolean_characteristics_sample_has_expected_scalar_values() + { + var policy = PolicyDocument.ParseJson( + File.ReadAllText(Path.Combine(SamplesDir, "boolean-characteristics.policy.json"))); + var match = Assert.Single(policy.Rules).Match; + + Assert.False(match.Interactive); + Assert.True(match.SkipHashCheck); + Assert.False(match.PreRelease); + Assert.True(match.HasCustomParameters); + Assert.False(match.HasCustomInstallLocation); + Assert.True(match.HasPrePostCommands); + Assert.False(match.HasKillBeforeOperation); + Assert.True(match.HasUninstallPrevious); + Assert.Equal([ManagerName.Winget], match.Managers); + Assert.Equal(["corp*"], match.SourceNames); + Assert.Equal(["Microsoft.VisualStudioCode"], match.PackageIdentifiers!.Exact); + Assert.Equal(["5.6.0.0"], match.Version!.Exact); + Assert.Equal([Elevation.Elevated], match.ExecutionElevation); + } + + [Fact] + public void Constraints_are_valid_only_for_allow_rules() + { + const string AllowWithConstraints = """ + { + "Id": "allow.rule", + "Priority": 1, + "Decision": "Allow", + "Match": { "Operations": ["Install"] }, + "Constraints": { "AllowInteractive": false } + } + """; + var allow = PolicySerializer.Deserialize(AllowWithConstraints)!; + Assert.NotNull(allow.Constraints); + Assert.Contains("\"Constraints\"", PolicySerializer.Serialize(allow), StringComparison.Ordinal); + + const string AllowWithoutConstraints = """ + { + "Id": "allow.rule", + "Priority": 1, + "Decision": "Allow", + "Match": { "Operations": ["Install"] } + } + """; + Assert.NotNull(PolicySerializer.Deserialize(AllowWithoutConstraints)); + + const string DenyWithoutConstraints = """ + { + "Id": "deny.rule", + "Priority": 1, + "Decision": "Deny", + "Match": { "Operations": ["Install"] } + } + """; + Assert.NotNull(PolicySerializer.Deserialize(DenyWithoutConstraints)); + + const string DenyWithNullConstraints = """ + { + "Id": "deny.rule", + "Priority": 1, + "Decision": "Deny", + "Match": { "Operations": ["Install"] }, + "Constraints": null + } + """; + var deny = PolicySerializer.Deserialize(DenyWithNullConstraints)!; + Assert.DoesNotContain("\"Constraints\"", PolicySerializer.Serialize(deny), StringComparison.Ordinal); + + const string DenyWithConstraints = """ + { + "Id": "deny.rule", + "Enabled": false, + "Priority": 1, + "Decision": "Deny", + "Match": { "Operations": ["Install"] }, + "Constraints": { "AllowInteractive": false } + } + """; + var exception = Assert.Throws( + () => PolicySerializer.Deserialize(DenyWithConstraints)); + Assert.Contains("$.Constraints", exception.Message, StringComparison.Ordinal); + + allow.Decision = Decision.Deny; + exception = Assert.Throws(() => PolicySerializer.Serialize(allow)); + Assert.Contains("$.Constraints", exception.Message, StringComparison.Ordinal); + + exception = Assert.Throws( + () => JsonSerializer.Deserialize(DenyWithConstraints, PolicySerializer.Options)); + Assert.Contains("$.Constraints", exception.Message, StringComparison.Ordinal); + Assert.Throws(() => JsonSerializer.Serialize(allow, PolicySerializer.Options)); + } + + [Fact] + public async Task Rust_schemas_allow_constraints_only_for_allow_rules() + { + var policy = PolicyDocument.ParseJson( + File.ReadAllText(Path.Combine(SamplesDir, "boolean-characteristics.policy.json"))); + var documents = new[] + { + ( + Document: JsonNode.Parse(policy.ToJson())!, + Schema: await JsonSchema.FromFileAsync(PolicySchema)), + ( + Document: JsonNode.Parse(policy.ToDraft().ToJson())!, + Schema: await JsonSchema.FromFileAsync(PolicyDraftSchema)), + }; + + foreach (var (document, schema) in documents) + { + var rule = document["Rules"]!.AsArray() + .First(rule => rule!["Decision"]!.GetValue() == "Allow")!; + rule["Constraints"] = new JsonObject { ["AllowInteractive"] = false }; + Assert.Empty(schema.Validate(document.ToJsonString())); + + rule["Decision"] = "Deny"; + Assert.NotEmpty(schema.Validate(document.ToJsonString())); + + rule["Constraints"] = null; + Assert.Empty(schema.Validate(document.ToJsonString())); + + rule.AsObject().Remove("Constraints"); + Assert.Empty(schema.Validate(document.ToJsonString())); + } + } + + [Fact] + public void Rust_schemas_define_boolean_match_characteristics_as_nullable_scalars() + { + foreach (var schemaPath in new[] { PolicySchema, PolicyDraftSchema }) + { + var schema = JsonNode.Parse(File.ReadAllText(schemaPath))!; + var properties = schema["definitions"]!["PolicyRule"]!["properties"]!["Match"]!["properties"]!; + + foreach (var propertyName in new[] + { + nameof(PolicyMatch.Interactive), + nameof(PolicyMatch.SkipHashCheck), + nameof(PolicyMatch.PreRelease), + nameof(PolicyMatch.HasCustomParameters), + nameof(PolicyMatch.HasCustomInstallLocation), + nameof(PolicyMatch.HasPrePostCommands), + nameof(PolicyMatch.HasKillBeforeOperation), + nameof(PolicyMatch.HasUninstallPrevious), + }) + { + var property = properties[propertyName]!; + var types = property["type"]!.AsArray().Select(value => value!.GetValue()).ToList(); + + Assert.Contains("boolean", types); + Assert.Contains("null", types); + Assert.Null(property["items"]); + Assert.Null(property["maxItems"]); + Assert.Null(property["uniqueItems"]); + } + } + } + + [Theory] + [MemberData(nameof(BooleanMatchProperties))] + public async Task Rust_schemas_reject_legacy_boolean_match_arrays_and_wrong_types(string propertyName) + { + var committed = PolicyDocument.ParseJson( + File.ReadAllText(Path.Combine(SamplesDir, "boolean-characteristics.policy.json"))); + var documents = new[] + { + ( + Document: JsonNode.Parse(committed.ToJson())!, + Schema: await JsonSchema.FromFileAsync(PolicySchema)), + ( + Document: JsonNode.Parse(committed.ToDraft().ToJson())!, + Schema: await JsonSchema.FromFileAsync(PolicyDraftSchema)), + }; + + foreach (var invalidValue in new[] { "[]", "[false]", "[true]", "[false,true]", "\"true\"", "0", "{}" }) + { + foreach (var (document, schema) in documents) + { + document["Rules"]![0]!["Match"]![propertyName] = JsonNode.Parse(invalidValue); + Assert.NotEmpty(schema.Validate(document.ToJsonString())); + } + } + } + + [Theory] + [MemberData(nameof(BooleanMatchProperties))] + public async Task Rust_schemas_require_an_effective_rule_criterion_when_boolean_is_null(string propertyName) + { + var committed = PolicyDocument.ParseJson( + File.ReadAllText(Path.Combine(SamplesDir, "boolean-characteristics.policy.json"))); + var documents = new[] + { + ( + Document: JsonNode.Parse(committed.ToJson())!, + Schema: await JsonSchema.FromFileAsync(PolicySchema)), + ( + Document: JsonNode.Parse(committed.ToDraft().ToJson())!, + Schema: await JsonSchema.FromFileAsync(PolicyDraftSchema)), + }; + + foreach (var (document, schema) in documents) + { + document["Rules"]![0]!["Match"] = new JsonObject { [propertyName] = null }; + Assert.NotEmpty(schema.Validate(document.ToJsonString())); + + document["Rules"]![0]!["Match"] = new JsonObject + { + ["Operations"] = new JsonArray("Install"), + [propertyName] = null, + }; + Assert.Empty(schema.Validate(document.ToJsonString())); + } } [Theory] [InlineData("StringPattern", 256)] + [InlineData("SourceName", 128)] [InlineData("VersionString", 128)] [InlineData("CustomParameterString", 512)] public void Policy_text_lists_count_unicode_scalars_at_length_boundaries(string valueKind, int maximum) { var document = JsonNode.Parse( File.ReadAllText(Path.Combine(SamplesDir, "corporate-allowlist.policy.json")))!; - var rule = document["Rules"]![0]!; + var rule = document["Rules"]!.AsArray() + .First(rule => rule!["Decision"]!.GetValue() == "Allow")!; var values = new JsonArray(); switch (valueKind) { case "StringPattern": - rule["Match"]!["PackageNames"] = values; + rule["Match"]!["PackageIdentifiers"] = new JsonObject { ["Patterns"] = values }; + break; + case "SourceName": + rule["Match"]!["SourceNames"] = values; break; case "VersionString": - rule["Match"]!["Versions"] = values; + rule["Match"]!["Version"] = new JsonObject { ["Exact"] = values }; break; default: var constraints = rule["Constraints"] as JsonObject ?? new JsonObject(); @@ -391,14 +1610,12 @@ public void Direct_policy_constraints_reject_invalid_bounded_strings(string coll var json = new JsonObject { [collectionName] = new JsonArray(value) }.ToJsonString(); Assert.Throws(() => PolicySerializer.Serialize(constraints)); - Assert.Throws(() => PolicySerializer.DeserializeStrict(json)); + Assert.Throws(() => PolicySerializer.Deserialize(json)); - foreach (var options in new[] { PolicySerializer.Options, PolicySerializer.StrictOptions }) - { - Assert.Throws(() => JsonSerializer.Serialize(constraints, options)); - Assert.Throws( - () => JsonSerializer.Deserialize(json, options)); - } + Assert.Throws( + () => JsonSerializer.Serialize(constraints, PolicySerializer.Options)); + Assert.Throws( + () => JsonSerializer.Deserialize(json, PolicySerializer.Options)); } } @@ -411,13 +1628,10 @@ public void Direct_policy_constraints_accept_valid_boundary_strings(string colle var json = new JsonObject { [collectionName] = new JsonArray(value) }.ToJsonString(); Assert.NotNull(PolicySerializer.Serialize(constraints)); - Assert.NotNull(PolicySerializer.DeserializeStrict(json)); + Assert.NotNull(PolicySerializer.Deserialize(json)); - foreach (var options in new[] { PolicySerializer.Options, PolicySerializer.StrictOptions }) - { - Assert.NotNull(JsonSerializer.Serialize(constraints, options)); - Assert.NotNull(JsonSerializer.Deserialize(json, options)); - } + Assert.NotNull(JsonSerializer.Serialize(constraints, PolicySerializer.Options)); + Assert.NotNull(JsonSerializer.Deserialize(json, PolicySerializer.Options)); } [Fact] @@ -461,6 +1675,32 @@ private static PolicyConstraints CreateConstraints(string collectionName, string return constraints; } + private static bool? GetBooleanMatch(PolicyMatch match, string propertyName) => + propertyName switch + { + nameof(PolicyMatch.Interactive) => match.Interactive, + nameof(PolicyMatch.SkipHashCheck) => match.SkipHashCheck, + nameof(PolicyMatch.PreRelease) => match.PreRelease, + nameof(PolicyMatch.HasCustomParameters) => match.HasCustomParameters, + nameof(PolicyMatch.HasCustomInstallLocation) => match.HasCustomInstallLocation, + nameof(PolicyMatch.HasPrePostCommands) => match.HasPrePostCommands, + nameof(PolicyMatch.HasKillBeforeOperation) => match.HasKillBeforeOperation, + nameof(PolicyMatch.HasUninstallPrevious) => match.HasUninstallPrevious, + _ => throw new ArgumentOutOfRangeException(nameof(propertyName), propertyName, null), + }; + + private static int GetCollectionMatchCount(PolicyMatch match, string propertyName) => + propertyName switch + { + nameof(PolicyMatch.Operations) => match.Operations.Count, + nameof(PolicyMatch.Managers) => match.Managers.Count, + nameof(PolicyMatch.SourceNames) => match.SourceNames.Count, + nameof(PolicyMatch.Scopes) => match.Scopes.Count, + nameof(PolicyMatch.Architectures) => match.Architectures.Count, + nameof(PolicyMatch.ExecutionElevation) => match.ExecutionElevation.Count, + _ => throw new ArgumentOutOfRangeException(nameof(propertyName), propertyName, null), + }; + private static string ResolvePolicyCrateRoot([CallerFilePath] string thisFile = "") { var testsDir = Path.GetDirectoryName(thisFile)!; @@ -472,7 +1712,6 @@ private static string MinimalPolicyJson(string revision, string rules) return $$""" { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "test.policy", "Publisher": "Test", @@ -480,8 +1719,7 @@ private static string MinimalPolicyJson(string revision, string rules) "PublishedAt": "2026-01-01T00:00:00Z" }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "DefaultDecision": "Deny" }, {{rules}} } diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs b/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs index 8b4655f..4bb815e 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs @@ -82,7 +82,7 @@ public enum Architecture Neutral, } -/// Requested elevation level. +/// Effective package-operation execution privilege. [JsonConverter(typeof(ExactCaseStringEnumConverter))] public enum Elevation { @@ -96,11 +96,4 @@ public enum Decision { Allow, Deny, -} - -/// Rule precedence strategy. -[JsonConverter(typeof(ExactCaseStringEnumConverter))] -public enum RulePrecedence -{ - PriorityThenDeny, } \ No newline at end of file diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJsonInput.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJsonInput.cs new file mode 100644 index 0000000..4ba3be2 --- /dev/null +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJsonInput.cs @@ -0,0 +1,158 @@ +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Devolutions.Now.Policy.Model; + +internal static class PolicyJsonInput +{ + internal static void RejectDuplicatePropertyNames(string json, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(json); + + var byteCount = Encoding.UTF8.GetByteCount(json); + var buffer = ArrayPool.Shared.Rent(byteCount); + try + { + var bytesWritten = Encoding.UTF8.GetBytes(json.AsSpan(), buffer); + var reader = new Utf8JsonReader( + buffer.AsSpan(0, bytesWritten), + new JsonReaderOptions + { + AllowTrailingCommas = options.AllowTrailingCommas, + CommentHandling = options.ReadCommentHandling, + MaxDepth = options.MaxDepth, + }); + + if (!reader.Read()) + { + throw new JsonException("The JSON input is empty."); + } + + RejectDuplicatePropertyNames(ref reader); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + internal static void RejectDuplicatePropertyNames(ref Utf8JsonReader reader) + { + var scanner = reader; + var rootToken = scanner.TokenType; + var rootDepth = scanner.CurrentDepth; + var objectProperties = new Stack>(); + + ProcessToken(ref scanner, objectProperties); + if (rootToken is not (JsonTokenType.StartObject or JsonTokenType.StartArray)) + { + return; + } + + while (scanner.Read()) + { + ProcessToken(ref scanner, objectProperties); + if (scanner.CurrentDepth == rootDepth + && ((rootToken == JsonTokenType.StartObject && scanner.TokenType == JsonTokenType.EndObject) + || (rootToken == JsonTokenType.StartArray && scanner.TokenType == JsonTokenType.EndArray))) + { + return; + } + } + + throw new JsonException("The JSON input ended before the current value was complete."); + } + + private static void ProcessToken( + ref Utf8JsonReader reader, + Stack> objectProperties) + { + switch (reader.TokenType) + { + case JsonTokenType.StartObject: + objectProperties.Push(new HashSet(StringComparer.Ordinal)); + break; + case JsonTokenType.EndObject: + objectProperties.Pop(); + break; + case JsonTokenType.PropertyName: + string propertyName; + try + { + propertyName = reader.GetString() + ?? throw new JsonException("A JSON property name must not be null."); + } + catch (InvalidOperationException exception) + { + throw new JsonException( + "A JSON property name contains an invalid Unicode escape sequence.", + exception); + } + + if (!objectProperties.Peek().Add(propertyName)) + { + throw new JsonException( + $"Duplicate JSON property name '{propertyName}' is not allowed; property names are compared using ordinal, case-sensitive equality."); + } + break; + } + } +} + +internal interface IDuplicatePropertyNameRejectingConverter; + +internal sealed class DuplicatePropertyNameRejectingConverter( + JsonTypeInfo fallbackTypeInfo, + Action? validate = null) + : JsonConverter, IDuplicatePropertyNameRejectingConverter +{ + private readonly ConditionalWeakTable> _effectiveTypeInfos = new(); + + public override T? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + PolicyJsonInput.RejectDuplicatePropertyNames(ref reader); + var value = JsonSerializer.Deserialize(ref reader, EffectiveTypeInfo(options)); + validate?.Invoke(value); + return value; + } + + public override void Write( + Utf8JsonWriter writer, + T value, + JsonSerializerOptions options) + { + validate?.Invoke(value); + JsonSerializer.Serialize(writer, value, EffectiveTypeInfo(options)); + } + + private JsonTypeInfo EffectiveTypeInfo(JsonSerializerOptions options) + { + return _effectiveTypeInfos.GetValue(options, CreateEffectiveTypeInfo); + } + + private JsonTypeInfo CreateEffectiveTypeInfo(JsonSerializerOptions options) + { + if (options.TypeInfoResolver is null) + { + return fallbackTypeInfo; + } + + var innerOptions = new JsonSerializerOptions(options); + for (var index = innerOptions.Converters.Count - 1; index >= 0; index--) + { + if (innerOptions.Converters[index] is IDuplicatePropertyNameRejectingConverter) + { + innerOptions.Converters.RemoveAt(index); + } + } + + return (JsonTypeInfo)innerOptions.GetTypeInfo(typeof(T)); + } +} \ No newline at end of file diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs index 09004fb..c8114c2 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs @@ -103,10 +103,6 @@ public sealed class PolicyDocument [JsonRequired] public PolicyFormatVersion PolicyFormatVersion { get; init; } = PolicyFormatVersion.Current; - [JsonPropertyName("PolicyType")] - [JsonRequired] - public string PolicyType { get; set; } = "PackageBrokerPolicy"; - [JsonPropertyName("Metadata")] [JsonRequired] public PolicyMetadata Metadata { get; set; } = new(); @@ -133,23 +129,22 @@ public static PolicyDocument Create(string id, string publisher, Decision defaul Enforcement = new PolicyEnforcement { DefaultDecision = defaultDecision, - RulePrecedence = RulePrecedence.PriorityThenDeny, }, }; } public static PolicyDocument ParseJson(string json) { - return PolicySerializer.DeserializePolicyDocumentStrict(json) + return PolicySerializer.Deserialize(json) ?? throw new JsonException("policy document was null"); } public PolicyDraftDocument ToDraft() { + PolicySerializer.ValidateRequiredCollectionElements(this); return new PolicyDraftDocument { PolicyFormatVersion = PolicyFormatVersion, - PolicyType = PolicyType, Metadata = PolicyModelClone.ToDraftMetadata(Metadata), Enforcement = PolicyModelClone.Enforcement(Enforcement), Rules = PolicyModelClone.Rules(Rules), @@ -172,10 +167,6 @@ public sealed class PolicyDraftDocument [JsonRequired] public PolicyFormatVersion PolicyFormatVersion { get; init; } = PolicyFormatVersion.Current; - [JsonPropertyName("PolicyType")] - [JsonRequired] - public string PolicyType { get; set; } = "PackageBrokerPolicy"; - [JsonPropertyName("Metadata")] [JsonRequired] public PolicyDraftMetadata Metadata { get; set; } = new(); @@ -203,14 +194,13 @@ public static PolicyDraftDocument Create( Enforcement = new PolicyEnforcement { DefaultDecision = defaultDecision, - RulePrecedence = RulePrecedence.PriorityThenDeny, }, }; } public static PolicyDraftDocument ParseJson(string json) { - return PolicySerializer.DeserializePolicyDraftDocumentStrict(json) + return PolicySerializer.Deserialize(json) ?? throw new JsonException("policy draft document was null"); } @@ -223,10 +213,10 @@ public PolicyDocument ToPolicyDocument(uint revision, DateTimeOffset publishedAt $"Policy revisions must be between 1 and {MaxRevision}."); } + PolicySerializer.ValidateRequiredCollectionElements(this); return new PolicyDocument { PolicyFormatVersion = PolicyFormatVersion, - PolicyType = PolicyType, Metadata = PolicyModelClone.ToCommittedMetadata(Metadata, revision, publishedAt), Enforcement = PolicyModelClone.Enforcement(Enforcement), Rules = PolicyModelClone.Rules(Rules), @@ -254,9 +244,17 @@ public sealed class PolicyMetadata [JsonRequired] public DateTimeOffset PublishedAt { get; set; } + /// + /// Earliest instant when the policy is active. When both bounds are present, this must be + /// strictly earlier than . + /// [JsonPropertyName("ValidFrom")] public DateTimeOffset? ValidFrom { get; set; } + /// + /// Instant after which the policy is inactive. When both bounds are present, this must be + /// strictly later than . + /// [JsonPropertyName("ValidUntil")] public DateTimeOffset? ValidUntil { get; set; } @@ -277,9 +275,17 @@ public sealed class PolicyDraftMetadata [JsonRequired] public string Publisher { get; set; } = ""; + /// + /// Earliest instant when the policy is active. When both bounds are present, this must be + /// strictly earlier than . + /// [JsonPropertyName("ValidFrom")] public DateTimeOffset? ValidFrom { get; set; } + /// + /// Instant after which the policy is inactive. When both bounds are present, this must be + /// strictly later than . + /// [JsonPropertyName("ValidUntil")] public DateTimeOffset? ValidUntil { get; set; } @@ -290,16 +296,16 @@ public sealed class PolicyDraftMetadata public string? SupportUrl { get; set; } } +/// +/// Enforcement configuration. Matching rules are evaluated by ascending priority. Deny wins +/// equal-priority Allow/Deny ties; remaining equal-priority ties retain document order. +/// public sealed class PolicyEnforcement { [JsonPropertyName("DefaultDecision")] [JsonRequired] public Decision DefaultDecision { get; set; } - [JsonPropertyName("RulePrecedence")] - [JsonRequired] - public RulePrecedence RulePrecedence { get; set; } - [JsonPropertyName("AuditMode")] public bool? AuditMode { get; set; } } @@ -328,65 +334,241 @@ public sealed class PolicyRule [JsonRequired] public PolicyMatch Match { get; set; } = new(); + /// Additional safety limits for an Allow rule. Invalid on Deny rules. [JsonPropertyName("Constraints")] public PolicyConstraints? Constraints { get; set; } } +/// +/// Conditions that must all match a request. At least one effective non-null, nonempty condition +/// is required when used by a . +/// public sealed class PolicyMatch { + /// + /// Optional operation filter. Omitted/empty does not narrow matching; canonical output omits empty. + /// [JsonPropertyName("Operations")] public List Operations { get; set; } = []; + /// + /// Optional manager filter. Omitted/empty does not narrow matching; canonical output omits empty. + /// [JsonPropertyName("Managers")] public List Managers { get; set; } = []; - [JsonPropertyName("Sources")] - public List Sources { get; set; } = []; + /// + /// Exact configured package source names. Comparison follows the selected package manager's + /// source-name semantics; wildcard characters are literal. Nonempty source names require + /// exactly one manager. Omitted/empty does not narrow matching; canonical output omits empty. + /// + [JsonPropertyName("SourceNames")] + public List SourceNames { get; set; } = []; + /// + /// Optional package-identifier condition. Exact uses validated stable identifiers; Patterns + /// explicitly uses wildcard patterns that may authorize multiple packages. Null does not + /// narrow matching. + /// [JsonPropertyName("PackageIdentifiers")] - public List PackageIdentifiers { get; set; } = []; - - [JsonPropertyName("PackageNames")] - public List PackageNames { get; set; } = []; - - [JsonPropertyName("Versions")] - public List Versions { get; set; } = []; + public PackageIdentifierCondition? PackageIdentifiers { get; set; } - [JsonPropertyName("VersionRange")] - public VersionRange? VersionRange { get; set; } + /// + /// Optional package-version condition. Exact supports arbitrary package version strings; + /// Range applies only to semantic versions. Null does not narrow matching. + /// + [JsonPropertyName("Version")] + public VersionCondition? Version { get; set; } + /// + /// Optional scope filter. Omitted/empty does not narrow matching; canonical output omits empty. + /// [JsonPropertyName("Scopes")] public List Scopes { get; set; } = []; + /// + /// Optional architecture filter. Omitted/empty does not narrow matching; canonical output omits empty. + /// [JsonPropertyName("Architectures")] public List Architectures { get; set; } = []; - [JsonPropertyName("Elevation")] - public List Elevation { get; set; } = []; + /// + /// Optional effective execution-elevation filter. Elevated means the package operation will run + /// with administrator privileges; Standard means it will not. Omitted/empty does not narrow + /// matching; canonical output omits empty. + /// + [JsonPropertyName("ExecutionElevation")] + public List ExecutionElevation { get; set; } = []; + /// + /// Optional condition on the request's interactive characteristic. + /// Null means this characteristic does not affect matching. + /// [JsonPropertyName("Interactive")] - public List Interactive { get; set; } = []; + public bool? Interactive { get; set; } + /// + /// Optional condition on the request's skip-hash-check characteristic. + /// Null means this characteristic does not affect matching. + /// [JsonPropertyName("SkipHashCheck")] - public List SkipHashCheck { get; set; } = []; + public bool? SkipHashCheck { get; set; } + /// + /// Optional condition on the request's pre-release characteristic. + /// Null means this characteristic does not affect matching. + /// [JsonPropertyName("PreRelease")] - public List PreRelease { get; set; } = []; + public bool? PreRelease { get; set; } + /// + /// Optional condition on whether the request has custom parameters. + /// Null means this characteristic does not affect matching. + /// [JsonPropertyName("HasCustomParameters")] - public List HasCustomParameters { get; set; } = []; + public bool? HasCustomParameters { get; set; } + /// + /// Optional condition on whether the request has a custom install location. + /// Null means this characteristic does not affect matching. + /// [JsonPropertyName("HasCustomInstallLocation")] - public List HasCustomInstallLocation { get; set; } = []; + public bool? HasCustomInstallLocation { get; set; } + /// + /// Optional condition on whether the request has pre/post commands. + /// Null means this characteristic does not affect matching. + /// [JsonPropertyName("HasPrePostCommands")] - public List HasPrePostCommands { get; set; } = []; + public bool? HasPrePostCommands { get; set; } + /// + /// Optional condition on whether the request has kill-before-operation entries. + /// Null means this characteristic does not affect matching. + /// [JsonPropertyName("HasKillBeforeOperation")] - public List HasKillBeforeOperation { get; set; } = []; + public bool? HasKillBeforeOperation { get; set; } + /// + /// Optional condition on whether the request enables uninstall-previous. + /// Null means this characteristic does not affect matching. + /// [JsonPropertyName("HasUninstallPrevious")] - public List HasUninstallPrevious { get; set; } = []; + public bool? HasUninstallPrevious { get; set; } +} + +/// Exactly one package-version matching mode. +public sealed class VersionCondition +{ + private List? _exact; + private VersionRange? _range; + + /// One or more exact package version strings, including non-SemVer values. + [JsonPropertyName("Exact")] + public List? Exact + { + get => _exact; + set + { + ExactSpecified = true; + _exact = value; + } + } + + /// A semantic-version range. + [JsonPropertyName("Range")] + public VersionRange? Range + { + get => _range; + set + { + RangeSpecified = true; + _range = value; + } + } + + /// Selects exact-version matching and clears the range mode. + public void UseExact(List exact) + { + ArgumentNullException.ThrowIfNull(exact); + ExactSpecified = true; + _exact = exact; + RangeSpecified = false; + _range = null; + } + + /// Selects semantic-range matching and clears the exact mode. + public void UseRange(VersionRange range) + { + ArgumentNullException.ThrowIfNull(range); + ExactSpecified = false; + _exact = null; + RangeSpecified = true; + _range = range; + } + + [JsonIgnore] + internal bool ExactSpecified { get; private set; } + + [JsonIgnore] + internal bool RangeSpecified { get; private set; } +} + +/// Exactly one package-identifier matching mode. +public sealed class PackageIdentifierCondition +{ + private List? _exact; + private List? _patterns; + + /// One or more validated stable package identifiers. + [JsonPropertyName("Exact")] + public List? Exact + { + get => _exact; + set + { + ExactSpecified = true; + _exact = value; + } + } + + /// Explicit wildcard patterns that may authorize multiple package identifiers. + [JsonPropertyName("Patterns")] + public List? Patterns + { + get => _patterns; + set + { + PatternsSpecified = true; + _patterns = value; + } + } + + /// Selects exact-identifier matching and clears the pattern mode. + public void UseExact(List exact) + { + ArgumentNullException.ThrowIfNull(exact); + ExactSpecified = true; + _exact = exact; + PatternsSpecified = false; + _patterns = null; + } + + /// Selects pattern matching and clears the exact mode. + public void UsePatterns(List patterns) + { + ArgumentNullException.ThrowIfNull(patterns); + ExactSpecified = false; + _exact = null; + PatternsSpecified = true; + _patterns = patterns; + } + + [JsonIgnore] + internal bool ExactSpecified { get; private set; } + + [JsonIgnore] + internal bool PatternsSpecified { get; private set; } } public sealed class VersionRange @@ -401,6 +583,7 @@ public sealed class VersionRange public bool IncludePrerelease { get; set; } } +/// Additional safety limits applied after an Allow rule matches. public sealed class PolicyConstraints { [JsonPropertyName("AllowInteractive")] @@ -473,7 +656,6 @@ internal static PolicyMetadata ToCommittedMetadata( internal static PolicyEnforcement Enforcement(PolicyEnforcement value) => new() { DefaultDecision = value.DefaultDecision, - RulePrecedence = value.RulePrecedence, AuditMode = value.AuditMode, }; @@ -494,31 +676,56 @@ internal static PolicyMetadata ToCommittedMetadata( { Operations = [.. value.Operations], Managers = [.. value.Managers], - Sources = [.. value.Sources], - PackageIdentifiers = [.. value.PackageIdentifiers], - PackageNames = [.. value.PackageNames], - Versions = [.. value.Versions], - VersionRange = value.VersionRange is null - ? null - : new VersionRange - { - MinVersion = value.VersionRange.MinVersion, - MaxVersion = value.VersionRange.MaxVersion, - IncludePrerelease = value.VersionRange.IncludePrerelease, - }, + SourceNames = [.. value.SourceNames], + PackageIdentifiers = PackageIdentifiers(value.PackageIdentifiers), + Version = Version(value.Version), Scopes = [.. value.Scopes], Architectures = [.. value.Architectures], - Elevation = [.. value.Elevation], - Interactive = [.. value.Interactive], - SkipHashCheck = [.. value.SkipHashCheck], - PreRelease = [.. value.PreRelease], - HasCustomParameters = [.. value.HasCustomParameters], - HasCustomInstallLocation = [.. value.HasCustomInstallLocation], - HasPrePostCommands = [.. value.HasPrePostCommands], - HasKillBeforeOperation = [.. value.HasKillBeforeOperation], - HasUninstallPrevious = [.. value.HasUninstallPrevious], + ExecutionElevation = [.. value.ExecutionElevation], + Interactive = value.Interactive, + SkipHashCheck = value.SkipHashCheck, + PreRelease = value.PreRelease, + HasCustomParameters = value.HasCustomParameters, + HasCustomInstallLocation = value.HasCustomInstallLocation, + HasPrePostCommands = value.HasPrePostCommands, + HasKillBeforeOperation = value.HasKillBeforeOperation, + HasUninstallPrevious = value.HasUninstallPrevious, }; + private static PackageIdentifierCondition? PackageIdentifiers(PackageIdentifierCondition? value) + { + if (value?.Exact is { } exact) + { + return new PackageIdentifierCondition { Exact = [.. exact] }; + } + if (value?.Patterns is { } patterns) + { + return new PackageIdentifierCondition { Patterns = [.. patterns] }; + } + return null; + } + + private static VersionCondition? Version(VersionCondition? value) + { + if (value?.Exact is { } exact) + { + return new VersionCondition { Exact = [.. exact] }; + } + if (value?.Range is { } range) + { + return new VersionCondition + { + Range = new VersionRange + { + MinVersion = range.MinVersion, + MaxVersion = range.MaxVersion, + IncludePrerelease = range.IncludePrerelease, + }, + }; + } + return null; + } + private static PolicyConstraints Constraints(PolicyConstraints value) => new() { AllowInteractive = value.AllowInteractive, diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs index 7ad7f7c..627b503 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs @@ -1,51 +1,53 @@ +using System.Globalization; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; +using System.Text.RegularExpressions; namespace Devolutions.Now.Policy.Model; -public static class PolicySerializer +public static partial class PolicySerializer { - public static readonly JsonSerializerOptions Options = CreateOptions(PolicySerializerContext.Default); + private const int MaxManagers = 16; + private const int MaxSourceNames = 128; - public static readonly JsonSerializerOptions StrictOptions = CreateOptions(PolicyStrictSerializerContext.Default); + /// + /// Source-generated policy JSON options. Deserialization rejects unknown and duplicate + /// property names throughout the input using ordinal, case-sensitive name comparison. + /// + public static readonly JsonSerializerOptions Options = CreateOptions(); public static string Serialize(PolicyDocument value) { ValidateRequiredCollectionElements(value); - return JsonSerializer.Serialize(value, PolicySerializerContext.Default.PolicyDocument); + return JsonSerializer.Serialize(value, TypeInfo()); } public static string Serialize(PolicyDraftDocument value) { ValidateRequiredCollectionElements(value); - return JsonSerializer.Serialize(value, PolicySerializerContext.Default.PolicyDraftDocument); + return JsonSerializer.Serialize(value, TypeInfo()); } - public static PolicyDocument? DeserializePolicyDocument(string json) => - Validate(JsonSerializer.Deserialize(json, PolicySerializerContext.Default.PolicyDocument)); - - public static PolicyDocument? DeserializePolicyDocumentStrict(string json) => - Validate(JsonSerializer.Deserialize(json, PolicyStrictSerializerContext.Default.PolicyDocument)); - - public static PolicyDraftDocument? DeserializePolicyDraftDocumentStrict(string json) => - Validate(JsonSerializer.Deserialize(json, PolicyStrictSerializerContext.Default.PolicyDraftDocument)); - public static string Serialize(T value) { ValidateSemanticValue(value); return JsonSerializer.Serialize(value, TypeInfo()); } - public static T? DeserializeStrict(string json) + /// + /// Deserializes a policy model using the closed, strict contract. + /// + public static T? Deserialize(string json) { - var value = JsonSerializer.Deserialize(json, StrictTypeInfo()); + PolicyJsonInput.RejectDuplicatePropertyNames(json, PolicySerializerContext.Default.Options); + var value = JsonSerializer.Deserialize(json, DeserializationTypeInfo()); ValidateSemanticValue(value); return value; } - private static void ValidateSemanticValue(object? value) + internal static void ValidateSemanticValue(object? value) { switch (value) { @@ -54,6 +56,10 @@ private static void ValidateSemanticValue(object? value) break; case PolicyMetadata metadata: ValidatePolicyRevision(metadata.Revision); + ValidateValidityWindow(metadata.ValidFrom, metadata.ValidUntil, "$"); + break; + case PolicyDraftMetadata metadata: + ValidateValidityWindow(metadata.ValidFrom, metadata.ValidUntil, "$"); break; case PolicyDraftDocument draft: ValidateRequiredCollectionElements(draft); @@ -64,37 +70,68 @@ private static void ValidateSemanticValue(object? value) case PolicyMatch match: ValidateRequiredCollectionElements(match, "$"); break; + case PackageIdentifierCondition identifiers: + ValidatePackageIdentifierCondition(identifiers, "$"); + break; + case VersionCondition version: + ValidateVersionCondition(version, "$"); + break; + case VersionRange range: + ValidateVersionRange(range, "$"); + break; case PolicyConstraints constraints: ValidateRequiredCollectionElements(constraints, "$"); break; } } - internal static void ValidateRequiredCollectionElements(PolicyDocument policy) + internal static void ValidateRequiredCollectionElements( + PolicyDocument policy, + string path = "$") { ValidatePolicyRevision(policy.Metadata.Revision); - ValidateRequiredCollectionElements(policy.Rules); + ValidateValidityWindow( + policy.Metadata.ValidFrom, + policy.Metadata.ValidUntil, + $"{path}.Metadata"); + ValidateRequiredCollectionElements(policy.Rules, $"{path}.Rules"); } - internal static void ValidateRequiredCollectionElements(PolicyDraftDocument policy) + internal static void ValidateRequiredCollectionElements( + PolicyDraftDocument policy, + string path = "$") { - ValidateRequiredCollectionElements(policy.Rules); + ValidateValidityWindow( + policy.Metadata.ValidFrom, + policy.Metadata.ValidUntil, + $"{path}.Metadata"); + ValidateRequiredCollectionElements(policy.Rules, $"{path}.Rules"); } - private static void ValidateRequiredCollectionElements(IReadOnlyList rules) + private static void ValidateRequiredCollectionElements( + IReadOnlyList rules, + string path) { - RejectNullElements(rules, "$.Rules"); + RejectNullElements(rules, path); for (var ruleIndex = 0; ruleIndex < rules.Count; ruleIndex++) { - ValidateRequiredCollectionElements(rules[ruleIndex], $"$.Rules[{ruleIndex}]"); + ValidateRequiredCollectionElements(rules[ruleIndex], $"{path}[{ruleIndex}]"); } } private static void ValidateRequiredCollectionElements(PolicyRule rule, string path) { ValidateRequiredCollectionElements(rule.Match, $"{path}.Match"); - + if (IsEmpty(rule.Match)) + { + throw new JsonException($"The JSON object at {path}.Match must contain at least one effective criterion."); + } + if (rule.Decision == Decision.Deny && rule.Constraints is not null) + { + throw new JsonException( + $"The JSON value at {path}.Constraints is valid only when {path}.Decision is Allow."); + } if (rule.Constraints is { } constraints) { ValidateRequiredCollectionElements(constraints, $"{path}.Constraints"); @@ -127,20 +164,173 @@ private static void ValidateRequiredCollectionElements(PolicyConstraints constra private static void ValidateRequiredCollectionElements(PolicyMatch match, string path) { - RejectBoundedStrings(match.Sources, 1, 256, $"{path}.Sources"); - RejectBoundedStrings(match.PackageIdentifiers, 1, 256, $"{path}.PackageIdentifiers"); - RejectBoundedStrings(match.PackageNames, 1, 256, $"{path}.PackageNames"); - RejectBoundedStrings(match.Versions, 1, 128, $"{path}.Versions"); - RejectBooleanMatch(match.Interactive, $"{path}.Interactive"); - RejectBooleanMatch(match.SkipHashCheck, $"{path}.SkipHashCheck"); - RejectBooleanMatch(match.PreRelease, $"{path}.PreRelease"); - RejectBooleanMatch(match.HasCustomParameters, $"{path}.HasCustomParameters"); - RejectBooleanMatch(match.HasCustomInstallLocation, $"{path}.HasCustomInstallLocation"); - RejectBooleanMatch(match.HasPrePostCommands, $"{path}.HasPrePostCommands"); - RejectBooleanMatch(match.HasKillBeforeOperation, $"{path}.HasKillBeforeOperation"); - RejectBooleanMatch(match.HasUninstallPrevious, $"{path}.HasUninstallPrevious"); + RejectDuplicateElements(match.Operations, $"{path}.Operations"); + RejectDuplicateElements(match.Managers, $"{path}.Managers"); + RejectDuplicateElements(match.SourceNames, $"{path}.SourceNames"); + RejectDuplicateElements(match.Scopes, $"{path}.Scopes"); + RejectDuplicateElements(match.Architectures, $"{path}.Architectures"); + RejectDuplicateElements(match.ExecutionElevation, $"{path}.ExecutionElevation"); + if (match.Managers.Count > MaxManagers) + { + throw new JsonException( + $"The JSON array at {path}.Managers must contain at most {MaxManagers} values."); + } + if (match.SourceNames.Count > MaxSourceNames) + { + throw new JsonException( + $"The JSON array at {path}.SourceNames must contain at most {MaxSourceNames} values."); + } + RejectBoundedStrings(match.SourceNames, 1, MaxSourceNames, $"{path}.SourceNames"); + if (match.SourceNames.Count > 0 && match.Managers.Count != 1) + { + throw new JsonException( + $"The JSON array at {path}.SourceNames requires exactly one value at {path}.Managers."); + } + if (match.PackageIdentifiers is { } identifiers) + { + ValidatePackageIdentifierCondition(identifiers, $"{path}.PackageIdentifiers"); + } + if (match.Version is { } version) + { + ValidateVersionCondition(version, $"{path}.Version"); + } + } + + private static void ValidatePackageIdentifierCondition( + PackageIdentifierCondition identifiers, + string path) + { + if (identifiers.ExactSpecified == identifiers.PatternsSpecified) + { + throw new JsonException($"The JSON object at {path} must contain exactly one of Exact or Patterns."); + } + if ((identifiers.ExactSpecified && identifiers.Exact is null) + || (identifiers.PatternsSpecified && identifiers.Patterns is null)) + { + throw new JsonException($"The selected package identifier mode at {path} must not be null."); + } + if (identifiers.Exact is { } exact) + { + if (exact.Count is 0 or > 1024) + { + throw new JsonException($"The JSON array at {path}.Exact must contain between 1 and 1024 values."); + } + RejectPackageIdentifiers(exact, $"{path}.Exact"); + RejectDuplicateElements(exact, $"{path}.Exact"); + } + if (identifiers.Patterns is { } patterns) + { + if (patterns.Count is 0 or > 1024) + { + throw new JsonException($"The JSON array at {path}.Patterns must contain between 1 and 1024 values."); + } + RejectBoundedStrings(patterns, 1, 256, $"{path}.Patterns"); + RejectDuplicateElements(patterns, $"{path}.Patterns"); + } + } + + private static void RejectPackageIdentifiers(IReadOnlyList values, string path) + { + RejectBoundedStrings(values, 1, 256, path); + const string AllowedPunctuation = ".-_+@/:[],#$%{}"; + for (var index = 0; index < values.Count; index++) + { + if (values[index].Any(character => + !char.IsAsciiLetterOrDigit(character) + && !AllowedPunctuation.Contains(character, StringComparison.Ordinal))) + { + throw new JsonException( + $"The JSON string at {path}[{index}] is not a valid exact package identifier."); + } + } + } + + private static void ValidateVersionCondition(VersionCondition version, string path) + { + if (version.ExactSpecified == version.RangeSpecified) + { + throw new JsonException($"The JSON object at {path} must contain exactly one of Exact or Range."); + } + if ((version.ExactSpecified && version.Exact is null) + || (version.RangeSpecified && version.Range is null)) + { + throw new JsonException($"The selected version mode at {path} must not be null."); + } + if (version.Exact is { } exact) + { + if (exact.Count is 0 or > 256) + { + throw new JsonException($"The JSON array at {path}.Exact must contain between 1 and 256 values."); + } + RejectBoundedStrings(exact, 1, 128, $"{path}.Exact"); + RejectDuplicateElements(exact, $"{path}.Exact"); + } + if (version.Range is { } range) + { + ValidateVersionRange(range, $"{path}.Range"); + } + } + + private static void ValidateVersionRange(VersionRange range, string path) + { + if (range.MinVersion is null && range.MaxVersion is null) + { + throw new JsonException( + $"The JSON object at {path} must specify MinVersion or MaxVersion."); + } + foreach (var (name, value) in new[] + { + (nameof(VersionRange.MinVersion), range.MinVersion), + (nameof(VersionRange.MaxVersion), range.MaxVersion), + }) + { + if (value is not null + && (value.Length > 128 + || !SemanticVersionRegex().IsMatch(value) + || !SemanticVersionCoreFitsUInt64(value))) + { + throw new JsonException( + $"The JSON string at {path}.{name} must be a canonical semantic version."); + } + } + } + + private static bool SemanticVersionCoreFitsUInt64(string value) + { + var suffixIndex = value.IndexOfAny(['-', '+']); + var core = suffixIndex < 0 ? value : value[..suffixIndex]; + foreach (var component in core.Split('.')) + { + if (!ulong.TryParse( + component, + NumberStyles.None, + CultureInfo.InvariantCulture, + out _)) + { + return false; + } + } + return true; } + private static bool IsEmpty(PolicyMatch match) => + match.Operations.Count == 0 + && match.Managers.Count == 0 + && match.SourceNames.Count == 0 + && match.PackageIdentifiers is null + && match.Version is null + && match.Scopes.Count == 0 + && match.Architectures.Count == 0 + && match.ExecutionElevation.Count == 0 + && match.Interactive is null + && match.SkipHashCheck is null + && match.PreRelease is null + && match.HasCustomParameters is null + && match.HasCustomInstallLocation is null + && match.HasPrePostCommands is null + && match.HasKillBeforeOperation is null + && match.HasUninstallPrevious is null; + private static PolicyDocument? Validate(PolicyDocument? policy) { if (policy is not null) @@ -161,19 +351,28 @@ private static void ValidateRequiredCollectionElements(PolicyMatch match, string return policy; } - private static void RejectBooleanMatch(IReadOnlyList values, string path) + private static void ValidatePolicyRevision(uint revision) { - if (values.Count > 1) + if (revision is 0 or > int.MaxValue) { - throw new JsonException($"The JSON array at {path} must contain at most one value."); + throw new JsonException($"Policy revision must be between 1 and {int.MaxValue}."); } } - private static void ValidatePolicyRevision(uint revision) + private static void ValidateValidityWindow( + DateTimeOffset? validFrom, + DateTimeOffset? validUntil, + string path) { - if (revision is 0 or > int.MaxValue) + if (validFrom is not null + && validUntil is not null + && validFrom.Value >= validUntil.Value) { - throw new JsonException($"Policy revision must be between 1 and {int.MaxValue}."); + throw new JsonException( + $"The JSON value at {path}.ValidUntil must be strictly later than {path}.ValidFrom.", + $"{path}.ValidUntil", + lineNumber: null, + bytePositionInLine: null); } } @@ -189,6 +388,18 @@ private static void RejectNullElements(IReadOnlyList values, string path) } } + private static void RejectDuplicateElements(IReadOnlyList values, string path) + { + var unique = new HashSet(); + for (var index = 0; index < values.Count; index++) + { + if (!unique.Add(values[index])) + { + throw new JsonException($"The JSON array at {path} must not contain duplicate values."); + } + } + } + private static void RejectBoundedStrings( IReadOnlyList values, int minLength, @@ -207,39 +418,86 @@ private static void RejectBoundedStrings( } } - private static JsonTypeInfo TypeInfo() => - typeof(T) == typeof(PolicyDocument) ? Cast(PolicySerializerContext.Default.PolicyDocument) : - typeof(T) == typeof(PolicyDraftDocument) ? Cast(PolicySerializerContext.Default.PolicyDraftDocument) : - typeof(T) == typeof(PolicyMetadata) ? Cast(PolicySerializerContext.Default.PolicyMetadata) : - typeof(T) == typeof(PolicyDraftMetadata) ? Cast(PolicySerializerContext.Default.PolicyDraftMetadata) : - typeof(T) == typeof(PolicyEnforcement) ? Cast(PolicySerializerContext.Default.PolicyEnforcement) : - typeof(T) == typeof(PolicyRule) ? Cast(PolicySerializerContext.Default.PolicyRule) : - typeof(T) == typeof(PolicyMatch) ? Cast(PolicySerializerContext.Default.PolicyMatch) : - typeof(T) == typeof(VersionRange) ? Cast(PolicySerializerContext.Default.VersionRange) : - typeof(T) == typeof(PolicyConstraints) ? Cast(PolicySerializerContext.Default.PolicyConstraints) : - throw new NotSupportedException($"Policy JSON serialization for {typeof(T).FullName} is not source-generated."); - - private static JsonTypeInfo StrictTypeInfo() => - typeof(T) == typeof(PolicyDocument) ? Cast(PolicyStrictSerializerContext.Default.PolicyDocument) : - typeof(T) == typeof(PolicyDraftDocument) ? Cast(PolicyStrictSerializerContext.Default.PolicyDraftDocument) : - typeof(T) == typeof(PolicyMetadata) ? Cast(PolicyStrictSerializerContext.Default.PolicyMetadata) : - typeof(T) == typeof(PolicyDraftMetadata) ? Cast(PolicyStrictSerializerContext.Default.PolicyDraftMetadata) : - typeof(T) == typeof(PolicyEnforcement) ? Cast(PolicyStrictSerializerContext.Default.PolicyEnforcement) : - typeof(T) == typeof(PolicyRule) ? Cast(PolicyStrictSerializerContext.Default.PolicyRule) : - typeof(T) == typeof(PolicyMatch) ? Cast(PolicyStrictSerializerContext.Default.PolicyMatch) : - typeof(T) == typeof(VersionRange) ? Cast(PolicyStrictSerializerContext.Default.VersionRange) : - typeof(T) == typeof(PolicyConstraints) ? Cast(PolicyStrictSerializerContext.Default.PolicyConstraints) : - throw new NotSupportedException($"Strict policy JSON deserialization for {typeof(T).FullName} is not source-generated."); + private static JsonTypeInfo TypeInfo() + { + _ = typeof(T) == typeof(PolicyDocument) + || typeof(T) == typeof(PolicyDraftDocument) + || typeof(T) == typeof(PolicyMetadata) + || typeof(T) == typeof(PolicyDraftMetadata) + || typeof(T) == typeof(PolicyEnforcement) + || typeof(T) == typeof(PolicyRule) + || typeof(T) == typeof(PolicyMatch) + || typeof(T) == typeof(PackageIdentifierCondition) + || typeof(T) == typeof(VersionCondition) + || typeof(T) == typeof(VersionRange) + || typeof(T) == typeof(PolicyConstraints) + ? true + : throw new NotSupportedException( + $"Policy JSON serialization for {typeof(T).FullName} is not source-generated."); + + return Cast(Options.GetTypeInfo(typeof(T))); + } + + private static JsonTypeInfo DeserializationTypeInfo() => + typeof(T) == typeof(PolicyDocument) ? Cast(PolicySerializerContext.Default.PolicyDocument) : + typeof(T) == typeof(PolicyDraftDocument) ? Cast(PolicySerializerContext.Default.PolicyDraftDocument) : + typeof(T) == typeof(PolicyMetadata) ? Cast(PolicySerializerContext.Default.PolicyMetadata) : + typeof(T) == typeof(PolicyDraftMetadata) ? Cast(PolicySerializerContext.Default.PolicyDraftMetadata) : + typeof(T) == typeof(PolicyEnforcement) ? Cast(PolicySerializerContext.Default.PolicyEnforcement) : + typeof(T) == typeof(PolicyRule) ? Cast(PolicySerializerContext.Default.PolicyRule) : + typeof(T) == typeof(PolicyMatch) ? Cast(PolicySerializerContext.Default.PolicyMatch) : + typeof(T) == typeof(PackageIdentifierCondition) ? Cast(PolicySerializerContext.Default.PackageIdentifierCondition) : + typeof(T) == typeof(VersionCondition) ? Cast(PolicySerializerContext.Default.VersionCondition) : + typeof(T) == typeof(VersionRange) ? Cast(PolicySerializerContext.Default.VersionRange) : + typeof(T) == typeof(PolicyConstraints) ? Cast(PolicySerializerContext.Default.PolicyConstraints) : + throw new NotSupportedException($"Policy JSON deserialization for {typeof(T).FullName} is not source-generated."); private static JsonTypeInfo Cast(JsonTypeInfo jsonTypeInfo) => (JsonTypeInfo)jsonTypeInfo; - private static JsonSerializerOptions CreateOptions(JsonSerializerContext context) => - new(context.Options) + private static JsonSerializerOptions CreateOptions() + { + var options = new JsonSerializerOptions(PolicySerializerContext.Default.Options) { - TypeInfoResolver = context.WithAddedModifier(AttachSemanticValidation), + TypeInfoResolver = PolicySerializerContext.Default.WithAddedModifier(AttachSemanticValidation), }; + AddDuplicateRejectingConverters(options, PolicySerializerContext.Default); + return options; + } + + private static void AddDuplicateRejectingConverters( + JsonSerializerOptions options, + PolicySerializerContext context) + { + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyDocument, + static value => ValidateSemanticValue(value))); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyDraftDocument, + static value => ValidateSemanticValue(value))); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyMetadata, + static value => ValidateSemanticValue(value))); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyDraftMetadata, + static value => ValidateSemanticValue(value))); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyEnforcement)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyRule)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyMatch)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PackageIdentifierCondition)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.VersionCondition)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.VersionRange)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyConstraints)); + } + private static void AttachSemanticValidation(JsonTypeInfo typeInfo) { if (typeInfo.Kind != JsonTypeInfoKind.Object) @@ -247,25 +505,49 @@ private static void AttachSemanticValidation(JsonTypeInfo typeInfo) return; } + ConfigureCanonicalSerialization(typeInfo); + if (typeInfo.Type == typeof(PolicyDocument) + || typeInfo.Type == typeof(PolicyDraftDocument) + || typeInfo.Type == typeof(PolicyMetadata) + || typeInfo.Type == typeof(PolicyDraftMetadata)) + { + return; + } typeInfo.OnSerializing = ValidateSemanticValue; typeInfo.OnDeserialized = ValidateSemanticValue; } -} -[JsonSourceGenerationOptions( - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - RespectNullableAnnotations = true)] -[JsonSerializable(typeof(PolicyDocument))] -[JsonSerializable(typeof(PolicyDraftDocument))] -[JsonSerializable(typeof(PolicyMetadata))] -[JsonSerializable(typeof(PolicyDraftMetadata))] -[JsonSerializable(typeof(PolicyEnforcement))] -[JsonSerializable(typeof(PolicyRule))] -[JsonSerializable(typeof(PolicyMatch))] -[JsonSerializable(typeof(VersionRange))] -[JsonSerializable(typeof(PolicyConstraints))] -internal sealed partial class PolicySerializerContext : JsonSerializerContext; + internal static void ConfigureCanonicalSerialization(JsonTypeInfo typeInfo) + { + if (typeInfo.Type != typeof(PolicyMatch)) + { + return; + } + + foreach (var property in typeInfo.Properties) + { + if (IsPolicyMatchCollectionProperty(property.Name)) + { + property.ShouldSerialize = static (_, value) => + value is System.Collections.ICollection { Count: > 0 }; + } + } + } + + private static bool IsPolicyMatchCollectionProperty(string propertyName) => + propertyName is + nameof(PolicyMatch.Operations) + or nameof(PolicyMatch.Managers) + or nameof(PolicyMatch.SourceNames) + or nameof(PolicyMatch.Scopes) + or nameof(PolicyMatch.Architectures) + or nameof(PolicyMatch.ExecutionElevation); + + [GeneratedRegex( + @"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?\z", + RegexOptions.CultureInvariant)] + private static partial Regex SemanticVersionRegex(); +} [JsonSourceGenerationOptions( WriteIndented = true, @@ -279,6 +561,8 @@ internal sealed partial class PolicySerializerContext : JsonSerializerContext; [JsonSerializable(typeof(PolicyEnforcement))] [JsonSerializable(typeof(PolicyRule))] [JsonSerializable(typeof(PolicyMatch))] +[JsonSerializable(typeof(PackageIdentifierCondition))] +[JsonSerializable(typeof(VersionCondition))] [JsonSerializable(typeof(VersionRange))] [JsonSerializable(typeof(PolicyConstraints))] -internal sealed partial class PolicyStrictSerializerContext : JsonSerializerContext; \ No newline at end of file +internal sealed partial class PolicySerializerContext : JsonSerializerContext; \ No newline at end of file diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/README.md b/policies/dotnet/Devolutions.Now.Policy.Model/README.md index 2e552dd..acb46a0 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Model/README.md @@ -20,17 +20,37 @@ Architecture ------------ - `PolicyModels.cs` defines committed `PolicyDocument`, editable `PolicyDraftDocument`, their metadata, explicit conversions, enforcement, rules, match criteria, constraints, and version range types. -- `Enums.cs` defines policy-level enums such as operation, manager, scope, architecture, elevation, decision, and rule precedence. -- `PolicySerializer.cs` defines shared `JsonSerializerOptions`, including strict parsing that rejects unknown JSON members and JSON null for non-nullable policy members or collection elements. +- `Enums.cs` defines policy-level enums such as operation, manager, scope, architecture, elevation, and decision. +- `PolicySerializer.cs` defines the single source-generated policy JSON contract. `PolicySerializer.Deserialize`, `PolicySerializer.Options`, and both document `ParseJson` helpers all reject unknown members, duplicate property names, and JSON null for non-nullable policy members or collection elements. +- The duplicate check covers every nested object, decodes escaped names before comparing them, and uses ordinal, case-sensitive equality to match canonical property-name handling. -`PolicyDocument.Create` constructs a committed policy and `PolicyDraftDocument.Create` constructs an editable draft. `PolicyDocument.ToDraft` removes server-managed `Revision` and `PublishedAt`; `PolicyDraftDocument.ToPolicyDocument` requires those values when committing. `ParseJson` is the only policy parsing entry point. +`PolicyDocument.Create` constructs a committed policy and `PolicyDraftDocument.Create` constructs an editable draft. `PolicyDocument.ToDraft` removes server-managed `Revision` and `PublishedAt`; `PolicyDraftDocument.ToPolicyDocument` requires those values when committing. `ParseJson` is the recommended strict policy parsing entry point. `PolicyFormatVersion` is software-managed format compatibility metadata, not a publisher release version. New documents stamp `1.0.0`; readers accept and preserve supported numeric versions in the 1.x line and reject malformed or unsupported-major values. Applications must not expose it as authored metadata. Policy documents contain no `$schema` member, and strict readers reject documents that contain one as unknown-field input. +`Metadata.ValidFrom` and `Metadata.ValidUntil` are optional operational UTC instants, not informational labels. Omitted or explicit `null` leaves that side unbounded, and canonical output omits an absent bound. When both are present, `ValidFrom` must be strictly earlier than `ValidUntil`; comparisons normalize offsets by instant. Brokers fail closed for every request: reject operations when the current instant is before `ValidFrom` or after `ValidUntil`, with no fallback policy. + +`PolicyMatch` boolean request characteristics (`Interactive`, `SkipHashCheck`, `PreRelease`, `HasCustomParameters`, `HasCustomInstallLocation`, `HasPrePostCommands`, `HasKillBeforeOperation`, and `HasUninstallPrevious`) are optional scalar booleans. Omitted or explicit `null` means the characteristic does not affect whether the rule matches; `false` and `true` require that exact request characteristic. Canonical serialization omits absent values. Legacy boolean arrays are rejected. A rule's `Match` must still contain at least one effective non-null, nonempty criterion. + +Collection-valued match filters accept omission or an explicit empty array as unrestricted input, but canonical serialization omits empty collections. The former `PackageNames` filter is rejected as unknown input. `PackageIdentifiers` has exactly one explicit mode: `Exact` contains validated stable identifiers and rejects wildcard characters, while `Patterns` contains deliberately broad wildcard patterns that may authorize multiple identifiers. Use `UseExact` or `UsePatterns` to switch a mutable `PackageIdentifierCondition` atomically. `SourceNames` contains exact configured package source names, not URLs or wildcard patterns. Source-name comparison follows the selected package manager's semantics, wildcard characters are literal, and nonempty `SourceNames` requires exactly one `Managers` value. + +`Version` also has exactly one explicit mode: `Exact` contains one or more arbitrary package version strings, including non-SemVer versions, while `Range` contains the semantic-version range. Use `UseExact` or `UseRange` to switch a mutable `VersionCondition` atomically. Omitted or explicit `null` does not narrow matching. The former `Versions` and `VersionRange` match properties are rejected as unknown input. + +`ExecutionElevation` matches the request's effective execution privilege, not only the client's requested elevation. Brokers must use `Elevated` when the package operation will run with administrator privileges—currently when `Scope` is `Machine` or `Client.RequestedElevation` is `Elevated`—and `Standard` otherwise. The former `Elevation` match property is rejected as unknown input. + +Rule precedence is fixed by the policy format and is not represented by a JSON field: lower `Priority` values evaluate first, `Deny` wins equal-priority Allow/Deny ties, and remaining equal-priority ties retain document order. `Constraints` are additional safety limits for an `Allow` rule and are invalid on `Deny` rules. Editors may host incomplete blank rules transiently, but must not serialize or save a rule until `Match` contains an effective condition. + Breaking change --------------- -Policy documents are JSON-only. `PolicyDocument.ParseYaml`, which was public in `Devolutions.Now.Policy.Model` 2026.8.13, has been removed intentionally. Consumers must migrate stored policies to JSON before upgrading; OpenAPI YAML and unrelated YAML documents are unaffected. +Policy documents are JSON-only. `PolicyDocument.ParseYaml`, which was public in `Devolutions.Now.Policy.Model` 2026.8.13, has been removed intentionally. Consumers must migrate stored policies to JSON before upgrading; OpenAPI YAML and unrelated YAML documents are unaffected. Boolean match arrays and the former `PolicyType`, `RulePrecedence`, and `PackageNames` members are also rejected rather than converted. + +Consumer migration +------------------ + +- Gateway removes `PolicyType`, `RulePrecedence`, `PackageNames`, and old match-field construction; keeps the fixed priority/deny-tie/document-order evaluator semantics; assumes Deny rules have no constraints; computes `ExecutionElevation` as `Elevated` when scope is `Machine` or requested elevation is `Elevated`, otherwise `Standard`; implements the explicit package/version/source modes; retains per-request UTC validity enforcement; and adds exact `ValidFrom`/`ValidUntil` boundary tests if missing. +- UniGetUI removes editors/help for `PolicyType`, `RulePrecedence`, `PackageNames`, and old match fields; shows constraints only for Allow rules; resolves friendly-name searches to stable identifiers; auto-generates unique priorities by visible order; uses date/time controls with inline `ValidFrom < ValidUntil` validation; and may host incomplete rules transiently but must not serialize or save them. +- Stored documents replace boolean arrays with scalar booleans, rename `Sources` to `SourceNames` and `Elevation` to `ExecutionElevation`, wrap package identifiers in `Exact` or `Patterns`, and replace `Versions`/`VersionRange` with one `Version` mode. Validation ---------- diff --git a/policies/rust/now-policy-api/openapi/now-policy-api.yaml b/policies/rust/now-policy-api/openapi/now-policy-api.yaml index cb6f555..74d8cef 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -422,9 +422,10 @@ components: properties: Message: description: Human-readable message about the cancelation outcome. - type: string + type: + - string + - 'null' maxLength: 2048 - nullable: true OperationId: description: Server-issued stable operation identifier. allOf: @@ -616,10 +617,11 @@ components: properties: Code: description: Machine-stable detail code. - type: string + type: + - string + - 'null' maxLength: 128 minLength: 1 - nullable: true Message: description: Human-readable detail message. type: string @@ -627,10 +629,11 @@ components: minLength: 1 Path: description: JSON pointer, header name, or other location for the error. - type: string + type: + - string + - 'null' maxLength: 512 minLength: 1 - nullable: true required: - Message ErrorResponse: @@ -665,7 +668,6 @@ components: - $ref: '#/components/schemas/PolicyManagementSnapshot' - enum: - null - nullable: true default: null Message: type: string @@ -682,7 +684,6 @@ components: - $ref: '#/components/schemas/PolicyValidationResult' - enum: - null - nullable: true default: null additionalProperties: false required: @@ -712,7 +713,6 @@ components: - $ref: '#/components/schemas/OperationDiagnostics' - enum: - null - nullable: true Policy: description: Summary of the policy used. allOf: @@ -803,14 +803,12 @@ components: - $ref: '#/components/schemas/OperationDiagnostics' - enum: - null - nullable: true Operation: description: Submitted operation. Omitted when the decision is deny. anyOf: - $ref: '#/components/schemas/OperationSubmission' - enum: - null - nullable: true Policy: description: Summary of the policy used. allOf: @@ -925,10 +923,11 @@ components: - $ref: '#/components/schemas/ManagerName' MaxOperationTimeoutSeconds: description: Maximum operation runtime before the broker may time out the process. - type: integer + type: + - integer + - 'null' format: uint64 minimum: 0 - nullable: true Operations: description: Operations supported for this manager. type: array @@ -1040,7 +1039,6 @@ components: - $ref: '#/components/schemas/EventChannel' - enum: - null - nullable: true OperationId: description: Server-issued stable operation identifier. allOf: @@ -1062,37 +1060,8 @@ components: description: |- Package identifier string. - Validated against an explicit allowlist of characters: ASCII alphanumerics - plus `. - _ + @ / : [ ] , # $ % { }`. - - - `.`, `-`, `_`, `+`: winget (`Notepad++.Notepad++`), chocolatey, pip, cargo, - dotnet, apt/dnf/pacman (`g++`, `libstdc++6`), PowerShell modules; - - - `@`, `/`: scoped npm/Bun packages (`@scope/package`), homebrew and scoop - `tap/formula` paths, versioned formulas (`python@3.11`); - - - `:`: npm aliases (`alias:@scope/package@1.0.0`), vcpkg triplets - (`curl:x64-windows`); - - - `[`, `]`, `,`: vcpkg features (`curl[ssl,http2]:x64-windows`), pip extras - (`requests[socks]`); - - - `#`, `$`, `%`, `{`, `}`: additional identifier punctuation (accepted by - product decision for forward compatibility). Caveat: these characters - carry expansion semantics in some shells (`${VAR}`, `%VAR%`, brace - expansion), so downstream command builders must pass identifiers as - discrete process arguments and never interpolate them into a shell - command line. - - Version range/pin operators (`<`, `>`, `=`, `!`, `|`, `^`, `~`) are - rejected: the broker matches against a specific, exact version carried in - the request's separate `Package.Version` field, so range expressions do - not belong in the identifier (npm aliases must use exact versions, e.g. - `alias:pkg@7.20.0`). The wildcards `*` and `?` are also rejected: - policy-side package identifier matching is wildcard-based, so wildcards in - request identifiers would be ambiguous. Everything else — whitespace, - control characters, `"`, `\`, backtick, `& ' ( ) ;`, and non-ASCII — is - rejected as well. + Validated against an explicit allowlist of characters used by supported + package managers. Wildcards and version range operators are rejected. type: string maxLength: 256 minLength: 1 @@ -1221,7 +1190,6 @@ components: - $ref: '#/components/schemas/ResourceId' - enum: - null - nullable: true Severity: $ref: '#/components/schemas/PolicyFindingSeverity' additionalProperties: false @@ -1241,13 +1209,11 @@ components: - InvalidFieldType - InvalidFieldValue - DuplicateRuleId - - IneffectiveBooleanMatch - InvalidVersionRange - EmptyVersionRange - InvalidWildcardPattern - ContradictoryConstraints - InvalidValidityInterval - - UnsupportedPolicyType - UnsupportedPolicyFormatVersion - AuditModeEnabled - DefaultAllow @@ -1299,7 +1265,6 @@ components: InvalidDiagnostics: enum: - null - nullable: true Policy: $ref: '#/components/schemas/PolicyDocument' State: @@ -1311,11 +1276,9 @@ components: InvalidDiagnostics: enum: - null - nullable: true Policy: enum: - null - nullable: true State: enum: - Missing @@ -1337,7 +1300,6 @@ components: Policy: enum: - null - nullable: true State: enum: - Invalid @@ -1348,7 +1310,6 @@ components: ReadOnlyReason: enum: - null - nullable: true WriteCapability: enum: - Writable @@ -1375,21 +1336,18 @@ components: - $ref: '#/components/schemas/InvalidPolicyDiagnostics' - enum: - null - nullable: true default: null Policy: anyOf: - $ref: '#/components/schemas/PolicyDocument' - enum: - null - nullable: true default: null ReadOnlyReason: anyOf: - $ref: '#/components/schemas/PolicyReadOnlyReason' - enum: - null - nullable: true default: null Source: $ref: '#/components/schemas/PolicyConfigurationSource' @@ -1627,7 +1585,6 @@ components: CanonicalDraft: enum: - null - nullable: true Findings: minItems: 1 not: @@ -1644,7 +1601,6 @@ components: ValidationReceipt: enum: - null - nullable: true PolicyValidationResultFields: type: object properties: @@ -1653,7 +1609,6 @@ components: - $ref: '#/components/schemas/PolicyDraftDocument' - enum: - null - nullable: true default: null Findings: type: array @@ -1668,7 +1623,6 @@ components: - $ref: '#/components/schemas/PolicyValidationReceipt' - enum: - null - nullable: true default: null ValidatorVersion: type: string @@ -1698,9 +1652,10 @@ components: properties: CustomInstallLocation: description: Custom install directory path. - type: string + type: + - string + - 'null' maxLength: 2048 - nullable: true CustomParameters: description: Additional command-line parameters. type: array @@ -1722,14 +1677,16 @@ components: default: false PostOperationCommand: description: Command to execute after the package operation. - type: string + type: + - string + - 'null' maxLength: 2048 - nullable: true PreOperationCommand: description: Command to execute before the package operation. - type: string + type: + - string + - 'null' maxLength: 2048 - nullable: true PreRelease: description: Allow pre-release versions. type: boolean @@ -1739,7 +1696,6 @@ components: - $ref: '#/components/schemas/Scope' - enum: - null - nullable: true SkipHashCheck: description: Skip package hash verification. type: boolean @@ -1762,13 +1718,13 @@ components: - $ref: '#/components/schemas/Architecture' - enum: - null - nullable: true Channel: description: Release channel. - type: string + type: + - string + - 'null' maxLength: 16 minLength: 1 - nullable: true Id: description: Package identifier (e.g., "Publisher.Package" for WinGet). allOf: @@ -1784,7 +1740,6 @@ components: - $ref: '#/components/schemas/VersionString' - enum: - null - nullable: true additionalProperties: false required: - Id @@ -1799,9 +1754,10 @@ components: minLength: 1 Url: description: Optional source URL. - type: string + type: + - string + - 'null' maxLength: 2048 - nullable: true additionalProperties: false required: - Name @@ -1815,27 +1771,25 @@ components: - $ref: '#/components/schemas/ManagerName' - enum: - null - nullable: true Operation: description: Operation from the request (null if not parsed). anyOf: - $ref: '#/components/schemas/Operation' - enum: - null - nullable: true PackageId: description: Package identifier from the request (null if not parsed). anyOf: - $ref: '#/components/schemas/PackageIdentifier' - enum: - null - nullable: true Source: description: Source name from the request (null if not parsed). - type: string + type: + - string + - 'null' maxLength: 256 minLength: 1 - nullable: true additionalProperties: false ResourceId: description: Resource identifier (operation IDs, request IDs). @@ -1928,23 +1882,26 @@ components: properties: CompletedAt: description: UTC timestamp when the operation completed or failed (null if still running). - type: string + type: + - string + - 'null' format: date-time - nullable: true Details: description: Manager-specific structured status details. ExitCode: description: Process exit code (present when status is `completed`, or `failed` due to non-zero exit). - type: integer + type: + - integer + - 'null' format: int32 - nullable: true Message: description: |- Human-readable message about the status. For failures this carries the short error summary (e.g. "winget.exe exited with code 0x8A150011", or a process-launch error). - type: string + type: + - string + - 'null' maxLength: 2048 - nullable: true OperationId: description: Server-issued stable operation identifier. allOf: @@ -1967,9 +1924,10 @@ components: - $ref: '#/components/schemas/ServerContext' StartedAt: description: UTC timestamp when the process was actually launched (null if not yet started). - type: string + type: + - string + - 'null' format: date-time - nullable: true Status: description: Current status of the operation. allOf: @@ -2016,7 +1974,7 @@ components: - Allow - Deny PolicyModelElevation: - description: Requested elevation level. + description: Effective package-operation execution privilege. type: string enum: - Standard @@ -2057,12 +2015,45 @@ components: - Install - Update - Uninstall - PolicyModelPackageBrokerPolicy: + PolicyModelPackageIdentifier: + description: |- + Exact stable package identifier used by requests and exact policy matching. + + Manager-specific punctuation used by real identifiers is accepted, while + wildcard characters and range/pin operators are rejected. type: string - enum: - - PackageBrokerPolicy + maxLength: 256 + minLength: 1 + pattern: ^[A-Za-z0-9._+@/:\[\],#$%{}-]+(?![\s\S]) + PolicyModelPackageIdentifierCondition: + description: Exactly one package-identifier mode. Exact authorizes stable identifiers; Patterns explicitly authorizes every identifier matched by a wildcard pattern. + oneOf: + - type: object + properties: + Exact: + type: array + items: + $ref: '#/components/schemas/PolicyModelPackageIdentifier' + maxItems: 1024 + minItems: 1 + uniqueItems: true + additionalProperties: false + required: + - Exact + - type: object + properties: + Patterns: + type: array + items: + $ref: '#/components/schemas/PolicyModelStringPattern' + maxItems: 1024 + minItems: 1 + uniqueItems: true + additionalProperties: false + required: + - Patterns PolicyModelPolicyConstraints: - description: Constraints applied after a rule matches. + description: Additional safety limits applied after an Allow rule matches. type: object properties: AllowCustomInstallLocation: @@ -2138,10 +2129,6 @@ components: Applications must not expose this field as publisher-authored editable metadata. allOf: - $ref: '#/components/schemas/PolicyModelPolicyFormatVersion' - PolicyType: - description: Must be `"PackageBrokerPolicy"`. - allOf: - - $ref: '#/components/schemas/PolicyModelPackageBrokerPolicy' Rules: description: Ordered list of policy rules (may be empty; enforcement defaults apply). type: array @@ -2151,7 +2138,6 @@ components: additionalProperties: false required: - PolicyFormatVersion - - PolicyType - Metadata - Enforcement - Rules @@ -2175,10 +2161,6 @@ components: as publisher-authored editable metadata. allOf: - $ref: '#/components/schemas/PolicyModelPolicyFormatVersion' - PolicyType: - description: Must be `"PackageBrokerPolicy"`. - allOf: - - $ref: '#/components/schemas/PolicyModelPackageBrokerPolicy' Rules: description: Ordered list of policy rules (may be empty; enforcement defaults apply). type: array @@ -2188,19 +2170,21 @@ components: additionalProperties: false required: - PolicyFormatVersion - - PolicyType - Metadata - Enforcement - Rules PolicyModelPolicyDraftMetadata: - description: Editable policy metadata without server-managed revision and publication time. + description: |- + Editable policy metadata without server-managed revision and publication time. When both + validity bounds are present, `ValidFrom` must be strictly earlier than `ValidUntil`. type: object properties: Description: description: Human-readable description. - type: string + type: + - string + - 'null' maxLength: 512 - nullable: true Id: description: Unique policy identifier. allOf: @@ -2216,41 +2200,46 @@ components: - $ref: '#/components/schemas/PolicyModelHttpUrl' - enum: - null - nullable: true ValidFrom: - description: Policy becomes active at this time. - type: string + description: |- + Earliest instant when the policy is active. When both bounds are present, this must be + strictly earlier than `ValidUntil`. + type: + - string + - 'null' format: date-time - nullable: true ValidUntil: - description: Policy expires at this time. - type: string + description: |- + Instant after which the policy is inactive. When both bounds are present, this must be + strictly later than `ValidFrom`. + type: + - string + - 'null' format: date-time - nullable: true additionalProperties: false required: - Id - Publisher PolicyModelPolicyEnforcement: - description: Enforcement configuration. + description: |- + Enforcement configuration. + + Matching rules are evaluated by ascending priority. Deny wins equal-priority + Allow/Deny ties; remaining equal-priority ties retain document order. type: object properties: AuditMode: description: When true, broker logs decisions but does not enforce. - type: boolean - nullable: true + type: + - boolean + - 'null' DefaultDecision: description: Decision when no rule matches. allOf: - $ref: '#/components/schemas/PolicyModelDecision' - RulePrecedence: - description: Rule precedence strategy (must be "PriorityThenDeny"). - allOf: - - $ref: '#/components/schemas/PolicyModelRulePrecedence' additionalProperties: false required: - DefaultDecision - - RulePrecedence PolicyModelPolicyFormatVersion: description: |- Software-managed policy document format version. @@ -2263,148 +2252,18 @@ components: type: string maxLength: 128 pattern: ^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?![\s\S]) - PolicyModelPolicyMatch: - description: |- - Match criteria for a policy rule. All specified fields must match. - At least one field must be present. - type: object - properties: - Architectures: - description: Allowed architectures. - type: array - items: - $ref: '#/components/schemas/PolicyModelArchitecture' - maxItems: 5 - uniqueItems: true - Elevation: - description: Allowed elevation levels. - type: array - items: - $ref: '#/components/schemas/PolicyModelElevation' - maxItems: 2 - uniqueItems: true - HasCustomInstallLocation: - description: Whether request has custom install location. - type: array - items: - type: boolean - maxItems: 1 - uniqueItems: true - HasCustomParameters: - description: Whether request has custom parameters. - type: array - items: - type: boolean - maxItems: 1 - uniqueItems: true - HasKillBeforeOperation: - description: Whether request has kill-before-operation entries. - type: array - items: - type: boolean - maxItems: 1 - uniqueItems: true - HasPrePostCommands: - description: Whether request has pre/post operation commands. - type: array - items: - type: boolean - maxItems: 1 - uniqueItems: true - HasUninstallPrevious: - description: Whether request has uninstall-previous flag set. - type: array - items: - type: boolean - maxItems: 1 - uniqueItems: true - Interactive: - description: Allowed interactive values. - type: array - items: - type: boolean - maxItems: 1 - uniqueItems: true - Managers: - description: Allowed managers. - type: array - items: - $ref: '#/components/schemas/PolicyModelManagerName' - maxItems: 16 - uniqueItems: true - Operations: - description: Allowed operations. - type: array - items: - $ref: '#/components/schemas/PolicyModelOperation' - maxItems: 3 - uniqueItems: true - PackageIdentifiers: - description: Package identifier patterns (wildcard). - type: array - items: - $ref: '#/components/schemas/PolicyModelStringPattern' - maxItems: 1024 - uniqueItems: true - PackageNames: - description: Package name patterns (wildcard). - type: array - items: - $ref: '#/components/schemas/PolicyModelStringPattern' - maxItems: 1024 - uniqueItems: true - PreRelease: - description: Allowed preRelease values. - type: array - items: - type: boolean - maxItems: 1 - uniqueItems: true - Scopes: - description: Allowed scopes. - type: array - items: - $ref: '#/components/schemas/PolicyModelScope' - maxItems: 2 - uniqueItems: true - SkipHashCheck: - description: Allowed skipHashCheck values. - type: array - items: - type: boolean - maxItems: 1 - uniqueItems: true - Sources: - description: Source patterns (wildcard). - type: array - items: - $ref: '#/components/schemas/PolicyModelStringPattern' - maxItems: 128 - uniqueItems: true - VersionRange: - description: Semantic version range. - anyOf: - - $ref: '#/components/schemas/PolicyModelVersionRange' - - enum: - - null - nullable: true - Versions: - description: Exact version list. - type: array - items: - $ref: '#/components/schemas/PolicyModelVersionString' - maxItems: 256 - uniqueItems: true - additionalProperties: false PolicyModelPolicyMetadata: - description: Policy metadata. + description: |- + Policy metadata. When both validity bounds are present, `ValidFrom` must be strictly earlier + than `ValidUntil`. type: object properties: Description: description: Human-readable description. - type: string + type: + - string + - 'null' maxLength: 512 - nullable: true Id: description: Unique policy identifier. allOf: @@ -2430,17 +2289,22 @@ components: - $ref: '#/components/schemas/PolicyModelHttpUrl' - enum: - null - nullable: true ValidFrom: - description: Policy becomes active at this time. - type: string + description: |- + Earliest instant when the policy is active. When both bounds are present, this must be + strictly earlier than `ValidUntil`. + type: + - string + - 'null' format: date-time - nullable: true ValidUntil: - description: Policy expires at this time. - type: string + description: |- + Instant after which the policy is inactive. When both bounds are present, this must be + strictly later than `ValidFrom`. + type: + - string + - 'null' format: date-time - nullable: true additionalProperties: false required: - Id @@ -2453,13 +2317,12 @@ components: properties: Constraints: description: |- - Additional constraints applied after matching. - When absent, no constraints are enforced beyond the match criteria. + Additional safety limits applied after an Allow rule matches. + Constraints are invalid on Deny rules. anyOf: - $ref: '#/components/schemas/PolicyModelPolicyConstraints' - enum: - null - nullable: true Decision: description: Decision if this rule matches. allOf: @@ -2475,10 +2338,235 @@ components: Match: description: |- Match criteria — request must satisfy all specified fields. - At least one criterion must be present. - allOf: - - $ref: '#/components/schemas/PolicyModelPolicyMatch' + At least one effective non-null, nonempty criterion must be present. + type: object + properties: + Architectures: + description: |- + Optional architecture filter. Omitted or empty does not narrow matching; + canonical serialization omits an empty collection. + type: array + items: + $ref: '#/components/schemas/PolicyModelArchitecture' + maxItems: 5 + uniqueItems: true + ExecutionElevation: + description: |- + Optional effective execution-elevation filter. Elevated means the package operation + will run with administrator privileges; Standard means it will not. Omitted or empty + does not narrow matching; canonical serialization omits an empty collection. + type: array + items: + $ref: '#/components/schemas/PolicyModelElevation' + maxItems: 2 + uniqueItems: true + HasCustomInstallLocation: + description: |- + Optional condition on whether the request has a custom install location. + Absent means this characteristic does not affect matching. + type: + - boolean + - 'null' + HasCustomParameters: + description: |- + Optional condition on whether the request has custom parameters. + Absent means this characteristic does not affect matching. + type: + - boolean + - 'null' + HasKillBeforeOperation: + description: |- + Optional condition on whether the request has kill-before-operation entries. + Absent means this characteristic does not affect matching. + type: + - boolean + - 'null' + HasPrePostCommands: + description: |- + Optional condition on whether the request has pre/post commands. + Absent means this characteristic does not affect matching. + type: + - boolean + - 'null' + HasUninstallPrevious: + description: |- + Optional condition on whether the request enables uninstall-previous. + Absent means this characteristic does not affect matching. + type: + - boolean + - 'null' + Interactive: + description: |- + Optional condition on the request's interactive characteristic. + Absent means this characteristic does not affect matching. + type: + - boolean + - 'null' + Managers: + description: |- + Optional manager filter. Omitted or empty does not narrow matching; + canonical serialization omits an empty collection. + type: array + items: + $ref: '#/components/schemas/PolicyModelManagerName' + maxItems: 16 + uniqueItems: true + Operations: + description: |- + Optional operation filter. Omitted or empty does not narrow matching; + canonical serialization omits an empty collection. + type: array + items: + $ref: '#/components/schemas/PolicyModelOperation' + maxItems: 3 + uniqueItems: true + PackageIdentifiers: + description: |- + Optional package-identifier condition. Exact uses validated stable identifiers; Patterns + uses explicit wildcard patterns that may authorize multiple packages. Absent does not + narrow matching. + anyOf: + - $ref: '#/components/schemas/PolicyModelPackageIdentifierCondition' + - enum: + - null + PreRelease: + description: |- + Optional condition on the request's preRelease characteristic. + Absent means this characteristic does not affect matching. + type: + - boolean + - 'null' + Scopes: + description: |- + Optional scope filter. Omitted or empty does not narrow matching; + canonical serialization omits an empty collection. + type: array + items: + $ref: '#/components/schemas/PolicyModelScope' + maxItems: 2 + uniqueItems: true + SkipHashCheck: + description: |- + Optional condition on the request's skipHashCheck characteristic. + Absent means this characteristic does not affect matching. + type: + - boolean + - 'null' + SourceNames: + description: |- + Optional exact configured-source-name filter. Matching uses the selected package manager's + source-name comparison semantics; wildcard characters are literal. Nonempty source names + require exactly one manager. Omitted or empty does not narrow matching; canonical + serialization omits an empty collection. + type: array + items: + $ref: '#/components/schemas/PolicyModelSourceName' + maxItems: 128 + uniqueItems: true + Version: + description: |- + Optional package-version condition. Exact supports arbitrary package version strings; + Range applies only to semantic versions. Absent does not narrow matching. + anyOf: + - $ref: '#/components/schemas/PolicyModelVersionCondition' + - enum: + - null + additionalProperties: false + anyOf: + - properties: + Operations: + minItems: 1 + required: + - Operations + - properties: + Managers: + minItems: 1 + required: + - Managers + - properties: + SourceNames: + minItems: 1 + required: + - SourceNames + - properties: + PackageIdentifiers: + type: object + required: + - PackageIdentifiers + - properties: + Version: + type: object + required: + - Version + - properties: + Scopes: + minItems: 1 + required: + - Scopes + - properties: + Architectures: + minItems: 1 + required: + - Architectures + - properties: + ExecutionElevation: + minItems: 1 + required: + - ExecutionElevation + - properties: + Interactive: + type: boolean + required: + - Interactive + - properties: + SkipHashCheck: + type: boolean + required: + - SkipHashCheck + - properties: + PreRelease: + type: boolean + required: + - PreRelease + - properties: + HasCustomParameters: + type: boolean + required: + - HasCustomParameters + - properties: + HasCustomInstallLocation: + type: boolean + required: + - HasCustomInstallLocation + - properties: + HasPrePostCommands: + type: boolean + required: + - HasPrePostCommands + - properties: + HasKillBeforeOperation: + type: boolean + required: + - HasKillBeforeOperation + - properties: + HasUninstallPrevious: + type: boolean + required: + - HasUninstallPrevious + if: + properties: + SourceNames: + minItems: 1 + required: + - SourceNames minProperties: 1 + then: + properties: + Managers: + maxItems: 1 + minItems: 1 + required: + - Managers Priority: description: Priority (lower = higher precedence). type: integer @@ -2487,10 +2575,21 @@ components: minimum: 0 Reason: description: Reason reported to the client. - type: string + type: + - string + - 'null' maxLength: 512 - nullable: true additionalProperties: false + not: + properties: + Constraints: + type: object + Decision: + enum: + - Deny + required: + - Decision + - Constraints required: - Id - Priority @@ -2501,24 +2600,58 @@ components: type: string maxLength: 128 pattern: ^[A-Za-z0-9][A-Za-z0-9._:\-]{0,127}$ - PolicyModelRulePrecedence: - description: Rule precedence strategy — always PriorityThenDeny. - type: string - enum: - - PriorityThenDeny PolicyModelScope: description: Package installation scope. type: string enum: - User - Machine + PolicyModelSemanticVersion: + description: |- + Semantic version string (SemVer 2.0.0). + + Validated at deserialization time using the `semver` crate. + type: string + maxLength: 128 + pattern: ^(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-4]|18446744073709551615)\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-4]|18446744073709551615)\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-4]|18446744073709551615)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?![\s\S]) + PolicyModelSourceName: + description: |- + Exact configured package source name. + + Matching uses the selected package manager's source-name comparison semantics. + Wildcard characters have no special meaning and are treated literally. + type: string + maxLength: 128 + minLength: 1 PolicyModelStringPattern: description: Case-insensitive exact value or wildcard pattern. type: string maxLength: 256 minLength: 1 + PolicyModelVersionCondition: + description: Exactly one package-version mode. Exact accepts arbitrary package version strings; Range applies only to semantic versions. + oneOf: + - type: object + properties: + Exact: + type: array + items: + $ref: '#/components/schemas/PolicyModelVersionString' + maxItems: 256 + minItems: 1 + uniqueItems: true + additionalProperties: false + required: + - Exact + - type: object + properties: + Range: + $ref: '#/components/schemas/PolicyModelVersionRange' + additionalProperties: false + required: + - Range PolicyModelVersionRange: - description: Semantic version range for matching. + description: Nonempty semantic-version range for matching. type: object properties: IncludePrerelease: @@ -2527,17 +2660,28 @@ components: default: false MaxVersion: description: Maximum version (inclusive). - type: string - maxLength: 128 - minLength: 1 - nullable: true + anyOf: + - $ref: '#/components/schemas/PolicyModelSemanticVersion' + - enum: + - null MinVersion: description: Minimum version (inclusive). - type: string - maxLength: 128 - minLength: 1 - nullable: true + anyOf: + - $ref: '#/components/schemas/PolicyModelSemanticVersion' + - enum: + - null additionalProperties: false + anyOf: + - properties: + MinVersion: + type: string + required: + - MinVersion + - properties: + MaxVersion: + type: string + required: + - MaxVersion PolicyModelVersionString: description: A short constrained string for version values. type: string diff --git a/policies/rust/now-policy-api/src/lib.rs b/policies/rust/now-policy-api/src/lib.rs index 2e8cef1..9b8fbcb 100644 --- a/policies/rust/now-policy-api/src/lib.rs +++ b/policies/rust/now-policy-api/src/lib.rs @@ -407,37 +407,8 @@ impl From<&str> for RuleId { /// Package identifier string. /// -/// Validated against an explicit allowlist of characters: ASCII alphanumerics -/// plus `. - _ + @ / : [ ] , # $ % { }`. -/// -/// - `.`, `-`, `_`, `+`: winget (`Notepad++.Notepad++`), chocolatey, pip, cargo, -/// dotnet, apt/dnf/pacman (`g++`, `libstdc++6`), PowerShell modules; -/// -/// - `@`, `/`: scoped npm/Bun packages (`@scope/package`), homebrew and scoop -/// `tap/formula` paths, versioned formulas (`python@3.11`); -/// -/// - `:`: npm aliases (`alias:@scope/package@1.0.0`), vcpkg triplets -/// (`curl:x64-windows`); -/// -/// - `[`, `]`, `,`: vcpkg features (`curl[ssl,http2]:x64-windows`), pip extras -/// (`requests[socks]`); -/// -/// - `#`, `$`, `%`, `{`, `}`: additional identifier punctuation (accepted by -/// product decision for forward compatibility). Caveat: these characters -/// carry expansion semantics in some shells (`${VAR}`, `%VAR%`, brace -/// expansion), so downstream command builders must pass identifiers as -/// discrete process arguments and never interpolate them into a shell -/// command line. -/// -/// Version range/pin operators (`<`, `>`, `=`, `!`, `|`, `^`, `~`) are -/// rejected: the broker matches against a specific, exact version carried in -/// the request's separate `Package.Version` field, so range expressions do -/// not belong in the identifier (npm aliases must use exact versions, e.g. -/// `alias:pkg@7.20.0`). The wildcards `*` and `?` are also rejected: -/// policy-side package identifier matching is wildcard-based, so wildcards in -/// request identifiers would be ambiguous. Everything else — whitespace, -/// control characters, `"`, `\`, backtick, `& ' ( ) ;`, and non-ASCII — is -/// rejected as well. +/// Validated against an explicit allowlist of characters used by supported +/// package managers. Wildcards and version range operators are rejected. #[derive( Debug, Clone, @@ -465,18 +436,16 @@ impl PackageIdentifier { reason: "must not be empty".to_owned(), }); } - if s.len() > 256 { return Err(ModelValidationError::Invalid { type_name: "PackageIdentifier", reason: format!("length {} exceeds maximum 256", s.len()), }); } - - if !s.bytes().all(|b| { - b.is_ascii_alphanumeric() + if !s.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!( - b, + byte, b'.' | b'-' | b'_' | b'+' @@ -505,8 +474,8 @@ impl PackageIdentifier { impl<'de> Deserialize<'de> for PackageIdentifier { fn deserialize>(deserializer: D) -> Result { - let s = String::deserialize(deserializer)?; - Self::parse(&s).map_err(serde::de::Error::custom) + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) } } diff --git a/policies/rust/now-policy-api/src/management.rs b/policies/rust/now-policy-api/src/management.rs index 6bb7579..6311d3c 100644 --- a/policies/rust/now-policy-api/src/management.rs +++ b/policies/rust/now-policy-api/src/management.rs @@ -96,13 +96,11 @@ pub enum PolicyFindingCode { InvalidFieldType, InvalidFieldValue, DuplicateRuleId, - IneffectiveBooleanMatch, InvalidVersionRange, EmptyVersionRange, InvalidWildcardPattern, ContradictoryConstraints, InvalidValidityInterval, - UnsupportedPolicyType, UnsupportedPolicyFormatVersion, AuditModeEnabled, DefaultAllow, @@ -975,9 +973,8 @@ mod tests { "IsValid": true, "CanonicalDraft": { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "test", "Publisher": "test" }, - "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Enforcement": { "DefaultDecision": "Deny" }, "Rules": [] }, "ValidationReceipt": "receipt", diff --git a/policies/rust/now-policy-server-template/src/server.rs b/policies/rust/now-policy-server-template/src/server.rs index 65ac716..e8d9f0d 100644 --- a/policies/rust/now-policy-server-template/src/server.rs +++ b/policies/rust/now-policy-server-template/src/server.rs @@ -124,6 +124,7 @@ pub fn openapi() -> OpenApi { let _ = api_routes().finish_api(&mut api); register_policy_management_body_limits(&mut api); register_policy_schema(&mut api); + normalize_openapi_31_nullable(&mut api); api } @@ -230,6 +231,65 @@ fn rewrite_policy_schema_refs( schemars::Schema::try_from(value).expect("BUG: rewritten policy schema should remain valid") } +fn normalize_openapi_31_nullable(api: &mut OpenApi) { + fn normalize(value: &mut serde_json::Value) { + match value { + serde_json::Value::Array(values) => { + for value in values { + normalize(value); + } + } + serde_json::Value::Object(values) => { + for value in values.values_mut() { + normalize(value); + } + + if values.remove("nullable") != Some(serde_json::Value::Bool(true)) { + return; + } + + match values.remove("type") { + Some(serde_json::Value::String(schema_type)) => { + values.insert("type".to_owned(), serde_json::json!([schema_type, "null"])); + } + Some(serde_json::Value::Array(mut schema_types)) => { + if !schema_types.iter().any(|schema_type| schema_type == "null") { + schema_types.push(serde_json::Value::String("null".to_owned())); + } + values.insert("type".to_owned(), serde_json::Value::Array(schema_types)); + } + Some(schema_type) => { + values.insert("type".to_owned(), schema_type); + } + _ if values + .get("enum") + .and_then(serde_json::Value::as_array) + .is_some_and(|variants| variants.iter().any(serde_json::Value::is_null)) => {} + _ => { + let non_null_schema = serde_json::Value::Object(std::mem::take(values)); + values.insert( + "anyOf".to_owned(), + serde_json::json!([non_null_schema, { "type": "null" }]), + ); + } + } + } + _ => {} + } + } + + let Some(components) = api.components.as_mut() else { + return; + }; + for schema in components.schemas.values_mut() { + let mut value = + serde_json::to_value(&schema.json_schema).expect("BUG: generated OpenAPI schema should serialize"); + normalize(&mut value); + schema.json_schema = + schemars::Schema::try_from(value).expect("BUG: normalized OpenAPI schema should remain valid"); + } +} + async fn health_handler(State(server): State) -> Json { Json(server.health().await) } @@ -563,6 +623,10 @@ mod tests { #[test] fn policy_openapi_preserves_nullable_optional_document_fields() { let api = serde_json::to_value(openapi()).expect("OpenAPI should serialize"); + assert!( + !api.to_string().contains("\"nullable\""), + "OpenAPI 3.1 schemas must not use the legacy nullable keyword" + ); for pointer in [ "/components/schemas/PolicyManagementSnapshotFields/properties/Policy/anyOf", "/components/schemas/PolicyValidationResultFields/properties/CanonicalDraft/anyOf", @@ -573,8 +637,7 @@ mod tests { .unwrap_or_else(|| panic!("missing nullable variants at {pointer}")); assert!( variants.iter().any(|variant| { - variant.get("nullable") == Some(&serde_json::Value::Bool(true)) - || variant.get("type").and_then(serde_json::Value::as_str) == Some("null") + variant.get("type").and_then(serde_json::Value::as_str) == Some("null") || variant .get("enum") .and_then(serde_json::Value::as_array) @@ -585,6 +648,33 @@ mod tests { } } + #[test] + fn policy_openapi_uses_nullable_scalar_boolean_match_conditions() { + let api = serde_json::to_value(openapi()).expect("OpenAPI should serialize"); + for property_name in [ + "Interactive", + "SkipHashCheck", + "PreRelease", + "HasCustomParameters", + "HasCustomInstallLocation", + "HasPrePostCommands", + "HasKillBeforeOperation", + "HasUninstallPrevious", + ] { + let property = &api["components"]["schemas"]["PolicyModelPolicyRule"]["properties"]["Match"]["properties"] + [property_name]; + let types = property["type"] + .as_array() + .unwrap_or_else(|| panic!("PolicyMatch.{property_name} should have an OpenAPI 3.1 type union")); + + assert!(types.iter().any(|value| value == "boolean")); + assert!(types.iter().any(|value| value == "null")); + assert!(property.get("items").is_none()); + assert!(property.get("maxItems").is_none()); + assert!(property.get("uniqueItems").is_none()); + } + } + #[test] fn policy_openapi_exposes_management_request_body_limit() { let api = serde_json::to_value(openapi()).expect("OpenAPI should serialize"); diff --git a/policies/rust/now-policy/README.md b/policies/rust/now-policy/README.md index ee3ecdb..ac3be66 100644 --- a/policies/rust/now-policy/README.md +++ b/policies/rust/now-policy/README.md @@ -8,4 +8,18 @@ Broker request, response, server, transport, and execution types are intentional `PolicyFormatVersion` is a software-managed document-format marker, not a publisher release version. Applications stamp the current `1.0.0` value when creating documents, accept and preserve supported numeric versions in the 1.x line, reject malformed or unsupported-major values, and must not expose it as publisher-authored editable metadata. Policy documents do not contain a `$schema` member; documents that contain it are rejected as unknown-field input under the strict contract. -`parse_policy_yaml` was intentionally removed as a breaking change. OpenAPI YAML generation and unrelated YAML inputs are unaffected. +`Metadata.ValidFrom` and `Metadata.ValidUntil` are optional operational UTC instants, not informational labels. Omitted or explicit `null` leaves that side unbounded, and canonical output omits an absent bound. When both are present, `ValidFrom` must be strictly earlier than `ValidUntil`; comparisons normalize offsets by instant. Brokers fail closed for every request: reject operations when the current instant is before `ValidFrom` or after `ValidUntil`, with no fallback policy. + +`PolicyMatch` boolean request characteristics (`Interactive`, `SkipHashCheck`, `PreRelease`, `HasCustomParameters`, `HasCustomInstallLocation`, `HasPrePostCommands`, `HasKillBeforeOperation`, and `HasUninstallPrevious`) are optional scalar booleans. Omitted or explicit `null` means the characteristic does not affect whether the rule matches; `false` and `true` require that exact request characteristic. Canonical serialization omits absent values. Legacy boolean arrays are rejected. A rule's `Match` must still contain at least one effective non-null, nonempty criterion. + +Collection-valued match filters accept omission or an explicit empty array as unrestricted input, but canonical serialization omits empty collections. The former `PackageNames` filter is rejected as unknown input. `PackageIdentifiers` has exactly one explicit mode: `Exact` contains validated stable identifiers and rejects wildcard characters, while `Patterns` contains deliberately broad wildcard patterns that may authorize multiple identifiers. `SourceNames` contains exact configured package source names, not URLs or wildcard patterns. Source-name comparison follows the selected package manager's semantics, wildcard characters are literal, and nonempty `SourceNames` requires exactly one `Managers` value. + +`Version` also has exactly one explicit mode: `Exact` contains one or more arbitrary package version strings, including non-SemVer versions, while `Range` contains the semantic-version range. Omitted or explicit `null` does not narrow matching. The former `Versions` and `VersionRange` match properties are rejected as unknown input. + +`ExecutionElevation` matches the request's effective execution privilege, not only the client's requested elevation. Brokers must use `Elevated` when the package operation will run with administrator privileges—currently when `Scope` is `Machine` or `Client.RequestedElevation` is `Elevated`—and `Standard` otherwise. The former `Elevation` match property is rejected as unknown input. + +Rule precedence is fixed by the policy format and is not represented by a JSON field: lower `Priority` values evaluate first, `Deny` wins equal-priority Allow/Deny ties, and remaining equal-priority ties retain document order. `Constraints` are additional safety limits for an `Allow` rule and are invalid on `Deny` rules. Editors may host incomplete blank rules transiently, but must not serialize or save a rule until `Match` contains an effective condition. + +`parse_policy_yaml` was intentionally removed as a breaking change. OpenAPI YAML generation and unrelated YAML inputs are unaffected. Boolean match arrays and the former `PolicyType`, `RulePrecedence`, and `PackageNames` members are rejected rather than converted. + +Gateway consumers must preserve the fixed priority/deny-tie/document-order evaluator semantics, omit constraints on Deny rules, compute `ExecutionElevation` from effective execution privilege, adopt the explicit package/version/source modes, retain per-request UTC validity enforcement, and add exact `ValidFrom`/`ValidUntil` boundary tests if missing. UniGetUI uses date/time controls with inline `ValidFrom < ValidUntil` validation. Editors may host incomplete rules transiently but must not serialize or save them; friendly-name search must resolve to stable identifiers rather than emit a `PackageNames` condition. diff --git a/policies/rust/now-policy/assets/samples/boolean-characteristics.policy.json b/policies/rust/now-policy/assets/samples/boolean-characteristics.policy.json new file mode 100644 index 0000000..38fee4c --- /dev/null +++ b/policies/rust/now-policy/assets/samples/boolean-characteristics.policy.json @@ -0,0 +1,52 @@ +{ + "PolicyFormatVersion": "1.0.0", + "Metadata": { + "Id": "boolean-characteristics", + "Publisher": "Devolutions", + "Revision": 1, + "PublishedAt": "2026-09-17T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny" + }, + "Rules": [ + { + "Id": "mixed.boolean-characteristics", + "Enabled": true, + "Priority": 100, + "Decision": "Allow", + "Match": { + "Operations": [ + "Install" + ], + "Managers": [ + "Winget" + ], + "SourceNames": [ + "corp*" + ], + "PackageIdentifiers": { + "Exact": [ + "Microsoft.VisualStudioCode" + ] + }, + "Version": { + "Exact": [ + "5.6.0.0" + ] + }, + "ExecutionElevation": [ + "Elevated" + ], + "Interactive": false, + "SkipHashCheck": true, + "PreRelease": false, + "HasCustomParameters": true, + "HasCustomInstallLocation": false, + "HasPrePostCommands": true, + "HasKillBeforeOperation": false, + "HasUninstallPrevious": true + } + } + ] +} diff --git a/policies/rust/now-policy/assets/samples/corporate-allowlist.policy.json b/policies/rust/now-policy/assets/samples/corporate-allowlist.policy.json index e0da844..f733095 100644 --- a/policies/rust/now-policy/assets/samples/corporate-allowlist.policy.json +++ b/policies/rust/now-policy/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,14 @@ "Managers": [ "Winget" ], - "Sources": [ + "SourceNames": [ "winget" ], - "PackageIdentifiers": [ - "Microsoft.VisualStudioCode" - ], + "PackageIdentifiers": { + "Exact": [ + "Microsoft.VisualStudioCode" + ] + }, "Scopes": [ "User", "Machine" @@ -105,12 +99,14 @@ "Managers": [ "Winget" ], - "Sources": [ + "SourceNames": [ "winget" ], - "PackageIdentifiers": [ - "Microsoft.PowerToys" - ], + "PackageIdentifiers": { + "Exact": [ + "Microsoft.PowerToys" + ] + }, "Scopes": [ "User", "Machine" @@ -130,4 +126,4 @@ } } ] -} \ No newline at end of file +} diff --git a/policies/rust/now-policy/assets/samples/deny-risky-options.policy.json b/policies/rust/now-policy/assets/samples/deny-risky-options.policy.json index 105eebf..5bd2b9a 100644 --- a/policies/rust/now-policy/assets/samples/deny-risky-options.policy.json +++ b/policies/rust/now-policy/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,7 +60,7 @@ "Managers": [ "Winget" ], - "Sources": [ + "SourceNames": [ "msstore", "winget-fonts" ] diff --git a/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-boolean-match-field.policy.json b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-boolean-match-field.policy.json new file mode 100644 index 0000000..b4240f1 --- /dev/null +++ b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-boolean-match-field.policy.json @@ -0,0 +1,23 @@ +{ + "PolicyFormatVersion": "1.0.0", + "Metadata": { + "Id": "duplicate.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny" + }, + "Rules": [ + { + "Id": "duplicate.boolean-match", + "Priority": 1, + "Decision": "Deny", + "Match": { + "Interactive": false, + "\u0049nteractive": true + } + } + ] +} diff --git a/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-constraints-field.policy.json b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-constraints-field.policy.json new file mode 100644 index 0000000..2ec9f10 --- /dev/null +++ b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-constraints-field.policy.json @@ -0,0 +1,26 @@ +{ + "PolicyFormatVersion": "1.0.0", + "Metadata": { + "Id": "duplicate.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny" + }, + "Rules": [ + { + "Id": "duplicate.constraints", + "Priority": 1, + "Decision": "Allow", + "Match": { + "Operations": ["Install"] + }, + "Constraints": { + "AllowedCustomParameters": ["--quiet"], + "AllowedCustomParameters": ["--silent"] + } + } + ] +} diff --git a/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-match-field.policy.json b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-match-field.policy.json new file mode 100644 index 0000000..26aa6a4 --- /dev/null +++ b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-match-field.policy.json @@ -0,0 +1,23 @@ +{ + "PolicyFormatVersion": "1.0.0", + "Metadata": { + "Id": "duplicate.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny" + }, + "Rules": [ + { + "Id": "duplicate.match", + "Priority": 1, + "Decision": "Deny", + "Match": { + "Operations": ["Install"], + "Operations": ["Update"] + } + } + ] +} diff --git a/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-metadata-id.policy.json b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-metadata-id.policy.json new file mode 100644 index 0000000..8c4e3d3 --- /dev/null +++ b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-metadata-id.policy.json @@ -0,0 +1,14 @@ +{ + "PolicyFormatVersion": "1.0.0", + "Metadata": { + "Id": "duplicate.test", + "\u0049d": "duplicate.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny" + }, + "Rules": [] +} diff --git a/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-metadata-publisher.policy.json b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-metadata-publisher.policy.json new file mode 100644 index 0000000..fe6c531 --- /dev/null +++ b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-metadata-publisher.policy.json @@ -0,0 +1,14 @@ +{ + "PolicyFormatVersion": "1.0.0", + "Metadata": { + "Id": "duplicate.test", + "Publisher": "First", + "Publisher": "Second", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny" + }, + "Rules": [] +} diff --git a/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-policy-format-version-conflicting.policy.json b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-policy-format-version-conflicting.policy.json new file mode 100644 index 0000000..06ce143 --- /dev/null +++ b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-policy-format-version-conflicting.policy.json @@ -0,0 +1,14 @@ +{ + "PolicyFormatVersion": "1.0.0", + "PolicyFormatVersion": "1.1.0", + "Metadata": { + "Id": "duplicate.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny" + }, + "Rules": [] +} diff --git a/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-policy-format-version-escaped.policy.json b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-policy-format-version-escaped.policy.json new file mode 100644 index 0000000..187a208 --- /dev/null +++ b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-policy-format-version-escaped.policy.json @@ -0,0 +1,14 @@ +{ + "PolicyFormatVersion": "1.0.0", + "PolicyFormatVersi\u006fn": "1.0.0", + "Metadata": { + "Id": "duplicate.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny" + }, + "Rules": [] +} diff --git a/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-policy-format-version-same.policy.json b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-policy-format-version-same.policy.json new file mode 100644 index 0000000..df85433 --- /dev/null +++ b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-policy-format-version-same.policy.json @@ -0,0 +1,14 @@ +{ + "PolicyFormatVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", + "Metadata": { + "Id": "duplicate.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny" + }, + "Rules": [] +} diff --git a/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-rule-id.policy.json b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-rule-id.policy.json new file mode 100644 index 0000000..bf4c20c --- /dev/null +++ b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-rule-id.policy.json @@ -0,0 +1,23 @@ +{ + "PolicyFormatVersion": "1.0.0", + "Metadata": { + "Id": "duplicate.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny" + }, + "Rules": [ + { + "Id": "first.rule", + "Id": "second.rule", + "Priority": 1, + "Decision": "Deny", + "Match": { + "Operations": ["Install"] + } + } + ] +} diff --git a/policies/rust/now-policy/assets/samples/invalid/policies/invalid-failure-decision.policy.json b/policies/rust/now-policy/assets/samples/invalid/policies/invalid-failure-decision.policy.json index 6a744a9..521f8ab 100644 --- a/policies/rust/now-policy/assets/samples/invalid/policies/invalid-failure-decision.policy.json +++ b/policies/rust/now-policy/assets/samples/invalid/policies/invalid-failure-decision.policy.json @@ -1,6 +1,5 @@ { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.invalid.failure-decision", "Publisher": "Contoso IT", @@ -9,8 +8,7 @@ }, "Enforcement": { "DefaultDecision": "Allow", - "failureDecision": "Allow", - "RulePrecedence": "PriorityThenDeny" + "failureDecision": "Allow" }, "Rules": [ { @@ -18,10 +16,12 @@ "Priority": 100, "Decision": "Allow", "Match": { - "PackageIdentifiers": [ - "*" - ] + "PackageIdentifiers": { + "Patterns": [ + "*" + ] + } } } ] -} \ No newline at end of file +} diff --git a/policies/rust/now-policy/assets/samples/powershell-advanced.policy.json b/policies/rust/now-policy/assets/samples/powershell-advanced.policy.json index f685df2..6e034e6 100644 --- a/policies/rust/now-policy/assets/samples/powershell-advanced.policy.json +++ b/policies/rust/now-policy/assets/samples/powershell-advanced.policy.json @@ -1,6 +1,5 @@ { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.powershell.advanced-scenarios", "Publisher": "Contoso IT", @@ -9,8 +8,7 @@ "Description": "PowerShell policy fixture for source, version range, and update operation coverage." }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "DefaultDecision": "Deny" }, "Rules": [ { @@ -22,7 +20,7 @@ "Managers": [ "PowerShell" ], - "Sources": [ + "SourceNames": [ "PoshTestGallery" ] } @@ -36,9 +34,7 @@ "Managers": [ "PowerShell" ], - "PreRelease": [ - true - ] + "PreRelease": true } }, { @@ -54,16 +50,20 @@ "Managers": [ "PowerShell" ], - "Sources": [ + "SourceNames": [ "PSGallery" ], - "PackageIdentifiers": [ - "Pester" - ], - "VersionRange": { - "MinVersion": "5.0.0", - "MaxVersion": "6.0.0", - "IncludePrerelease": false + "PackageIdentifiers": { + "Exact": [ + "Pester" + ] + }, + "Version": { + "Range": { + "MinVersion": "5.0.0", + "MaxVersion": "6.0.0", + "IncludePrerelease": false + } }, "Scopes": [ "User" @@ -78,4 +78,4 @@ } } ] -} \ No newline at end of file +} diff --git a/policies/rust/now-policy/assets/samples/powershell-current-user.policy.json b/policies/rust/now-policy/assets/samples/powershell-current-user.policy.json index ccef4c1..bbc0bdb 100644 --- a/policies/rust/now-policy/assets/samples/powershell-current-user.policy.json +++ b/policies/rust/now-policy/assets/samples/powershell-current-user.policy.json @@ -1,6 +1,5 @@ { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.powershell.current-user-modules", "Publisher": "Contoso IT", @@ -9,8 +8,7 @@ "Description": "PowerShell Gallery module policy for non-admin CurrentUser installs." }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "DefaultDecision": "Deny" }, "Rules": [ { @@ -36,7 +34,7 @@ "Managers": [ "PowerShell" ], - "Elevation": [ + "ExecutionElevation": [ "Elevated" ] } @@ -50,9 +48,7 @@ "Managers": [ "PowerShell" ], - "PreRelease": [ - true - ] + "PreRelease": true } }, { @@ -68,12 +64,14 @@ "Managers": [ "PowerShell" ], - "Sources": [ + "SourceNames": [ "PSGallery" ], - "PackageIdentifiers": [ - "Pester" - ], + "PackageIdentifiers": { + "Exact": [ + "Pester" + ] + }, "Scopes": [ "User" ] @@ -87,4 +85,4 @@ } } ] -} \ No newline at end of file +} diff --git a/policies/rust/now-policy/assets/samples/scenario-coverage.policy.json b/policies/rust/now-policy/assets/samples/scenario-coverage.policy.json index 4ddb628..f2e1740 100644 --- a/policies/rust/now-policy/assets/samples/scenario-coverage.policy.json +++ b/policies/rust/now-policy/assets/samples/scenario-coverage.policy.json @@ -1,6 +1,5 @@ { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.desktop.scenario-coverage", "Publisher": "Contoso IT", @@ -9,8 +8,7 @@ "Description": "Focused policy used to exercise simulator precedence, version, and constraint behavior." }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "DefaultDecision": "Deny" }, "Rules": [ { @@ -23,9 +21,11 @@ "Managers": [ "Winget" ], - "PackageIdentifiers": [ - "Microsoft.PowerToys" - ] + "PackageIdentifiers": { + "Exact": [ + "Microsoft.PowerToys" + ] + } } }, { @@ -34,9 +34,7 @@ "Decision": "Deny", "Reason": "Interactive brokered installs are not allowed in the scenario coverage policy.", "Match": { - "Interactive": [ - true - ] + "Interactive": true } }, { @@ -48,12 +46,12 @@ "Managers": [ "Winget" ], - "PackageIdentifiers": [ - "Microsoft.VisualStudioCode" - ], - "HasCustomParameters": [ - true - ] + "PackageIdentifiers": { + "Exact": [ + "Microsoft.VisualStudioCode" + ] + }, + "HasCustomParameters": true } }, { @@ -65,12 +63,12 @@ "Managers": [ "Winget" ], - "PackageIdentifiers": [ - "Microsoft.VisualStudioCode" - ], - "HasCustomParameters": [ - true - ] + "PackageIdentifiers": { + "Exact": [ + "Microsoft.VisualStudioCode" + ] + }, + "HasCustomParameters": true }, "Constraints": { "AllowCustomParameters": true @@ -82,9 +80,7 @@ "Decision": "Deny", "Reason": "Pre and post commands are outside the package manager trust boundary.", "Match": { - "HasPrePostCommands": [ - true - ] + "HasPrePostCommands": true } }, { @@ -93,9 +89,7 @@ "Decision": "Deny", "Reason": "Killing processes before a brokered operation is not allowed.", "Match": { - "HasKillBeforeOperation": [ - true - ] + "HasKillBeforeOperation": true } }, { @@ -111,12 +105,14 @@ "Managers": [ "Winget" ], - "Sources": [ + "SourceNames": [ "winget" ], - "PackageIdentifiers": [ - "Microsoft.PowerToys" - ], + "PackageIdentifiers": { + "Exact": [ + "Microsoft.PowerToys" + ] + }, "Scopes": [ "Machine" ], @@ -146,16 +142,20 @@ "Managers": [ "Winget" ], - "Sources": [ + "SourceNames": [ "winget" ], - "PackageIdentifiers": [ - "Microsoft.VisualStudioCode" - ], - "VersionRange": { - "MinVersion": "1.90.0", - "MaxVersion": "2.0.0", - "IncludePrerelease": false + "PackageIdentifiers": { + "Exact": [ + "Microsoft.VisualStudioCode" + ] + }, + "Version": { + "Range": { + "MinVersion": "1.90.0", + "MaxVersion": "2.0.0", + "IncludePrerelease": false + } }, "Scopes": [ "Machine" @@ -186,12 +186,14 @@ "Managers": [ "Winget" ], - "Sources": [ + "SourceNames": [ "winget" ], - "PackageIdentifiers": [ - "Git.Git" - ], + "PackageIdentifiers": { + "Exact": [ + "Git.Git" + ] + }, "Scopes": [ "Machine" ], @@ -230,12 +232,14 @@ "Managers": [ "Winget" ], - "Sources": [ + "SourceNames": [ "winget" ], - "PackageIdentifiers": [ - "Git.Git" - ], + "PackageIdentifiers": { + "Exact": [ + "Git.Git" + ] + }, "Scopes": [ "Machine" ], @@ -253,4 +257,4 @@ } } ] -} \ No newline at end of file +} diff --git a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json index c011677..1edb1ba 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json @@ -27,7 +27,7 @@ "type": "string" }, "Elevation": { - "description": "Requested elevation level.", + "description": "Effective package-operation execution privilege.", "enum": [ "Standard", "Elevated" @@ -72,15 +72,57 @@ ], "type": "string" }, - "PackageBrokerPolicy": { - "enum": [ - "PackageBrokerPolicy" - ], + "PackageIdentifier": { + "description": "Exact stable package identifier used by requests and exact policy matching.\n\n Manager-specific punctuation used by real identifiers is accepted, while\n wildcard characters and range/pin operators are rejected.", + "maxLength": 256, + "minLength": 1, + "pattern": "^[A-Za-z0-9._+@/:\\[\\],#$%{}-]+(?![\\s\\S])", "type": "string" }, + "PackageIdentifierCondition": { + "description": "Exactly one package-identifier mode. Exact authorizes stable identifiers; Patterns explicitly authorizes every identifier matched by a wildcard pattern.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "Exact": { + "items": { + "$ref": "#/definitions/PackageIdentifier" + }, + "maxItems": 1024, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "Exact" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "Patterns": { + "items": { + "$ref": "#/definitions/StringPattern" + }, + "maxItems": 1024, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "Patterns" + ], + "type": "object" + } + ] + }, "PolicyConstraints": { "additionalProperties": false, - "description": "Constraints applied after a rule matches.", + "description": "Additional safety limits applied after an Allow rule matches.", "properties": { "AllowCustomInstallLocation": { "description": "Allow custom install location.", @@ -155,7 +197,7 @@ }, "PolicyDraftMetadata": { "additionalProperties": false, - "description": "Editable policy metadata without server-managed revision and publication time.", + "description": "Editable policy metadata without server-managed revision and publication time. When both\n validity bounds are present, `ValidFrom` must be strictly earlier than `ValidUntil`.", "properties": { "Description": { "description": "Human-readable description.", @@ -191,7 +233,7 @@ "description": "URL for support or documentation." }, "ValidFrom": { - "description": "Policy becomes active at this time.", + "description": "Earliest instant when the policy is active. When both bounds are present, this must be\n strictly earlier than `ValidUntil`.", "format": "date-time", "type": [ "string", @@ -199,7 +241,7 @@ ] }, "ValidUntil": { - "description": "Policy expires at this time.", + "description": "Instant after which the policy is inactive. When both bounds are present, this must be\n strictly later than `ValidFrom`.", "format": "date-time", "type": [ "string", @@ -215,7 +257,7 @@ }, "PolicyEnforcement": { "additionalProperties": false, - "description": "Enforcement configuration.", + "description": "Enforcement configuration.\n\n Matching rules are evaluated by ascending priority. Deny wins equal-priority\n Allow/Deny ties; remaining equal-priority ties retain document order.", "properties": { "AuditMode": { "description": "When true, broker logs decisions but does not enforce.", @@ -231,19 +273,10 @@ } ], "description": "Decision when no rule matches." - }, - "RulePrecedence": { - "allOf": [ - { - "$ref": "#/definitions/RulePrecedence" - } - ], - "description": "Rule precedence strategy (must be \"PriorityThenDeny\")." } }, "required": [ - "DefaultDecision", - "RulePrecedence" + "DefaultDecision" ], "type": "object" }, @@ -253,180 +286,25 @@ "pattern": "^1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?![\\s\\S])", "type": "string" }, - "PolicyMatch": { - "additionalProperties": false, - "description": "Match criteria for a policy rule. All specified fields must match.\n At least one field must be present.", - "properties": { - "Architectures": { - "description": "Allowed architectures.", - "items": { - "$ref": "#/definitions/Architecture" - }, - "maxItems": 5, - "type": "array", - "uniqueItems": true - }, - "Elevation": { - "description": "Allowed elevation levels.", - "items": { - "$ref": "#/definitions/Elevation" - }, - "maxItems": 2, - "type": "array", - "uniqueItems": true - }, - "HasCustomInstallLocation": { - "description": "Whether request has custom install location.", - "items": { - "type": "boolean" - }, - "maxItems": 1, - "type": "array", - "uniqueItems": true - }, - "HasCustomParameters": { - "description": "Whether request has custom parameters.", - "items": { - "type": "boolean" - }, - "maxItems": 1, - "type": "array", - "uniqueItems": true - }, - "HasKillBeforeOperation": { - "description": "Whether request has kill-before-operation entries.", - "items": { - "type": "boolean" - }, - "maxItems": 1, - "type": "array", - "uniqueItems": true - }, - "HasPrePostCommands": { - "description": "Whether request has pre/post operation commands.", - "items": { - "type": "boolean" - }, - "maxItems": 1, - "type": "array", - "uniqueItems": true - }, - "HasUninstallPrevious": { - "description": "Whether request has uninstall-previous flag set.", - "items": { - "type": "boolean" - }, - "maxItems": 1, - "type": "array", - "uniqueItems": true - }, - "Interactive": { - "description": "Allowed interactive values.", - "items": { - "type": "boolean" - }, - "maxItems": 1, - "type": "array", - "uniqueItems": true - }, - "Managers": { - "description": "Allowed managers.", - "items": { - "$ref": "#/definitions/ManagerName" - }, - "maxItems": 16, - "type": "array", - "uniqueItems": true - }, - "Operations": { - "description": "Allowed operations.", - "items": { - "$ref": "#/definitions/Operation" - }, - "maxItems": 3, - "type": "array", - "uniqueItems": true - }, - "PackageIdentifiers": { - "description": "Package identifier patterns (wildcard).", - "items": { - "$ref": "#/definitions/StringPattern" - }, - "maxItems": 1024, - "type": "array", - "uniqueItems": true - }, - "PackageNames": { - "description": "Package name patterns (wildcard).", - "items": { - "$ref": "#/definitions/StringPattern" - }, - "maxItems": 1024, - "type": "array", - "uniqueItems": true - }, - "PreRelease": { - "description": "Allowed preRelease values.", - "items": { - "type": "boolean" - }, - "maxItems": 1, - "type": "array", - "uniqueItems": true - }, - "Scopes": { - "description": "Allowed scopes.", - "items": { - "$ref": "#/definitions/Scope" - }, - "maxItems": 2, - "type": "array", - "uniqueItems": true - }, - "SkipHashCheck": { - "description": "Allowed skipHashCheck values.", - "items": { - "type": "boolean" - }, - "maxItems": 1, - "type": "array", - "uniqueItems": true - }, - "Sources": { - "description": "Source patterns (wildcard).", - "items": { - "$ref": "#/definitions/StringPattern" - }, - "maxItems": 128, - "type": "array", - "uniqueItems": true - }, - "VersionRange": { - "anyOf": [ - { - "$ref": "#/definitions/VersionRange" - }, - { - "type": "null" - } - ], - "description": "Semantic version range." - }, - "Versions": { - "description": "Exact version list.", - "items": { - "$ref": "#/definitions/VersionString" - }, - "maxItems": 256, - "type": "array", - "uniqueItems": true - } - }, - "type": "object" - }, "PolicyRule": { "additionalProperties": false, "description": "A single policy rule.", + "not": { + "properties": { + "Constraints": { + "type": "object" + }, + "Decision": { + "enum": [ + "Deny" + ] + } + }, + "required": [ + "Decision", + "Constraints" + ] + }, "properties": { "Constraints": { "anyOf": [ @@ -437,7 +315,7 @@ "type": "null" } ], - "description": "Additional constraints applied after matching.\n When absent, no constraints are enforced beyond the match criteria." + "description": "Additional safety limits applied after an Allow rule matches.\n Constraints are invalid on Deny rules." }, "Decision": { "allOf": [ @@ -461,13 +339,327 @@ "description": "Unique rule identifier." }, "Match": { - "allOf": [ + "additionalProperties": false, + "anyOf": [ + { + "properties": { + "Operations": { + "minItems": 1 + } + }, + "required": [ + "Operations" + ] + }, + { + "properties": { + "Managers": { + "minItems": 1 + } + }, + "required": [ + "Managers" + ] + }, + { + "properties": { + "SourceNames": { + "minItems": 1 + } + }, + "required": [ + "SourceNames" + ] + }, + { + "properties": { + "PackageIdentifiers": { + "type": "object" + } + }, + "required": [ + "PackageIdentifiers" + ] + }, + { + "properties": { + "Version": { + "type": "object" + } + }, + "required": [ + "Version" + ] + }, + { + "properties": { + "Scopes": { + "minItems": 1 + } + }, + "required": [ + "Scopes" + ] + }, { - "$ref": "#/definitions/PolicyMatch" + "properties": { + "Architectures": { + "minItems": 1 + } + }, + "required": [ + "Architectures" + ] + }, + { + "properties": { + "ExecutionElevation": { + "minItems": 1 + } + }, + "required": [ + "ExecutionElevation" + ] + }, + { + "properties": { + "Interactive": { + "type": "boolean" + } + }, + "required": [ + "Interactive" + ] + }, + { + "properties": { + "SkipHashCheck": { + "type": "boolean" + } + }, + "required": [ + "SkipHashCheck" + ] + }, + { + "properties": { + "PreRelease": { + "type": "boolean" + } + }, + "required": [ + "PreRelease" + ] + }, + { + "properties": { + "HasCustomParameters": { + "type": "boolean" + } + }, + "required": [ + "HasCustomParameters" + ] + }, + { + "properties": { + "HasCustomInstallLocation": { + "type": "boolean" + } + }, + "required": [ + "HasCustomInstallLocation" + ] + }, + { + "properties": { + "HasPrePostCommands": { + "type": "boolean" + } + }, + "required": [ + "HasPrePostCommands" + ] + }, + { + "properties": { + "HasKillBeforeOperation": { + "type": "boolean" + } + }, + "required": [ + "HasKillBeforeOperation" + ] + }, + { + "properties": { + "HasUninstallPrevious": { + "type": "boolean" + } + }, + "required": [ + "HasUninstallPrevious" + ] } ], - "description": "Match criteria — request must satisfy all specified fields.\n At least one criterion must be present.", - "minProperties": 1 + "description": "Match criteria — request must satisfy all specified fields.\n At least one effective non-null, nonempty criterion must be present.", + "if": { + "properties": { + "SourceNames": { + "minItems": 1 + } + }, + "required": [ + "SourceNames" + ] + }, + "minProperties": 1, + "properties": { + "Architectures": { + "description": "Optional architecture filter. Omitted or empty does not narrow matching;\n canonical serialization omits an empty collection.", + "items": { + "$ref": "#/definitions/Architecture" + }, + "maxItems": 5, + "type": "array", + "uniqueItems": true + }, + "ExecutionElevation": { + "description": "Optional effective execution-elevation filter. Elevated means the package operation\n will run with administrator privileges; Standard means it will not. Omitted or empty\n does not narrow matching; canonical serialization omits an empty collection.", + "items": { + "$ref": "#/definitions/Elevation" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "HasCustomInstallLocation": { + "description": "Optional condition on whether the request has a custom install location.\n Absent means this characteristic does not affect matching.", + "type": [ + "boolean", + "null" + ] + }, + "HasCustomParameters": { + "description": "Optional condition on whether the request has custom parameters.\n Absent means this characteristic does not affect matching.", + "type": [ + "boolean", + "null" + ] + }, + "HasKillBeforeOperation": { + "description": "Optional condition on whether the request has kill-before-operation entries.\n Absent means this characteristic does not affect matching.", + "type": [ + "boolean", + "null" + ] + }, + "HasPrePostCommands": { + "description": "Optional condition on whether the request has pre/post commands.\n Absent means this characteristic does not affect matching.", + "type": [ + "boolean", + "null" + ] + }, + "HasUninstallPrevious": { + "description": "Optional condition on whether the request enables uninstall-previous.\n Absent means this characteristic does not affect matching.", + "type": [ + "boolean", + "null" + ] + }, + "Interactive": { + "description": "Optional condition on the request's interactive characteristic.\n Absent means this characteristic does not affect matching.", + "type": [ + "boolean", + "null" + ] + }, + "Managers": { + "description": "Optional manager filter. Omitted or empty does not narrow matching;\n canonical serialization omits an empty collection.", + "items": { + "$ref": "#/definitions/ManagerName" + }, + "maxItems": 16, + "type": "array", + "uniqueItems": true + }, + "Operations": { + "description": "Optional operation filter. Omitted or empty does not narrow matching;\n canonical serialization omits an empty collection.", + "items": { + "$ref": "#/definitions/Operation" + }, + "maxItems": 3, + "type": "array", + "uniqueItems": true + }, + "PackageIdentifiers": { + "anyOf": [ + { + "$ref": "#/definitions/PackageIdentifierCondition" + }, + { + "type": "null" + } + ], + "description": "Optional package-identifier condition. Exact uses validated stable identifiers; Patterns\n uses explicit wildcard patterns that may authorize multiple packages. Absent does not\n narrow matching." + }, + "PreRelease": { + "description": "Optional condition on the request's preRelease characteristic.\n Absent means this characteristic does not affect matching.", + "type": [ + "boolean", + "null" + ] + }, + "Scopes": { + "description": "Optional scope filter. Omitted or empty does not narrow matching;\n canonical serialization omits an empty collection.", + "items": { + "$ref": "#/definitions/Scope" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "SkipHashCheck": { + "description": "Optional condition on the request's skipHashCheck characteristic.\n Absent means this characteristic does not affect matching.", + "type": [ + "boolean", + "null" + ] + }, + "SourceNames": { + "description": "Optional exact configured-source-name filter. Matching uses the selected package manager's\n source-name comparison semantics; wildcard characters are literal. Nonempty source names\n require exactly one manager. Omitted or empty does not narrow matching; canonical\n serialization omits an empty collection.", + "items": { + "$ref": "#/definitions/SourceName" + }, + "maxItems": 128, + "type": "array", + "uniqueItems": true + }, + "Version": { + "anyOf": [ + { + "$ref": "#/definitions/VersionCondition" + }, + { + "type": "null" + } + ], + "description": "Optional package-version condition. Exact supports arbitrary package version strings;\n Range applies only to semantic versions. Absent does not narrow matching." + } + }, + "then": { + "properties": { + "Managers": { + "maxItems": 1, + "minItems": 1 + } + }, + "required": [ + "Managers" + ] + }, + "type": "object" }, "Priority": { "description": "Priority (lower = higher precedence).", @@ -499,13 +691,6 @@ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:\\-]{0,127}$", "type": "string" }, - "RulePrecedence": { - "description": "Rule precedence strategy — always PriorityThenDeny.", - "enum": [ - "PriorityThenDeny" - ], - "type": "string" - }, "Scope": { "description": "Package installation scope.", "enum": [ @@ -514,15 +699,84 @@ ], "type": "string" }, + "SemanticVersion": { + "description": "Semantic version string (SemVer 2.0.0).\n\n Validated at deserialization time using the `semver` crate.", + "maxLength": 128, + "pattern": "^(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-4]|18446744073709551615)\\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-4]|18446744073709551615)\\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-4]|18446744073709551615)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?![\\s\\S])", + "type": "string" + }, + "SourceName": { + "description": "Exact configured package source name.\n\n Matching uses the selected package manager's source-name comparison semantics.\n Wildcard characters have no special meaning and are treated literally.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, "StringPattern": { "description": "Case-insensitive exact value or wildcard pattern.", "maxLength": 256, "minLength": 1, "type": "string" }, + "VersionCondition": { + "description": "Exactly one package-version mode. Exact accepts arbitrary package version strings; Range applies only to semantic versions.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "Exact": { + "items": { + "$ref": "#/definitions/VersionString" + }, + "maxItems": 256, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "Exact" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "Range": { + "$ref": "#/definitions/VersionRange" + } + }, + "required": [ + "Range" + ], + "type": "object" + } + ] + }, "VersionRange": { "additionalProperties": false, - "description": "Semantic version range for matching.", + "anyOf": [ + { + "properties": { + "MinVersion": { + "type": "string" + } + }, + "required": [ + "MinVersion" + ] + }, + { + "properties": { + "MaxVersion": { + "type": "string" + } + }, + "required": [ + "MaxVersion" + ] + } + ], + "description": "Nonempty semantic-version range for matching.", "properties": { "IncludePrerelease": { "default": false, @@ -530,22 +784,26 @@ "type": "boolean" }, "MaxVersion": { - "description": "Maximum version (inclusive).", - "maxLength": 128, - "minLength": 1, - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/SemanticVersion" + }, + { + "type": "null" + } + ], + "description": "Maximum version (inclusive)." }, "MinVersion": { - "description": "Minimum version (inclusive).", - "maxLength": 128, - "minLength": 1, - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/SemanticVersion" + }, + { + "type": "null" + } + ], + "description": "Minimum version (inclusive)." } }, "type": "object" @@ -583,14 +841,6 @@ ], "description": "Software-managed policy document format version.\n\n Applications must stamp the current value and must not expose this field\n as publisher-authored editable metadata." }, - "PolicyType": { - "allOf": [ - { - "$ref": "#/definitions/PackageBrokerPolicy" - } - ], - "description": "Must be `\"PackageBrokerPolicy\"`." - }, "Rules": { "description": "Ordered list of policy rules (may be empty; enforcement defaults apply).", "items": { @@ -602,7 +852,6 @@ }, "required": [ "PolicyFormatVersion", - "PolicyType", "Metadata", "Enforcement", "Rules" diff --git a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json index fc1a12c..428db05 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -27,7 +27,7 @@ "type": "string" }, "Elevation": { - "description": "Requested elevation level.", + "description": "Effective package-operation execution privilege.", "enum": [ "Standard", "Elevated" @@ -72,15 +72,57 @@ ], "type": "string" }, - "PackageBrokerPolicy": { - "enum": [ - "PackageBrokerPolicy" - ], + "PackageIdentifier": { + "description": "Exact stable package identifier used by requests and exact policy matching.\n\n Manager-specific punctuation used by real identifiers is accepted, while\n wildcard characters and range/pin operators are rejected.", + "maxLength": 256, + "minLength": 1, + "pattern": "^[A-Za-z0-9._+@/:\\[\\],#$%{}-]+(?![\\s\\S])", "type": "string" }, + "PackageIdentifierCondition": { + "description": "Exactly one package-identifier mode. Exact authorizes stable identifiers; Patterns explicitly authorizes every identifier matched by a wildcard pattern.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "Exact": { + "items": { + "$ref": "#/definitions/PackageIdentifier" + }, + "maxItems": 1024, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "Exact" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "Patterns": { + "items": { + "$ref": "#/definitions/StringPattern" + }, + "maxItems": 1024, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "Patterns" + ], + "type": "object" + } + ] + }, "PolicyConstraints": { "additionalProperties": false, - "description": "Constraints applied after a rule matches.", + "description": "Additional safety limits applied after an Allow rule matches.", "properties": { "AllowCustomInstallLocation": { "description": "Allow custom install location.", @@ -155,7 +197,7 @@ }, "PolicyEnforcement": { "additionalProperties": false, - "description": "Enforcement configuration.", + "description": "Enforcement configuration.\n\n Matching rules are evaluated by ascending priority. Deny wins equal-priority\n Allow/Deny ties; remaining equal-priority ties retain document order.", "properties": { "AuditMode": { "description": "When true, broker logs decisions but does not enforce.", @@ -171,19 +213,10 @@ } ], "description": "Decision when no rule matches." - }, - "RulePrecedence": { - "allOf": [ - { - "$ref": "#/definitions/RulePrecedence" - } - ], - "description": "Rule precedence strategy (must be \"PriorityThenDeny\")." } }, "required": [ - "DefaultDecision", - "RulePrecedence" + "DefaultDecision" ], "type": "object" }, @@ -193,180 +226,9 @@ "pattern": "^1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?![\\s\\S])", "type": "string" }, - "PolicyMatch": { - "additionalProperties": false, - "description": "Match criteria for a policy rule. All specified fields must match.\n At least one field must be present.", - "properties": { - "Architectures": { - "description": "Allowed architectures.", - "items": { - "$ref": "#/definitions/Architecture" - }, - "maxItems": 5, - "type": "array", - "uniqueItems": true - }, - "Elevation": { - "description": "Allowed elevation levels.", - "items": { - "$ref": "#/definitions/Elevation" - }, - "maxItems": 2, - "type": "array", - "uniqueItems": true - }, - "HasCustomInstallLocation": { - "description": "Whether request has custom install location.", - "items": { - "type": "boolean" - }, - "maxItems": 1, - "type": "array", - "uniqueItems": true - }, - "HasCustomParameters": { - "description": "Whether request has custom parameters.", - "items": { - "type": "boolean" - }, - "maxItems": 1, - "type": "array", - "uniqueItems": true - }, - "HasKillBeforeOperation": { - "description": "Whether request has kill-before-operation entries.", - "items": { - "type": "boolean" - }, - "maxItems": 1, - "type": "array", - "uniqueItems": true - }, - "HasPrePostCommands": { - "description": "Whether request has pre/post operation commands.", - "items": { - "type": "boolean" - }, - "maxItems": 1, - "type": "array", - "uniqueItems": true - }, - "HasUninstallPrevious": { - "description": "Whether request has uninstall-previous flag set.", - "items": { - "type": "boolean" - }, - "maxItems": 1, - "type": "array", - "uniqueItems": true - }, - "Interactive": { - "description": "Allowed interactive values.", - "items": { - "type": "boolean" - }, - "maxItems": 1, - "type": "array", - "uniqueItems": true - }, - "Managers": { - "description": "Allowed managers.", - "items": { - "$ref": "#/definitions/ManagerName" - }, - "maxItems": 16, - "type": "array", - "uniqueItems": true - }, - "Operations": { - "description": "Allowed operations.", - "items": { - "$ref": "#/definitions/Operation" - }, - "maxItems": 3, - "type": "array", - "uniqueItems": true - }, - "PackageIdentifiers": { - "description": "Package identifier patterns (wildcard).", - "items": { - "$ref": "#/definitions/StringPattern" - }, - "maxItems": 1024, - "type": "array", - "uniqueItems": true - }, - "PackageNames": { - "description": "Package name patterns (wildcard).", - "items": { - "$ref": "#/definitions/StringPattern" - }, - "maxItems": 1024, - "type": "array", - "uniqueItems": true - }, - "PreRelease": { - "description": "Allowed preRelease values.", - "items": { - "type": "boolean" - }, - "maxItems": 1, - "type": "array", - "uniqueItems": true - }, - "Scopes": { - "description": "Allowed scopes.", - "items": { - "$ref": "#/definitions/Scope" - }, - "maxItems": 2, - "type": "array", - "uniqueItems": true - }, - "SkipHashCheck": { - "description": "Allowed skipHashCheck values.", - "items": { - "type": "boolean" - }, - "maxItems": 1, - "type": "array", - "uniqueItems": true - }, - "Sources": { - "description": "Source patterns (wildcard).", - "items": { - "$ref": "#/definitions/StringPattern" - }, - "maxItems": 128, - "type": "array", - "uniqueItems": true - }, - "VersionRange": { - "anyOf": [ - { - "$ref": "#/definitions/VersionRange" - }, - { - "type": "null" - } - ], - "description": "Semantic version range." - }, - "Versions": { - "description": "Exact version list.", - "items": { - "$ref": "#/definitions/VersionString" - }, - "maxItems": 256, - "type": "array", - "uniqueItems": true - } - }, - "type": "object" - }, "PolicyMetadata": { "additionalProperties": false, - "description": "Policy metadata.", + "description": "Policy metadata. When both validity bounds are present, `ValidFrom` must be strictly earlier\n than `ValidUntil`.", "properties": { "Description": { "description": "Human-readable description.", @@ -414,7 +276,7 @@ "description": "URL for support or documentation." }, "ValidFrom": { - "description": "Policy becomes active at this time.", + "description": "Earliest instant when the policy is active. When both bounds are present, this must be\n strictly earlier than `ValidUntil`.", "format": "date-time", "type": [ "string", @@ -422,7 +284,7 @@ ] }, "ValidUntil": { - "description": "Policy expires at this time.", + "description": "Instant after which the policy is inactive. When both bounds are present, this must be\n strictly later than `ValidFrom`.", "format": "date-time", "type": [ "string", @@ -441,6 +303,22 @@ "PolicyRule": { "additionalProperties": false, "description": "A single policy rule.", + "not": { + "properties": { + "Constraints": { + "type": "object" + }, + "Decision": { + "enum": [ + "Deny" + ] + } + }, + "required": [ + "Decision", + "Constraints" + ] + }, "properties": { "Constraints": { "anyOf": [ @@ -451,7 +329,7 @@ "type": "null" } ], - "description": "Additional constraints applied after matching.\n When absent, no constraints are enforced beyond the match criteria." + "description": "Additional safety limits applied after an Allow rule matches.\n Constraints are invalid on Deny rules." }, "Decision": { "allOf": [ @@ -475,13 +353,327 @@ "description": "Unique rule identifier." }, "Match": { - "allOf": [ + "additionalProperties": false, + "anyOf": [ + { + "properties": { + "Operations": { + "minItems": 1 + } + }, + "required": [ + "Operations" + ] + }, + { + "properties": { + "Managers": { + "minItems": 1 + } + }, + "required": [ + "Managers" + ] + }, + { + "properties": { + "SourceNames": { + "minItems": 1 + } + }, + "required": [ + "SourceNames" + ] + }, + { + "properties": { + "PackageIdentifiers": { + "type": "object" + } + }, + "required": [ + "PackageIdentifiers" + ] + }, + { + "properties": { + "Version": { + "type": "object" + } + }, + "required": [ + "Version" + ] + }, + { + "properties": { + "Scopes": { + "minItems": 1 + } + }, + "required": [ + "Scopes" + ] + }, { - "$ref": "#/definitions/PolicyMatch" + "properties": { + "Architectures": { + "minItems": 1 + } + }, + "required": [ + "Architectures" + ] + }, + { + "properties": { + "ExecutionElevation": { + "minItems": 1 + } + }, + "required": [ + "ExecutionElevation" + ] + }, + { + "properties": { + "Interactive": { + "type": "boolean" + } + }, + "required": [ + "Interactive" + ] + }, + { + "properties": { + "SkipHashCheck": { + "type": "boolean" + } + }, + "required": [ + "SkipHashCheck" + ] + }, + { + "properties": { + "PreRelease": { + "type": "boolean" + } + }, + "required": [ + "PreRelease" + ] + }, + { + "properties": { + "HasCustomParameters": { + "type": "boolean" + } + }, + "required": [ + "HasCustomParameters" + ] + }, + { + "properties": { + "HasCustomInstallLocation": { + "type": "boolean" + } + }, + "required": [ + "HasCustomInstallLocation" + ] + }, + { + "properties": { + "HasPrePostCommands": { + "type": "boolean" + } + }, + "required": [ + "HasPrePostCommands" + ] + }, + { + "properties": { + "HasKillBeforeOperation": { + "type": "boolean" + } + }, + "required": [ + "HasKillBeforeOperation" + ] + }, + { + "properties": { + "HasUninstallPrevious": { + "type": "boolean" + } + }, + "required": [ + "HasUninstallPrevious" + ] } ], - "description": "Match criteria — request must satisfy all specified fields.\n At least one criterion must be present.", - "minProperties": 1 + "description": "Match criteria — request must satisfy all specified fields.\n At least one effective non-null, nonempty criterion must be present.", + "if": { + "properties": { + "SourceNames": { + "minItems": 1 + } + }, + "required": [ + "SourceNames" + ] + }, + "minProperties": 1, + "properties": { + "Architectures": { + "description": "Optional architecture filter. Omitted or empty does not narrow matching;\n canonical serialization omits an empty collection.", + "items": { + "$ref": "#/definitions/Architecture" + }, + "maxItems": 5, + "type": "array", + "uniqueItems": true + }, + "ExecutionElevation": { + "description": "Optional effective execution-elevation filter. Elevated means the package operation\n will run with administrator privileges; Standard means it will not. Omitted or empty\n does not narrow matching; canonical serialization omits an empty collection.", + "items": { + "$ref": "#/definitions/Elevation" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "HasCustomInstallLocation": { + "description": "Optional condition on whether the request has a custom install location.\n Absent means this characteristic does not affect matching.", + "type": [ + "boolean", + "null" + ] + }, + "HasCustomParameters": { + "description": "Optional condition on whether the request has custom parameters.\n Absent means this characteristic does not affect matching.", + "type": [ + "boolean", + "null" + ] + }, + "HasKillBeforeOperation": { + "description": "Optional condition on whether the request has kill-before-operation entries.\n Absent means this characteristic does not affect matching.", + "type": [ + "boolean", + "null" + ] + }, + "HasPrePostCommands": { + "description": "Optional condition on whether the request has pre/post commands.\n Absent means this characteristic does not affect matching.", + "type": [ + "boolean", + "null" + ] + }, + "HasUninstallPrevious": { + "description": "Optional condition on whether the request enables uninstall-previous.\n Absent means this characteristic does not affect matching.", + "type": [ + "boolean", + "null" + ] + }, + "Interactive": { + "description": "Optional condition on the request's interactive characteristic.\n Absent means this characteristic does not affect matching.", + "type": [ + "boolean", + "null" + ] + }, + "Managers": { + "description": "Optional manager filter. Omitted or empty does not narrow matching;\n canonical serialization omits an empty collection.", + "items": { + "$ref": "#/definitions/ManagerName" + }, + "maxItems": 16, + "type": "array", + "uniqueItems": true + }, + "Operations": { + "description": "Optional operation filter. Omitted or empty does not narrow matching;\n canonical serialization omits an empty collection.", + "items": { + "$ref": "#/definitions/Operation" + }, + "maxItems": 3, + "type": "array", + "uniqueItems": true + }, + "PackageIdentifiers": { + "anyOf": [ + { + "$ref": "#/definitions/PackageIdentifierCondition" + }, + { + "type": "null" + } + ], + "description": "Optional package-identifier condition. Exact uses validated stable identifiers; Patterns\n uses explicit wildcard patterns that may authorize multiple packages. Absent does not\n narrow matching." + }, + "PreRelease": { + "description": "Optional condition on the request's preRelease characteristic.\n Absent means this characteristic does not affect matching.", + "type": [ + "boolean", + "null" + ] + }, + "Scopes": { + "description": "Optional scope filter. Omitted or empty does not narrow matching;\n canonical serialization omits an empty collection.", + "items": { + "$ref": "#/definitions/Scope" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "SkipHashCheck": { + "description": "Optional condition on the request's skipHashCheck characteristic.\n Absent means this characteristic does not affect matching.", + "type": [ + "boolean", + "null" + ] + }, + "SourceNames": { + "description": "Optional exact configured-source-name filter. Matching uses the selected package manager's\n source-name comparison semantics; wildcard characters are literal. Nonempty source names\n require exactly one manager. Omitted or empty does not narrow matching; canonical\n serialization omits an empty collection.", + "items": { + "$ref": "#/definitions/SourceName" + }, + "maxItems": 128, + "type": "array", + "uniqueItems": true + }, + "Version": { + "anyOf": [ + { + "$ref": "#/definitions/VersionCondition" + }, + { + "type": "null" + } + ], + "description": "Optional package-version condition. Exact supports arbitrary package version strings;\n Range applies only to semantic versions. Absent does not narrow matching." + } + }, + "then": { + "properties": { + "Managers": { + "maxItems": 1, + "minItems": 1 + } + }, + "required": [ + "Managers" + ] + }, + "type": "object" }, "Priority": { "description": "Priority (lower = higher precedence).", @@ -513,13 +705,6 @@ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:\\-]{0,127}$", "type": "string" }, - "RulePrecedence": { - "description": "Rule precedence strategy — always PriorityThenDeny.", - "enum": [ - "PriorityThenDeny" - ], - "type": "string" - }, "Scope": { "description": "Package installation scope.", "enum": [ @@ -528,15 +713,84 @@ ], "type": "string" }, + "SemanticVersion": { + "description": "Semantic version string (SemVer 2.0.0).\n\n Validated at deserialization time using the `semver` crate.", + "maxLength": 128, + "pattern": "^(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-4]|18446744073709551615)\\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-4]|18446744073709551615)\\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-4]|18446744073709551615)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?![\\s\\S])", + "type": "string" + }, + "SourceName": { + "description": "Exact configured package source name.\n\n Matching uses the selected package manager's source-name comparison semantics.\n Wildcard characters have no special meaning and are treated literally.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, "StringPattern": { "description": "Case-insensitive exact value or wildcard pattern.", "maxLength": 256, "minLength": 1, "type": "string" }, + "VersionCondition": { + "description": "Exactly one package-version mode. Exact accepts arbitrary package version strings; Range applies only to semantic versions.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "Exact": { + "items": { + "$ref": "#/definitions/VersionString" + }, + "maxItems": 256, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "Exact" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "Range": { + "$ref": "#/definitions/VersionRange" + } + }, + "required": [ + "Range" + ], + "type": "object" + } + ] + }, "VersionRange": { "additionalProperties": false, - "description": "Semantic version range for matching.", + "anyOf": [ + { + "properties": { + "MinVersion": { + "type": "string" + } + }, + "required": [ + "MinVersion" + ] + }, + { + "properties": { + "MaxVersion": { + "type": "string" + } + }, + "required": [ + "MaxVersion" + ] + } + ], + "description": "Nonempty semantic-version range for matching.", "properties": { "IncludePrerelease": { "default": false, @@ -544,22 +798,26 @@ "type": "boolean" }, "MaxVersion": { - "description": "Maximum version (inclusive).", - "maxLength": 128, - "minLength": 1, - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/SemanticVersion" + }, + { + "type": "null" + } + ], + "description": "Maximum version (inclusive)." }, "MinVersion": { - "description": "Minimum version (inclusive).", - "maxLength": 128, - "minLength": 1, - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/SemanticVersion" + }, + { + "type": "null" + } + ], + "description": "Minimum version (inclusive)." } }, "type": "object" @@ -597,14 +855,6 @@ ], "description": "Software-managed policy document format version.\n\n Applications must not expose this field as publisher-authored editable metadata." }, - "PolicyType": { - "allOf": [ - { - "$ref": "#/definitions/PackageBrokerPolicy" - } - ], - "description": "Must be `\"PackageBrokerPolicy\"`." - }, "Rules": { "description": "Ordered list of policy rules (may be empty; enforcement defaults apply).", "items": { @@ -616,7 +866,6 @@ }, "required": [ "PolicyFormatVersion", - "PolicyType", "Metadata", "Enforcement", "Rules" diff --git a/policies/rust/now-policy/src/enums.rs b/policies/rust/now-policy/src/enums.rs index 35c926a..cfa8e2c 100644 --- a/policies/rust/now-policy/src/enums.rs +++ b/policies/rust/now-policy/src/enums.rs @@ -61,7 +61,7 @@ pub enum Decision { Deny, } -/// Requested elevation level. +/// Effective package-operation execution privilege. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] #[schemars(rename = "Elevation")] pub enum Elevation { diff --git a/policies/rust/now-policy/src/lib.rs b/policies/rust/now-policy/src/lib.rs index efdd132..cb84e3c 100644 --- a/policies/rust/now-policy/src/lib.rs +++ b/policies/rust/now-policy/src/lib.rs @@ -3,12 +3,10 @@ #![allow(clippy::std_instead_of_core, unused_qualifications)] pub mod enums; -pub mod markers; pub mod newtypes; pub mod policy; pub mod schema; pub use enums::*; -pub use markers::*; pub use newtypes::*; pub use policy::*; diff --git a/policies/rust/now-policy/src/markers.rs b/policies/rust/now-policy/src/markers.rs deleted file mode 100644 index be12479..0000000 --- a/policies/rust/now-policy/src/markers.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Marker types -- zero-size structs that serialize to a fixed string constant. - -use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; -use serde::{Deserialize, Serialize}; - -macro_rules! fixed_string_marker { - ( - $(#[$attr:meta])* - $vis:vis struct $name:ident => $value:expr; - ) => { - $(#[$attr])* - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - $vis struct $name; - - impl Serialize for $name { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str($value) - } - } - - impl<'de> Deserialize<'de> for $name { - fn deserialize>(deserializer: D) -> Result { - let value = String::deserialize(deserializer)?; - if value == $value { - Ok(Self) - } else { - Err(serde::de::Error::custom(format_args!( - "expected {:?}, got {:?}", - $value, value - ))) - } - } - } - - impl JsonSchema for $name { - fn schema_name() -> std::borrow::Cow<'static, str> { - stringify!($name).into() - } - - fn json_schema(_gen: &mut SchemaGenerator) -> Schema { - json_schema!({ - "type": "string", - "enum": [$value], - }) - } - } - }; -} - -fixed_string_marker! { - /// Marker type for policy type: serializes to `"PackageBrokerPolicy"`. - pub struct PackageBrokerPolicy => "PackageBrokerPolicy"; -} diff --git a/policies/rust/now-policy/src/newtypes.rs b/policies/rust/now-policy/src/newtypes.rs index 22292da..163a6b6 100644 --- a/policies/rust/now-policy/src/newtypes.rs +++ b/policies/rust/now-policy/src/newtypes.rs @@ -3,6 +3,22 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +macro_rules! u64_component_pattern { + () => { + r"(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-4]|18446744073709551615)" + }; +} + +const SEMANTIC_VERSION_PATTERN: &str = concat!( + "^", + u64_component_pattern!(), + r"\.", + u64_component_pattern!(), + r"\.", + u64_component_pattern!(), + r"(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?![\s\S])" +); + /// Error returned when a policy newtype fails deserialization validation. #[derive(Debug, thiserror::Error)] pub enum ModelValidationError { @@ -41,9 +57,7 @@ fn validate_bounded_string( pub struct SemanticVersion( #[schemars( length(max = 128), - regex( - pattern = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" - ) + regex(pattern = SEMANTIC_VERSION_PATTERN) )] pub String, ); @@ -340,7 +354,7 @@ impl From for HttpUrl { } /// Case-insensitive exact value or wildcard pattern. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, JsonSchema)] pub struct StringPattern(#[schemars(length(min = 1, max = 256))] pub String); impl StringPattern { @@ -357,6 +371,13 @@ impl<'de> Deserialize<'de> for StringPattern { } } +impl Serialize for StringPattern { + fn serialize(&self, serializer: S) -> Result { + Self::parse(&self.0).map_err(serde::ser::Error::custom)?; + serializer.serialize_str(&self.0) + } +} + impl std::ops::Deref for StringPattern { type Target = str; @@ -377,8 +398,136 @@ impl std::fmt::Display for StringPattern { } } +/// Exact configured package source name. +/// +/// Matching uses the selected package manager's source-name comparison semantics. +/// Wildcard characters have no special meaning and are treated literally. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, JsonSchema)] +pub struct SourceName(#[schemars(length(min = 1, max = 128))] String); + +impl SourceName { + pub fn parse(s: &str) -> Result { + validate_bounded_string(s, 1, 128, "SourceName")?; + Ok(Self(s.to_owned())) + } +} + +impl<'de> Deserialize<'de> for SourceName { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + +impl Serialize for SourceName { + fn serialize(&self, serializer: S) -> Result { + Self::parse(&self.0).map_err(serde::ser::Error::custom)?; + serializer.serialize_str(&self.0) + } +} + +impl std::ops::Deref for SourceName { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + +impl AsRef for SourceName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for SourceName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Exact stable package identifier used by requests and exact policy matching. +/// +/// Manager-specific punctuation used by real identifiers is accepted, while +/// wildcard characters and range/pin operators are rejected. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, JsonSchema)] +pub struct PackageIdentifier( + #[schemars( + length(min = 1, max = 256), + regex(pattern = r"^[A-Za-z0-9._+@/:\[\],#$%{}-]+(?![\s\S])") + )] + String, +); + +impl PackageIdentifier { + pub fn parse(s: &str) -> Result { + validate_bounded_string(s, 1, 256, "PackageIdentifier")?; + if !s.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'.' | b'-' + | b'_' + | b'+' + | b'@' + | b'/' + | b':' + | b'[' + | b']' + | b',' + | b'#' + | b'$' + | b'%' + | b'{' + | b'}' + ) + }) { + return Err(ModelValidationError::Invalid { + type_name: "PackageIdentifier", + reason: "must contain only ASCII alphanumerics or '. - _ + @ / : [ ] , # $ % { }'".to_owned(), + }); + } + + Ok(Self(s.to_owned())) + } +} + +impl<'de> Deserialize<'de> for PackageIdentifier { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + +impl Serialize for PackageIdentifier { + fn serialize(&self, serializer: S) -> Result { + Self::parse(&self.0).map_err(serde::ser::Error::custom)?; + serializer.serialize_str(&self.0) + } +} + +impl std::ops::Deref for PackageIdentifier { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + +impl AsRef for PackageIdentifier { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for PackageIdentifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + /// A short constrained string for version values. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, JsonSchema)] pub struct VersionString(#[schemars(length(min = 1, max = 128))] pub String); impl VersionString { @@ -395,6 +544,13 @@ impl<'de> Deserialize<'de> for VersionString { } } +impl Serialize for VersionString { + fn serialize(&self, serializer: S) -> Result { + Self::parse(&self.0).map_err(serde::ser::Error::custom)?; + serializer.serialize_str(&self.0) + } +} + impl std::ops::Deref for VersionString { type Target = str; diff --git a/policies/rust/now-policy/src/policy.rs b/policies/rust/now-policy/src/policy.rs index 43c4968..4d57bce 100644 --- a/policies/rust/now-policy/src/policy.rs +++ b/policies/rust/now-policy/src/policy.rs @@ -8,7 +8,8 @@ use serde::{Deserialize, Serialize}; use crate::{ Architecture, CustomParameterString, Decision, Elevation, HttpUrl, ManagerName, ModelValidationError, Operation, - PackageBrokerPolicy, PolicyFormatVersion, ResourceId, Scope, StringPattern, VersionString, + PackageIdentifier, PolicyFormatVersion, ResourceId, Scope, SemanticVersion, SourceName, StringPattern, + VersionString, }; const MAX_POLICY_REVISION: u32 = 2_147_483_647; @@ -24,9 +25,6 @@ pub struct PolicyDocument { /// Applications must not expose this field as publisher-authored editable metadata. pub policy_format_version: PolicyFormatVersion, - /// Must be `"PackageBrokerPolicy"`. - pub policy_type: PackageBrokerPolicy, - /// Policy metadata. pub metadata: PolicyMetadata, @@ -43,7 +41,6 @@ impl PolicyDocument { pub fn to_draft(&self) -> PolicyDraftDocument { PolicyDraftDocument { policy_format_version: self.policy_format_version.clone(), - policy_type: self.policy_type, metadata: self.metadata.to_draft(), enforcement: self.enforcement.clone(), rules: self.rules.clone(), @@ -63,9 +60,6 @@ pub struct PolicyDraftDocument { /// as publisher-authored editable metadata. pub policy_format_version: PolicyFormatVersion, - /// Must be `"PackageBrokerPolicy"`. - pub policy_type: PackageBrokerPolicy, - /// Editable policy metadata. pub metadata: PolicyDraftMetadata, @@ -93,19 +87,19 @@ impl PolicyDraftDocument { Ok(PolicyDocument { policy_format_version: self.policy_format_version, - policy_type: self.policy_type, - metadata: self.metadata.into_policy_metadata(revision, published_at), + metadata: self.metadata.into_policy_metadata(revision, published_at)?, enforcement: self.enforcement, rules: self.rules, }) } } -/// Policy metadata. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +/// Policy metadata. When both validity bounds are present, `ValidFrom` must be strictly earlier +/// than `ValidUntil`. +#[derive(Debug, Clone, JsonSchema)] #[schemars(rename = "PolicyMetadata")] -#[serde(rename_all = "PascalCase")] -#[serde(deny_unknown_fields)] +#[schemars(rename_all = "PascalCase")] +#[schemars(deny_unknown_fields)] pub struct PolicyMetadata { /// Unique policy identifier. pub id: ResourceId, @@ -125,12 +119,12 @@ pub struct PolicyMetadata { /// ISO 8601 publication timestamp (RFC 3339). pub published_at: DateTime, - /// Policy becomes active at this time. - #[serde(default, skip_serializing_if = "Option::is_none")] + /// Earliest instant when the policy is active. When both bounds are present, this must be + /// strictly earlier than `ValidUntil`. pub valid_from: Option>, - /// Policy expires at this time. - #[serde(default, skip_serializing_if = "Option::is_none")] + /// Instant after which the policy is inactive. When both bounds are present, this must be + /// strictly later than `ValidFrom`. pub valid_until: Option>, /// Human-readable description. @@ -154,22 +148,18 @@ fn validate_policy_revision(revision: u32) -> Result<(), ModelValidationError> { Ok(()) } -fn serialize_policy_revision(revision: &u32, serializer: S) -> Result { - validate_policy_revision(*revision).map_err(serde::ser::Error::custom)?; - revision.serialize(serializer) -} - fn deserialize_policy_revision<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { let revision = u32::deserialize(deserializer)?; validate_policy_revision(revision).map_err(serde::de::Error::custom)?; Ok(revision) } -/// Editable policy metadata without server-managed revision and publication time. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +/// Editable policy metadata without server-managed revision and publication time. When both +/// validity bounds are present, `ValidFrom` must be strictly earlier than `ValidUntil`. +#[derive(Debug, Clone, JsonSchema)] #[schemars(rename = "PolicyDraftMetadata")] -#[serde(rename_all = "PascalCase")] -#[serde(deny_unknown_fields)] +#[schemars(rename_all = "PascalCase")] +#[schemars(deny_unknown_fields)] pub struct PolicyDraftMetadata { /// Unique policy identifier. pub id: ResourceId, @@ -178,12 +168,12 @@ pub struct PolicyDraftMetadata { #[schemars(length(min = 1, max = 128))] pub publisher: String, - /// Policy becomes active at this time. - #[serde(default, skip_serializing_if = "Option::is_none")] + /// Earliest instant when the policy is active. When both bounds are present, this must be + /// strictly earlier than `ValidUntil`. pub valid_from: Option>, - /// Policy expires at this time. - #[serde(default, skip_serializing_if = "Option::is_none")] + /// Instant after which the policy is inactive. When both bounds are present, this must be + /// strictly later than `ValidFrom`. pub valid_until: Option>, /// Human-readable description. @@ -210,8 +200,17 @@ impl PolicyMetadata { } impl PolicyDraftMetadata { - fn into_policy_metadata(self, revision: u32, published_at: DateTime) -> PolicyMetadata { - PolicyMetadata { + fn into_policy_metadata( + self, + revision: u32, + published_at: DateTime, + ) -> Result { + validate_validity_window( + "PolicyDraftMetadata", + self.valid_from.as_ref(), + self.valid_until.as_ref(), + )?; + Ok(PolicyMetadata { id: self.id, publisher: self.publisher, revision, @@ -220,11 +219,186 @@ impl PolicyDraftMetadata { valid_until: self.valid_until, description: self.description, support_url: self.support_url, + }) + } +} + +fn validate_validity_window( + type_name: &'static str, + valid_from: Option<&DateTime>, + valid_until: Option<&DateTime>, +) -> Result<(), ModelValidationError> { + if let (Some(valid_from), Some(valid_until)) = (valid_from, valid_until) + && valid_from >= valid_until + { + return Err(ModelValidationError::Invalid { + type_name, + reason: "ValidUntil must be strictly later than ValidFrom".to_owned(), + }); + } + Ok(()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +struct PolicyMetadataWire { + id: ResourceId, + publisher: String, + #[serde(deserialize_with = "deserialize_policy_revision")] + revision: u32, + published_at: DateTime, + #[serde(default)] + valid_from: Option>, + #[serde(default)] + valid_until: Option>, + #[serde(default)] + description: Option, + #[serde(default)] + support_url: Option, +} + +impl TryFrom for PolicyMetadata { + type Error = ModelValidationError; + + fn try_from(value: PolicyMetadataWire) -> Result { + validate_validity_window("PolicyMetadata", value.valid_from.as_ref(), value.valid_until.as_ref())?; + Ok(Self { + id: value.id, + publisher: value.publisher, + revision: value.revision, + published_at: value.published_at, + valid_from: value.valid_from, + valid_until: value.valid_until, + description: value.description, + support_url: value.support_url, + }) + } +} + +impl<'de> Deserialize<'de> for PolicyMetadata { + fn deserialize>(deserializer: D) -> Result { + Self::try_from(PolicyMetadataWire::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct PolicyMetadataRef<'a> { + id: &'a ResourceId, + publisher: &'a str, + revision: &'a u32, + published_at: &'a DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + valid_from: Option<&'a DateTime>, + #[serde(skip_serializing_if = "Option::is_none")] + valid_until: Option<&'a DateTime>, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + support_url: Option<&'a HttpUrl>, +} + +impl Serialize for PolicyMetadata { + fn serialize(&self, serializer: S) -> Result { + validate_policy_revision(self.revision).map_err(serde::ser::Error::custom)?; + validate_validity_window("PolicyMetadata", self.valid_from.as_ref(), self.valid_until.as_ref()) + .map_err(serde::ser::Error::custom)?; + PolicyMetadataRef { + id: &self.id, + publisher: &self.publisher, + revision: &self.revision, + published_at: &self.published_at, + valid_from: self.valid_from.as_ref(), + valid_until: self.valid_until.as_ref(), + description: self.description.as_deref(), + support_url: self.support_url.as_ref(), } + .serialize(serializer) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +struct PolicyDraftMetadataWire { + id: ResourceId, + publisher: String, + #[serde(default)] + valid_from: Option>, + #[serde(default)] + valid_until: Option>, + #[serde(default)] + description: Option, + #[serde(default)] + support_url: Option, +} + +impl TryFrom for PolicyDraftMetadata { + type Error = ModelValidationError; + + fn try_from(value: PolicyDraftMetadataWire) -> Result { + validate_validity_window( + "PolicyDraftMetadata", + value.valid_from.as_ref(), + value.valid_until.as_ref(), + )?; + Ok(Self { + id: value.id, + publisher: value.publisher, + valid_from: value.valid_from, + valid_until: value.valid_until, + description: value.description, + support_url: value.support_url, + }) + } +} + +impl<'de> Deserialize<'de> for PolicyDraftMetadata { + fn deserialize>(deserializer: D) -> Result { + Self::try_from(PolicyDraftMetadataWire::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct PolicyDraftMetadataRef<'a> { + id: &'a ResourceId, + publisher: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + valid_from: Option<&'a DateTime>, + #[serde(skip_serializing_if = "Option::is_none")] + valid_until: Option<&'a DateTime>, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + support_url: Option<&'a HttpUrl>, +} + +impl Serialize for PolicyDraftMetadata { + fn serialize(&self, serializer: S) -> Result { + validate_validity_window( + "PolicyDraftMetadata", + self.valid_from.as_ref(), + self.valid_until.as_ref(), + ) + .map_err(serde::ser::Error::custom)?; + PolicyDraftMetadataRef { + id: &self.id, + publisher: &self.publisher, + valid_from: self.valid_from.as_ref(), + valid_until: self.valid_until.as_ref(), + description: self.description.as_deref(), + support_url: self.support_url.as_ref(), + } + .serialize(serializer) } } /// Enforcement configuration. +/// +/// Matching rules are evaluated by ascending priority. Deny wins equal-priority +/// Allow/Deny ties; remaining equal-priority ties retain document order. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[schemars(rename = "PolicyEnforcement")] #[serde(rename_all = "PascalCase")] @@ -233,24 +407,15 @@ pub struct PolicyEnforcement { /// Decision when no rule matches. pub default_decision: Decision, - /// Rule precedence strategy (must be "PriorityThenDeny"). - pub rule_precedence: RulePrecedence, - /// When true, broker logs decisions but does not enforce. #[serde(default, skip_serializing_if = "Option::is_none")] pub audit_mode: Option, } -/// Rule precedence strategy — always PriorityThenDeny. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[schemars(rename = "RulePrecedence")] -pub enum RulePrecedence { - PriorityThenDeny, -} - /// A single policy rule. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, JsonSchema)] #[schemars(rename = "PolicyRule")] +#[schemars(transform = enforce_allow_constraints_schema)] #[serde(rename_all = "PascalCase")] #[serde(deny_unknown_fields)] pub struct PolicyRule { @@ -274,21 +439,137 @@ pub struct PolicyRule { pub reason: Option, /// Match criteria — request must satisfy all specified fields. - /// At least one criterion must be present. + /// At least one effective non-null, nonempty criterion must be present. #[serde(rename = "Match", deserialize_with = "deserialize_non_empty_match")] #[schemars(with = "NonEmptyPolicyMatchSchema")] pub match_criteria: PolicyMatch, - /// Additional constraints applied after matching. - /// When absent, no constraints are enforced beyond the match criteria. + /// Additional safety limits applied after an Allow rule matches. + /// Constraints are invalid on Deny rules. #[serde(default, skip_serializing_if = "Option::is_none")] pub constraints: Option, } +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +struct PolicyRuleWire { + id: ResourceId, + #[serde(default = "default_true")] + enabled: bool, + priority: u32, + decision: Decision, + #[serde(default)] + reason: Option, + #[serde(rename = "Match", deserialize_with = "deserialize_non_empty_match")] + match_criteria: PolicyMatch, + #[serde(default)] + constraints: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct PolicyRuleRef<'a> { + id: &'a ResourceId, + enabled: bool, + priority: u32, + decision: Decision, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option<&'a String>, + #[serde(rename = "Match")] + match_criteria: &'a PolicyMatch, + #[serde(skip_serializing_if = "Option::is_none")] + constraints: Option<&'a PolicyConstraints>, +} + fn default_true() -> bool { true } +fn validate_policy_rule( + decision: Decision, + match_criteria: &PolicyMatch, + constraints: Option<&PolicyConstraints>, +) -> Result<(), &'static str> { + if match_criteria.is_empty() { + return Err("PolicyRule.Match must contain at least one effective criterion"); + } + if decision == Decision::Deny && constraints.is_some() { + return Err("PolicyRule.Constraints are valid only when Decision is Allow"); + } + if !match_criteria.source_names.is_empty() && match_criteria.managers.len() != 1 { + return Err("PolicyRule.Match.SourceNames requires exactly one PolicyRule.Match.Managers value"); + } + + Ok(()) +} + +impl TryFrom for PolicyRule { + type Error = &'static str; + + fn try_from(value: PolicyRuleWire) -> Result { + validate_policy_rule(value.decision, &value.match_criteria, value.constraints.as_ref())?; + Ok(Self { + id: value.id, + enabled: value.enabled, + priority: value.priority, + decision: value.decision, + reason: value.reason, + match_criteria: value.match_criteria, + constraints: value.constraints, + }) + } +} + +impl<'de> Deserialize<'de> for PolicyRule { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Self::try_from(PolicyRuleWire::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } +} + +impl Serialize for PolicyRule { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + validate_policy_rule(self.decision, &self.match_criteria, self.constraints.as_ref()) + .map_err(serde::ser::Error::custom)?; + PolicyRuleRef { + id: &self.id, + enabled: self.enabled, + priority: self.priority, + decision: self.decision, + reason: self.reason.as_ref(), + match_criteria: &self.match_criteria, + constraints: self.constraints.as_ref(), + } + .serialize(serializer) + } +} + +fn enforce_allow_constraints_schema(schema: &mut Schema) { + schema + .as_object_mut() + .expect("PolicyRule schema should be an object") + .extend( + json_schema!({ + "not": { + "required": ["Decision", "Constraints"], + "properties": { + "Decision": { "enum": ["Deny"] }, + "Constraints": { "type": "object" } + } + } + }) + .as_object() + .expect("PolicyRule conditional schema should be an object") + .clone(), + ); +} + fn deserialize_non_empty_match<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { let m = PolicyMatch::deserialize(deserializer)?; if m.is_empty() { @@ -309,169 +590,344 @@ impl JsonSchema for NonEmptyPolicyMatchSchema { } fn json_schema(generator: &mut SchemaGenerator) -> Schema { - json_schema!({ - "minProperties": 1, - "allOf": [generator.subschema_for::()], - }) + let mut schema = PolicyMatch::json_schema(generator); + let schema_object = schema.as_object_mut().expect("PolicyMatch schema should be an object"); + schema_object.insert("minProperties".to_owned(), serde_json::json!(1)); + schema_object.insert( + "anyOf".to_owned(), + serde_json::json!([ + { "required": ["Operations"], "properties": { "Operations": { "minItems": 1 } } }, + { "required": ["Managers"], "properties": { "Managers": { "minItems": 1 } } }, + { "required": ["SourceNames"], "properties": { "SourceNames": { "minItems": 1 } } }, + { "required": ["PackageIdentifiers"], "properties": { "PackageIdentifiers": { "type": "object" } } }, + { "required": ["Version"], "properties": { "Version": { "type": "object" } } }, + { "required": ["Scopes"], "properties": { "Scopes": { "minItems": 1 } } }, + { "required": ["Architectures"], "properties": { "Architectures": { "minItems": 1 } } }, + { "required": ["ExecutionElevation"], "properties": { "ExecutionElevation": { "minItems": 1 } } }, + { "required": ["Interactive"], "properties": { "Interactive": { "type": "boolean" } } }, + { "required": ["SkipHashCheck"], "properties": { "SkipHashCheck": { "type": "boolean" } } }, + { "required": ["PreRelease"], "properties": { "PreRelease": { "type": "boolean" } } }, + { "required": ["HasCustomParameters"], "properties": { "HasCustomParameters": { "type": "boolean" } } }, + { "required": ["HasCustomInstallLocation"], "properties": { "HasCustomInstallLocation": { "type": "boolean" } } }, + { "required": ["HasPrePostCommands"], "properties": { "HasPrePostCommands": { "type": "boolean" } } }, + { "required": ["HasKillBeforeOperation"], "properties": { "HasKillBeforeOperation": { "type": "boolean" } } }, + { "required": ["HasUninstallPrevious"], "properties": { "HasUninstallPrevious": { "type": "boolean" } } } + ]), + ); + schema } } /// Match criteria for a policy rule. All specified fields must match. -/// At least one field must be present. -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +/// At least one effective non-null, nonempty criterion must be present. +#[derive(Debug, Clone, Default, JsonSchema)] #[schemars(rename = "PolicyMatch")] -#[serde(rename_all = "PascalCase")] -#[serde(deny_unknown_fields)] +#[schemars(rename_all = "PascalCase")] +#[schemars(deny_unknown_fields)] +#[schemars(transform = enforce_source_names_schema)] pub struct PolicyMatch { - /// Allowed operations. + /// Optional operation filter. Omitted or empty does not narrow matching; + /// canonical serialization omits an empty collection. #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] #[schemars(length(max = 3))] pub operations: BTreeSet, - /// Allowed managers. + /// Optional manager filter. Omitted or empty does not narrow matching; + /// canonical serialization omits an empty collection. #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] #[schemars(length(max = 16))] pub managers: BTreeSet, - /// Source patterns (wildcard). + /// Optional exact configured-source-name filter. Matching uses the selected package manager's + /// source-name comparison semantics; wildcard characters are literal. Nonempty source names + /// require exactly one manager. Omitted or empty does not narrow matching; canonical + /// serialization omits an empty collection. #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] #[schemars(length(max = 128))] - pub sources: BTreeSet, + pub source_names: BTreeSet, - /// Package identifier patterns (wildcard). - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - #[schemars(length(max = 1024))] - pub package_identifiers: BTreeSet, - - /// Package name patterns (wildcard). - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - #[schemars(length(max = 1024))] - pub package_names: BTreeSet, - - /// Exact version list. - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - #[schemars(length(max = 256))] - pub versions: BTreeSet, + /// Optional package-identifier condition. Exact uses validated stable identifiers; Patterns + /// uses explicit wildcard patterns that may authorize multiple packages. Absent does not + /// narrow matching. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub package_identifiers: Option, - /// Semantic version range. + /// Optional package-version condition. Exact supports arbitrary package version strings; + /// Range applies only to semantic versions. Absent does not narrow matching. #[serde(default, skip_serializing_if = "Option::is_none")] - pub version_range: Option, + pub version: Option, - /// Allowed scopes. + /// Optional scope filter. Omitted or empty does not narrow matching; + /// canonical serialization omits an empty collection. #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] #[schemars(length(max = 2))] pub scopes: BTreeSet, - /// Allowed architectures. + /// Optional architecture filter. Omitted or empty does not narrow matching; + /// canonical serialization omits an empty collection. #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] #[schemars(length(max = 5))] pub architectures: BTreeSet, - /// Allowed elevation levels. + /// Optional effective execution-elevation filter. Elevated means the package operation + /// will run with administrator privileges; Standard means it will not. Omitted or empty + /// does not narrow matching; canonical serialization omits an empty collection. #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] #[schemars(length(max = 2))] - pub elevation: BTreeSet, + pub execution_elevation: BTreeSet, - /// Allowed interactive values. - #[serde( - default, - skip_serializing_if = "BTreeSet::is_empty", - serialize_with = "serialize_boolean_match", - deserialize_with = "deserialize_boolean_match" - )] - #[schemars(length(max = 1))] - pub interactive: BTreeSet, + /// Optional condition on the request's interactive characteristic. + /// Absent means this characteristic does not affect matching. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interactive: Option, - /// Allowed skipHashCheck values. - #[serde( - default, - skip_serializing_if = "BTreeSet::is_empty", - serialize_with = "serialize_boolean_match", - deserialize_with = "deserialize_boolean_match" - )] - #[schemars(length(max = 1))] - pub skip_hash_check: BTreeSet, + /// Optional condition on the request's skipHashCheck characteristic. + /// Absent means this characteristic does not affect matching. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_hash_check: Option, - /// Allowed preRelease values. - #[serde( - default, - skip_serializing_if = "BTreeSet::is_empty", - serialize_with = "serialize_boolean_match", - deserialize_with = "deserialize_boolean_match" - )] - #[schemars(length(max = 1))] - pub pre_release: BTreeSet, + /// Optional condition on the request's preRelease characteristic. + /// Absent means this characteristic does not affect matching. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pre_release: Option, - /// Whether request has custom parameters. - #[serde( - default, - skip_serializing_if = "BTreeSet::is_empty", - serialize_with = "serialize_boolean_match", - deserialize_with = "deserialize_boolean_match" - )] - #[schemars(length(max = 1))] - pub has_custom_parameters: BTreeSet, + /// Optional condition on whether the request has custom parameters. + /// Absent means this characteristic does not affect matching. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub has_custom_parameters: Option, - /// Whether request has custom install location. - #[serde( - default, - skip_serializing_if = "BTreeSet::is_empty", - serialize_with = "serialize_boolean_match", - deserialize_with = "deserialize_boolean_match" - )] - #[schemars(length(max = 1))] - pub has_custom_install_location: BTreeSet, + /// Optional condition on whether the request has a custom install location. + /// Absent means this characteristic does not affect matching. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub has_custom_install_location: Option, - /// Whether request has pre/post operation commands. - #[serde( - default, - skip_serializing_if = "BTreeSet::is_empty", - serialize_with = "serialize_boolean_match", - deserialize_with = "deserialize_boolean_match" - )] - #[schemars(length(max = 1))] - pub has_pre_post_commands: BTreeSet, + /// Optional condition on whether the request has pre/post commands. + /// Absent means this characteristic does not affect matching. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub has_pre_post_commands: Option, - /// Whether request has kill-before-operation entries. - #[serde( - default, - skip_serializing_if = "BTreeSet::is_empty", - serialize_with = "serialize_boolean_match", - deserialize_with = "deserialize_boolean_match" - )] - #[schemars(length(max = 1))] - pub has_kill_before_operation: BTreeSet, + /// Optional condition on whether the request has kill-before-operation entries. + /// Absent means this characteristic does not affect matching. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub has_kill_before_operation: Option, - /// Whether request has uninstall-previous flag set. - #[serde( - default, - skip_serializing_if = "BTreeSet::is_empty", - serialize_with = "serialize_boolean_match", - deserialize_with = "deserialize_boolean_match" - )] - #[schemars(length(max = 1))] - pub has_uninstall_previous: BTreeSet, + /// Optional condition on whether the request enables uninstall-previous. + /// Absent means this characteristic does not affect matching. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub has_uninstall_previous: Option, +} + +fn enforce_source_names_schema(schema: &mut Schema) { + let schema = schema.as_object_mut().expect("PolicyMatch schema should be an object"); + schema.insert( + "if".to_owned(), + serde_json::json!({ + "required": ["SourceNames"], + "properties": { + "SourceNames": { "minItems": 1 } + } + }), + ); + schema.insert( + "then".to_owned(), + serde_json::json!({ + "required": ["Managers"], + "properties": { + "Managers": { "minItems": 1, "maxItems": 1 } + } + }), + ); } -fn deserialize_boolean_match<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result, D::Error> { - let values = Vec::::deserialize(deserializer)?; - if values.len() > 1 { - return Err(serde::de::Error::custom( - "boolean match arrays must contain at most one value", - )); +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +struct PolicyMatchWire { + #[serde(default)] + operations: Vec, + #[serde(default)] + managers: Vec, + #[serde(default)] + source_names: Vec, + #[serde(default)] + package_identifiers: Option, + #[serde(default)] + version: Option, + #[serde(default)] + scopes: Vec, + #[serde(default)] + architectures: Vec, + #[serde(default)] + execution_elevation: Vec, + #[serde(default)] + interactive: Option, + #[serde(default)] + skip_hash_check: Option, + #[serde(default)] + pre_release: Option, + #[serde(default)] + has_custom_parameters: Option, + #[serde(default)] + has_custom_install_location: Option, + #[serde(default)] + has_pre_post_commands: Option, + #[serde(default)] + has_kill_before_operation: Option, + #[serde(default)] + has_uninstall_previous: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct PolicyMatchRef<'a> { + #[serde(skip_serializing_if = "BTreeSet::is_empty")] + operations: &'a BTreeSet, + #[serde(skip_serializing_if = "BTreeSet::is_empty")] + managers: &'a BTreeSet, + #[serde(skip_serializing_if = "BTreeSet::is_empty")] + source_names: &'a BTreeSet, + #[serde(skip_serializing_if = "Option::is_none")] + package_identifiers: Option<&'a PackageIdentifierCondition>, + #[serde(skip_serializing_if = "Option::is_none")] + version: Option<&'a VersionCondition>, + #[serde(skip_serializing_if = "BTreeSet::is_empty")] + scopes: &'a BTreeSet, + #[serde(skip_serializing_if = "BTreeSet::is_empty")] + architectures: &'a BTreeSet, + #[serde(skip_serializing_if = "BTreeSet::is_empty")] + execution_elevation: &'a BTreeSet, + #[serde(skip_serializing_if = "Option::is_none")] + interactive: Option, + #[serde(skip_serializing_if = "Option::is_none")] + skip_hash_check: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pre_release: Option, + #[serde(skip_serializing_if = "Option::is_none")] + has_custom_parameters: Option, + #[serde(skip_serializing_if = "Option::is_none")] + has_custom_install_location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + has_pre_post_commands: Option, + #[serde(skip_serializing_if = "Option::is_none")] + has_kill_before_operation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + has_uninstall_previous: Option, +} + +const MAX_MANAGERS: usize = 16; +const MAX_SOURCE_NAMES: usize = 128; + +fn validate_policy_match(value: &PolicyMatch) -> Result<(), &'static str> { + if value.managers.len() > MAX_MANAGERS { + return Err("PolicyMatch.Managers must contain at most 16 values"); + } + if value.source_names.len() > MAX_SOURCE_NAMES { + return Err("PolicyMatch.SourceNames must contain at most 128 values"); } + if !value.source_names.is_empty() && value.managers.len() != 1 { + return Err("PolicyMatch.SourceNames requires exactly one PolicyMatch.Managers value"); + } + Ok(()) +} - Ok(values.into_iter().collect()) +fn reject_duplicate_values(values: &[T], path: &'static str) -> Result<(), &'static str> { + let unique = values.iter().collect::>(); + if unique.len() != values.len() { + return Err(path); + } + Ok(()) } -fn serialize_boolean_match(values: &BTreeSet, serializer: S) -> Result { - if values.len() > 1 { - return Err(serde::ser::Error::custom( - "boolean match arrays must contain at most one value", - )); +impl TryFrom for PolicyMatch { + type Error = &'static str; + + fn try_from(value: PolicyMatchWire) -> Result { + reject_duplicate_values( + &value.operations, + "PolicyMatch.Operations must not contain duplicate values", + )?; + reject_duplicate_values( + &value.managers, + "PolicyMatch.Managers must not contain duplicate values", + )?; + if value.managers.len() > MAX_MANAGERS { + return Err("PolicyMatch.Managers must contain at most 16 values"); + } + reject_duplicate_values( + &value.source_names, + "PolicyMatch.SourceNames must not contain duplicate values", + )?; + if value.source_names.len() > MAX_SOURCE_NAMES { + return Err("PolicyMatch.SourceNames must contain at most 128 values"); + } + reject_duplicate_values(&value.scopes, "PolicyMatch.Scopes must not contain duplicate values")?; + reject_duplicate_values( + &value.architectures, + "PolicyMatch.Architectures must not contain duplicate values", + )?; + reject_duplicate_values( + &value.execution_elevation, + "PolicyMatch.ExecutionElevation must not contain duplicate values", + )?; + + let result = Self { + operations: value.operations.into_iter().collect(), + managers: value.managers.into_iter().collect(), + source_names: value.source_names.into_iter().collect(), + package_identifiers: value.package_identifiers, + version: value.version, + scopes: value.scopes.into_iter().collect(), + architectures: value.architectures.into_iter().collect(), + execution_elevation: value.execution_elevation.into_iter().collect(), + interactive: value.interactive, + skip_hash_check: value.skip_hash_check, + pre_release: value.pre_release, + has_custom_parameters: value.has_custom_parameters, + has_custom_install_location: value.has_custom_install_location, + has_pre_post_commands: value.has_pre_post_commands, + has_kill_before_operation: value.has_kill_before_operation, + has_uninstall_previous: value.has_uninstall_previous, + }; + validate_policy_match(&result)?; + Ok(result) } +} - values.serialize(serializer) +impl<'de> Deserialize<'de> for PolicyMatch { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Self::try_from(PolicyMatchWire::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } +} + +impl Serialize for PolicyMatch { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + validate_policy_match(self).map_err(serde::ser::Error::custom)?; + PolicyMatchRef { + operations: &self.operations, + managers: &self.managers, + source_names: &self.source_names, + package_identifiers: self.package_identifiers.as_ref(), + version: self.version.as_ref(), + scopes: &self.scopes, + architectures: &self.architectures, + execution_elevation: &self.execution_elevation, + interactive: self.interactive, + skip_hash_check: self.skip_hash_check, + pre_release: self.pre_release, + has_custom_parameters: self.has_custom_parameters, + has_custom_install_location: self.has_custom_install_location, + has_pre_post_commands: self.has_pre_post_commands, + has_kill_before_operation: self.has_kill_before_operation, + has_uninstall_previous: self.has_uninstall_previous, + } + .serialize(serializer) + } } impl PolicyMatch { @@ -479,47 +935,327 @@ impl PolicyMatch { pub fn is_empty(&self) -> bool { self.operations.is_empty() && self.managers.is_empty() - && self.sources.is_empty() - && self.package_identifiers.is_empty() - && self.package_names.is_empty() - && self.versions.is_empty() - && self.version_range.is_none() + && self.source_names.is_empty() + && self.package_identifiers.is_none() + && self.version.is_none() && self.scopes.is_empty() && self.architectures.is_empty() - && self.elevation.is_empty() - && self.interactive.is_empty() - && self.skip_hash_check.is_empty() - && self.pre_release.is_empty() - && self.has_custom_parameters.is_empty() - && self.has_custom_install_location.is_empty() - && self.has_pre_post_commands.is_empty() - && self.has_kill_before_operation.is_empty() - && self.has_uninstall_previous.is_empty() + && self.execution_elevation.is_empty() + && self.interactive.is_none() + && self.skip_hash_check.is_none() + && self.pre_release.is_none() + && self.has_custom_parameters.is_none() + && self.has_custom_install_location.is_none() + && self.has_pre_post_commands.is_none() + && self.has_kill_before_operation.is_none() + && self.has_uninstall_previous.is_none() } } -/// Semantic version range for matching. -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[schemars(rename = "VersionRange")] +/// Mutually exclusive package-identifier matching mode. +#[derive(Debug, Clone)] +pub enum PackageIdentifierCondition { + Exact(BTreeSet), + Patterns(BTreeSet), +} + +#[derive(Deserialize)] #[serde(rename_all = "PascalCase")] -#[serde(deny_unknown_fields)] +enum PackageIdentifierConditionWire { + Exact(Vec), + Patterns(Vec), +} + +impl<'de> Deserialize<'de> for PackageIdentifierCondition { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match PackageIdentifierConditionWire::deserialize(deserializer)? { + PackageIdentifierConditionWire::Exact(values) if values.is_empty() || values.len() > 1024 => Err( + serde::de::Error::custom("PackageIdentifiers.Exact must contain between 1 and 1024 values"), + ), + PackageIdentifierConditionWire::Exact(values) => { + reject_duplicate_values(&values, "PackageIdentifiers.Exact must not contain duplicate values") + .map_err(serde::de::Error::custom)?; + Ok(Self::Exact(values.into_iter().collect())) + } + PackageIdentifierConditionWire::Patterns(values) if values.is_empty() || values.len() > 1024 => Err( + serde::de::Error::custom("PackageIdentifiers.Patterns must contain between 1 and 1024 values"), + ), + PackageIdentifierConditionWire::Patterns(values) => { + reject_duplicate_values(&values, "PackageIdentifiers.Patterns must not contain duplicate values") + .map_err(serde::de::Error::custom)?; + Ok(Self::Patterns(values.into_iter().collect())) + } + } + } +} + +impl Serialize for PackageIdentifierCondition { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Exact(values) if values.is_empty() || values.len() > 1024 => Err(serde::ser::Error::custom( + "PackageIdentifiers.Exact must contain between 1 and 1024 values", + )), + Self::Exact(values) => { + #[derive(Serialize)] + #[serde(rename_all = "PascalCase")] + enum ExactRef<'a> { + Exact(&'a BTreeSet), + } + ExactRef::Exact(values).serialize(serializer) + } + Self::Patterns(values) if values.is_empty() || values.len() > 1024 => Err(serde::ser::Error::custom( + "PackageIdentifiers.Patterns must contain between 1 and 1024 values", + )), + Self::Patterns(values) => { + #[derive(Serialize)] + #[serde(rename_all = "PascalCase")] + enum PatternsRef<'a> { + Patterns(&'a BTreeSet), + } + PatternsRef::Patterns(values).serialize(serializer) + } + } + } +} + +impl JsonSchema for PackageIdentifierCondition { + fn schema_name() -> std::borrow::Cow<'static, str> { + "PackageIdentifierCondition".into() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "Exactly one package-identifier mode. Exact authorizes stable identifiers; Patterns explicitly authorizes every identifier matched by a wildcard pattern.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["Exact"], + "properties": { + "Exact": { + "type": "array", + "minItems": 1, + "maxItems": 1024, + "uniqueItems": true, + "items": generator.subschema_for::() + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["Patterns"], + "properties": { + "Patterns": { + "type": "array", + "minItems": 1, + "maxItems": 1024, + "uniqueItems": true, + "items": generator.subschema_for::() + } + } + } + ] + }) + } +} + +/// Mutually exclusive package-version condition. +#[derive(Debug, Clone)] +pub enum VersionCondition { + /// One or more exact package version strings. Values need not be semantic versions. + Exact(BTreeSet), + /// Semantic-version range. + Range(VersionRange), +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +enum VersionConditionWire { + Exact(Vec), + Range(VersionRange), +} + +impl<'de> Deserialize<'de> for VersionCondition { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match VersionConditionWire::deserialize(deserializer)? { + VersionConditionWire::Exact(values) if values.is_empty() || values.len() > 256 => Err( + serde::de::Error::custom("Version.Exact must contain between 1 and 256 values"), + ), + VersionConditionWire::Exact(values) => { + reject_duplicate_values(&values, "Version.Exact must not contain duplicate values") + .map_err(serde::de::Error::custom)?; + Ok(Self::Exact(values.into_iter().collect())) + } + VersionConditionWire::Range(range) => Ok(Self::Range(range)), + } + } +} + +impl Serialize for VersionCondition { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Exact(values) if values.is_empty() || values.len() > 256 => Err(serde::ser::Error::custom( + "Version.Exact must contain between 1 and 256 values", + )), + Self::Exact(values) => { + #[derive(Serialize)] + #[serde(rename_all = "PascalCase")] + enum ExactRef<'a> { + Exact(&'a BTreeSet), + } + ExactRef::Exact(values).serialize(serializer) + } + Self::Range(range) => { + #[derive(Serialize)] + #[serde(rename_all = "PascalCase")] + enum RangeRef<'a> { + Range(&'a VersionRange), + } + RangeRef::Range(range).serialize(serializer) + } + } + } +} + +impl JsonSchema for VersionCondition { + fn schema_name() -> std::borrow::Cow<'static, str> { + "VersionCondition".into() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "Exactly one package-version mode. Exact accepts arbitrary package version strings; Range applies only to semantic versions.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["Exact"], + "properties": { + "Exact": { + "type": "array", + "minItems": 1, + "maxItems": 256, + "uniqueItems": true, + "items": generator.subschema_for::() + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["Range"], + "properties": { + "Range": generator.subschema_for::() + } + } + ] + }) + } +} + +/// Nonempty semantic-version range for matching. +#[derive(Debug, Clone, Default, JsonSchema)] +#[schemars(rename = "VersionRange")] +#[schemars(rename_all = "PascalCase")] +#[schemars(deny_unknown_fields)] +#[schemars(transform = require_version_range_boundary)] pub struct VersionRange { /// Minimum version (inclusive). - #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(length(min = 1, max = 128))] - pub min_version: Option, + pub min_version: Option, /// Maximum version (inclusive). - #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(length(min = 1, max = 128))] - pub max_version: Option, + pub max_version: Option, /// Whether to include pre-release versions. #[serde(default)] pub include_prerelease: bool, } -/// Constraints applied after a rule matches. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +struct VersionRangeWire { + #[serde(default, skip_serializing_if = "Option::is_none")] + min_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + max_version: Option, + #[serde(default)] + include_prerelease: bool, +} + +fn validate_version_range(range: &VersionRange) -> Result<(), &'static str> { + if range.min_version.is_none() && range.max_version.is_none() { + return Err("Version.Range must specify MinVersion or MaxVersion"); + } + for version in [range.min_version.as_ref(), range.max_version.as_ref()] + .into_iter() + .flatten() + { + if SemanticVersion::parse(version).is_err() { + return Err("Version.Range boundaries must be canonical semantic versions"); + } + } + Ok(()) +} + +impl<'de> Deserialize<'de> for VersionRange { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let wire = VersionRangeWire::deserialize(deserializer)?; + let range = Self { + min_version: wire.min_version, + max_version: wire.max_version, + include_prerelease: wire.include_prerelease, + }; + validate_version_range(&range).map_err(serde::de::Error::custom)?; + Ok(range) + } +} + +impl Serialize for VersionRange { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + validate_version_range(self).map_err(serde::ser::Error::custom)?; + VersionRangeWire { + min_version: self.min_version.clone(), + max_version: self.max_version.clone(), + include_prerelease: self.include_prerelease, + } + .serialize(serializer) + } +} + +fn require_version_range_boundary(schema: &mut Schema) { + schema + .as_object_mut() + .expect("VersionRange schema should be an object") + .insert( + "anyOf".to_owned(), + serde_json::json!([ + { "required": ["MinVersion"], "properties": { "MinVersion": { "type": "string" } } }, + { "required": ["MaxVersion"], "properties": { "MaxVersion": { "type": "string" } } } + ]), + ); +} + +/// Additional safety limits applied after an Allow rule matches. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[schemars(rename = "PolicyConstraints")] #[serde(rename_all = "PascalCase")] diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index 549e8a3..4f7c7b9 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -5,7 +5,10 @@ use std::path::PathBuf; use chrono::{TimeZone, Utc}; -use now_policy::{CURRENT_POLICY_FORMAT_VERSION, CustomParameterString, PolicyDocument, StringPattern, VersionString}; +use now_policy::{ + CURRENT_POLICY_FORMAT_VERSION, CustomParameterString, ManagerName, PackageIdentifier, PolicyDocument, + SemanticVersion, SourceName, StringPattern, VersionString, +}; fn samples_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/samples") @@ -16,6 +19,7 @@ fn all_sample_policies_deserialize() { let dir = samples_dir(); let policy_files = [ + "boolean-characteristics.policy.json", "corporate-allowlist.policy.json", "deny-risky-options.policy.json", "powershell-advanced.policy.json", @@ -77,20 +81,624 @@ fn draft_conversion_enforces_revision_bounds() { } #[test] -fn mixed_boolean_match_values_are_rejected() { - let path = samples_dir().join("corporate-allowlist.policy.json"); - let mut value: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); - value["Rules"][0]["Match"]["Interactive"] = serde_json::json!([false, true]); +fn validity_windows_are_operational_and_strictly_ordered_by_instant() { + fn committed_metadata(validity: &str) -> String { + format!( + r#"{{ + "Id":"validity.test", + "Publisher":"Test", + "Revision":1, + "PublishedAt":"2026-01-01T00:00:00Z" + {validity} + }}"# + ) + } - let result: Result = serde_json::from_value(value); - assert!(result.is_err()); + fn draft_metadata(validity: &str) -> String { + format!( + r#"{{ + "Id":"validity.test", + "Publisher":"Test" + {validity} + }}"# + ) + } - let empty: now_policy::PolicyMatch = serde_json::from_value(serde_json::json!({ "Interactive": [] })).unwrap(); - assert!(empty.interactive.is_empty()); + for validity in [ + "", + r#","ValidFrom":null,"ValidUntil":null"#, + r#","ValidFrom":"2026-01-01T00:00:00Z""#, + r#","ValidUntil":"2026-01-01T00:00:00Z""#, + r#","ValidFrom":"2026-01-01T01:00:00+01:00","ValidUntil":"2026-01-01T00:30:00Z""#, + ] { + let metadata: now_policy::PolicyMetadata = serde_json::from_str(&committed_metadata(validity)).unwrap(); + let draft: now_policy::PolicyDraftMetadata = serde_json::from_str(&draft_metadata(validity)).unwrap(); + serde_json::to_value(metadata).unwrap(); + serde_json::to_value(draft).unwrap(); + } + + let null_metadata: now_policy::PolicyMetadata = + serde_json::from_str(&committed_metadata(r#","ValidFrom":null,"ValidUntil":null"#)).unwrap(); + let canonical = serde_json::to_value(null_metadata).unwrap(); + assert!(canonical.get("ValidFrom").is_none()); + assert!(canonical.get("ValidUntil").is_none()); + + for validity in [ + r#","ValidFrom":"2026-01-01T01:00:00+01:00","ValidUntil":"2026-01-01T00:00:00Z""#, + r#","ValidFrom":"2026-01-01T00:30:00Z","ValidUntil":"2026-01-01T01:00:00+01:00""#, + ] { + let error = serde_json::from_str::(&committed_metadata(validity)) + .unwrap_err() + .to_string(); + assert!(error.contains("ValidUntil"), "unexpected error: {error}"); + assert!(error.contains("ValidFrom"), "unexpected error: {error}"); + + let error = serde_json::from_str::(&draft_metadata(validity)) + .unwrap_err() + .to_string(); + assert!(error.contains("ValidUntil"), "unexpected error: {error}"); + assert!(error.contains("ValidFrom"), "unexpected error: {error}"); + } - let mut invalid = now_policy::PolicyMatch::default(); - invalid.interactive.extend([false, true]); - assert!(serde_json::to_value(invalid).is_err()); + let mut invalid: now_policy::PolicyDraftMetadata = + serde_json::from_str(&draft_metadata(r#","ValidFrom":"2026-01-01T00:00:00Z""#)).unwrap(); + invalid.valid_until = invalid.valid_from; + let error = serde_json::to_value(&invalid).unwrap_err().to_string(); + assert!(error.contains("ValidUntil"), "unexpected error: {error}"); + + let committed: PolicyDocument = + serde_json::from_str(&std::fs::read_to_string(samples_dir().join("corporate-allowlist.policy.json")).unwrap()) + .unwrap(); + let mut draft = committed.to_draft(); + draft.metadata.valid_from = Some(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap()); + draft.metadata.valid_until = draft.metadata.valid_from; + assert!( + draft + .into_policy_document(1, Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap()) + .is_err() + ); +} + +#[test] +fn boolean_match_characteristics_accept_omitted_null_false_and_true() { + let property_names = [ + "Interactive", + "SkipHashCheck", + "PreRelease", + "HasCustomParameters", + "HasCustomInstallLocation", + "HasPrePostCommands", + "HasKillBeforeOperation", + "HasUninstallPrevious", + ]; + + let omitted: now_policy::PolicyMatch = serde_json::from_str("{}").unwrap(); + let omitted_json = serde_json::to_value(omitted).unwrap(); + for property_name in property_names { + assert!(omitted_json.get(property_name).is_none()); + + let explicit_null: now_policy::PolicyMatch = + serde_json::from_str(&format!(r#"{{"{property_name}":null}}"#)).unwrap(); + assert!( + serde_json::to_value(explicit_null) + .unwrap() + .get(property_name) + .is_none() + ); + + for expected in [false, true] { + let value: now_policy::PolicyMatch = + serde_json::from_str(&format!(r#"{{"{property_name}":{expected}}}"#)).unwrap(); + assert_eq!(serde_json::to_value(value).unwrap()[property_name], expected); + } + } +} + +#[test] +fn boolean_match_characteristics_reject_legacy_arrays_and_wrong_types() { + let property_names = [ + "Interactive", + "SkipHashCheck", + "PreRelease", + "HasCustomParameters", + "HasCustomInstallLocation", + "HasPrePostCommands", + "HasKillBeforeOperation", + "HasUninstallPrevious", + ]; + + for property_name in property_names { + for invalid_value in ["[]", "[false]", "[true]", "[false,true]", "\"true\"", "0", "{}"] { + let json = format!(r#"{{"{property_name}":{invalid_value}}}"#); + assert!( + serde_json::from_str::(&json).is_err(), + "{property_name} should reject {invalid_value}" + ); + } + } +} + +#[test] +fn null_only_boolean_match_is_not_an_effective_rule_criterion() { + let property_names = [ + "Interactive", + "SkipHashCheck", + "PreRelease", + "HasCustomParameters", + "HasCustomInstallLocation", + "HasPrePostCommands", + "HasKillBeforeOperation", + "HasUninstallPrevious", + ]; + + for property_name in property_names { + let null_only = + format!(r#"{{"Id":"test.rule","Priority":1,"Decision":"Allow","Match":{{"{property_name}":null}}}}"#); + assert!(serde_json::from_str::(&null_only).is_err()); + + let with_operation = format!( + r#"{{"Id":"test.rule","Priority":1,"Decision":"Allow","Match":{{"Operations":["Install"],"{property_name}":null}}}}"# + ); + assert!(serde_json::from_str::(&with_operation).is_ok()); + } +} + +#[test] +fn constraints_are_valid_only_for_allow_rules() { + let allow_with_constraints = r#"{ + "Id":"allow.rule", + "Priority":1, + "Decision":"Allow", + "Match":{"Operations":["Install"]}, + "Constraints":{"AllowInteractive":false} + }"#; + let allow: now_policy::PolicyRule = serde_json::from_str(allow_with_constraints).unwrap(); + assert!(allow.constraints.is_some()); + assert!(serde_json::to_value(&allow).unwrap().get("Constraints").is_some()); + + let allow_without_constraints = r#"{ + "Id":"allow.rule", + "Priority":1, + "Decision":"Allow", + "Match":{"Operations":["Install"]} + }"#; + assert!(serde_json::from_str::(allow_without_constraints).is_ok()); + + let deny_without_constraints = r#"{ + "Id":"deny.rule", + "Priority":1, + "Decision":"Deny", + "Match":{"Operations":["Install"]} + }"#; + assert!(serde_json::from_str::(deny_without_constraints).is_ok()); + + let deny_with_null_constraints = r#"{ + "Id":"deny.rule", + "Priority":1, + "Decision":"Deny", + "Match":{"Operations":["Install"]}, + "Constraints":null + }"#; + let deny: now_policy::PolicyRule = serde_json::from_str(deny_with_null_constraints).unwrap(); + assert!(serde_json::to_value(&deny).unwrap().get("Constraints").is_none()); + + let deny_with_constraints = r#"{ + "Id":"deny.rule", + "Enabled":false, + "Priority":1, + "Decision":"Deny", + "Match":{"Operations":["Install"]}, + "Constraints":{"AllowInteractive":false} + }"#; + let error = serde_json::from_str::(deny_with_constraints) + .unwrap_err() + .to_string(); + assert!(error.contains("PolicyRule.Constraints"), "unexpected error: {error}"); + + let mut invalid = allow; + invalid.decision = now_policy::Decision::Deny; + let error = serde_json::to_value(invalid).unwrap_err().to_string(); + assert!(error.contains("PolicyRule.Constraints"), "unexpected error: {error}"); +} + +#[test] +fn boolean_match_characteristics_round_trip_in_representative_mixed_match() { + let json = serde_json::json!({ + "Operations": ["Install"], + "Interactive": false, + "SkipHashCheck": true, + "HasCustomParameters": false, + "HasUninstallPrevious": true + }); + let value: now_policy::PolicyMatch = serde_json::from_value(json).unwrap(); + + assert_eq!(value.interactive, Some(false)); + assert_eq!(value.skip_hash_check, Some(true)); + assert_eq!(value.has_custom_parameters, Some(false)); + assert_eq!(value.has_uninstall_previous, Some(true)); + assert_eq!(value.pre_release, None); + assert_eq!(value.has_custom_install_location, None); + assert_eq!(value.has_pre_post_commands, None); + assert_eq!(value.has_kill_before_operation, None); + + let serialized = serde_json::to_value(value).unwrap(); + assert_eq!(serialized["Interactive"], false); + assert_eq!(serialized["SkipHashCheck"], true); + assert!(serialized.get("PreRelease").is_none()); + assert!(serialized.get("HasCustomInstallLocation").is_none()); +} + +#[test] +fn collection_match_filters_accept_empty_input_and_canonicalize_to_omitted() { + let properties = [ + ("Operations", "\"Install\""), + ("Managers", "\"Winget\""), + ("SourceNames", "\"winget\""), + ("Scopes", "\"User\""), + ("Architectures", "\"X64\""), + ("ExecutionElevation", "\"Standard\""), + ]; + + for (property_name, element_json) in properties { + let omitted: now_policy::PolicyMatch = serde_json::from_str("{}").unwrap(); + assert!(serde_json::to_value(omitted).unwrap().get(property_name).is_none()); + + let empty: now_policy::PolicyMatch = serde_json::from_str(&format!(r#"{{"{property_name}":[]}}"#)).unwrap(); + assert!(serde_json::to_value(empty).unwrap().get(property_name).is_none()); + + let populated_json = format!( + r#"{{"{property_name}":[{element_json}]{} }}"#, + if property_name == "SourceNames" { + r#","Managers":["Winget"]"# + } else { + "" + } + ); + let populated: now_policy::PolicyMatch = serde_json::from_str(&populated_json).unwrap(); + assert_eq!( + serde_json::to_value(populated).unwrap()[property_name] + .as_array() + .map(Vec::len), + Some(1) + ); + } +} + +#[test] +fn empty_collection_only_match_is_not_an_effective_rule_criterion() { + let properties = [ + ("Operations", "\"Install\""), + ("Managers", "\"Winget\""), + ("SourceNames", "\"winget\""), + ("Scopes", "\"User\""), + ("Architectures", "\"X64\""), + ("ExecutionElevation", "\"Standard\""), + ]; + + for (property_name, element_json) in properties { + let empty_only = + format!(r#"{{"Id":"test.rule","Priority":1,"Decision":"Allow","Match":{{"{property_name}":[]}}}}"#); + assert!(serde_json::from_str::(&empty_only).is_err()); + + let with_boolean = format!( + r#"{{"Id":"test.rule","Priority":1,"Decision":"Allow","Match":{{"{property_name}":[],"Interactive":false}}}}"# + ); + let rule: now_policy::PolicyRule = serde_json::from_str(&with_boolean).unwrap(); + let serialized = serde_json::to_value(rule).unwrap(); + assert!(serialized["Match"].get(property_name).is_none()); + assert_eq!(serialized["Match"]["Interactive"], false); + + let populated = format!( + r#"{{"Id":"test.rule","Priority":1,"Decision":"Allow","Match":{{"{property_name}":[{element_json}]{} }}}}"#, + if property_name == "SourceNames" { + r#","Managers":["Winget"]"# + } else { + "" + } + ); + assert!(serde_json::from_str::(&populated).is_ok()); + } +} + +#[test] +fn collection_match_filters_reject_duplicate_values() { + let properties = [ + ("Operations", "\"Install\""), + ("Managers", "\"Winget\""), + ("SourceNames", "\"winget\""), + ("Scopes", "\"User\""), + ("Architectures", "\"X64\""), + ("ExecutionElevation", "\"Standard\""), + ]; + + for (property_name, element_json) in properties { + let json = format!( + r#"{{"{property_name}":[{element_json},{element_json}]{} }}"#, + if property_name == "SourceNames" { + r#","Managers":["Winget"]"# + } else { + "" + } + ); + assert!( + serde_json::from_str::(&json).is_err(), + "{property_name} should reject duplicate values" + ); + } +} + +#[test] +fn source_names_require_managers_and_preserve_exact_literal_names() { + let without_manager = r#"{ + "Id":"source.rule", + "Priority":1, + "Decision":"Allow", + "Match":{"SourceNames":["corp*"]} + }"#; + let error = serde_json::from_str::(without_manager) + .unwrap_err() + .to_string(); + assert!(error.contains("SourceNames"), "unexpected error: {error}"); + + let with_manager = r#"{ + "Id":"source.rule", + "Priority":1, + "Decision":"Allow", + "Match":{ + "Managers":["Winget"], + "SourceNames":["corp*","PSGallery"] + } + }"#; + let mut rule: now_policy::PolicyRule = serde_json::from_str(with_manager).unwrap(); + assert_eq!(rule.match_criteria.managers.len(), 1); + assert_eq!(rule.match_criteria.source_names.len(), 2); + assert!( + rule.match_criteria + .source_names + .iter() + .any(|name| name.as_ref() == "corp*") + ); + + rule.match_criteria.managers.clear(); + let error = serde_json::to_value(rule).unwrap_err().to_string(); + assert!(error.contains("SourceNames"), "unexpected error: {error}"); + + let multiple_managers = r#"{ + "Id":"source.rule", + "Priority":1, + "Decision":"Allow", + "Match":{ + "Managers":["Winget","PowerShell"], + "SourceNames":["corp"] + } + }"#; + let error = serde_json::from_str::(multiple_managers) + .unwrap_err() + .to_string(); + assert!(error.contains("SourceNames"), "unexpected error: {error}"); +} + +#[test] +fn managers_enforce_schema_collection_bound_on_input_and_output() { + let manager_names = [ + "Winget", + "PowerShell", + "PowerShell7", + "Apt", + "Bun", + "Cargo", + "Chocolatey", + "Dnf", + "Dotnet", + "Flatpak", + "Homebrew", + "Npm", + "Pacman", + "Pip", + "Scoop", + "Snap", + "Vcpkg", + ]; + let managers = manager_names[..16] + .iter() + .map(|name| format!(r#""{name}""#)) + .collect::>() + .join(","); + let json = format!(r#"{{"Managers":[{managers}]}}"#); + let mut maximum: now_policy::PolicyMatch = serde_json::from_str(&json).unwrap(); + assert_eq!(maximum.managers.len(), 16); + serde_json::to_value(&maximum).unwrap(); + + let too_many = format!(r#"{{"Managers":[{managers},"Vcpkg"]}}"#); + let error = serde_json::from_str::(&too_many) + .unwrap_err() + .to_string(); + assert!(error.contains("at most 16"), "unexpected error: {error}"); + + maximum.managers.insert(ManagerName::Vcpkg); + let error = serde_json::to_value(maximum).unwrap_err().to_string(); + assert!(error.contains("at most 16"), "unexpected error: {error}"); +} + +#[test] +fn source_names_enforce_schema_collection_bound_on_input_and_output() { + let source_names = (0..128) + .map(|index| format!(r#""source-{index}""#)) + .collect::>() + .join(","); + let json = format!(r#"{{"Managers":["Winget"],"SourceNames":[{source_names}]}}"#); + let mut maximum: now_policy::PolicyMatch = serde_json::from_str(&json).unwrap(); + assert_eq!(maximum.source_names.len(), 128); + serde_json::to_value(&maximum).unwrap(); + + let too_many = format!(r#"{{"Managers":["Winget"],"SourceNames":[{source_names},"source-128"]}}"#); + let error = serde_json::from_str::(&too_many) + .unwrap_err() + .to_string(); + assert!(error.contains("at most 128"), "unexpected error: {error}"); + + maximum.source_names.insert(SourceName::parse("source-128").unwrap()); + let error = serde_json::to_value(maximum).unwrap_err().to_string(); + assert!(error.contains("at most 128"), "unexpected error: {error}"); +} + +#[test] +fn package_identifier_condition_requires_exactly_one_nonempty_mode() { + let exact: now_policy::PackageIdentifierCondition = + serde_json::from_str(r#"{"Exact":["Microsoft.VisualStudioCode"]}"#).unwrap(); + assert!(matches!(exact, now_policy::PackageIdentifierCondition::Exact(_))); + + let patterns: now_policy::PackageIdentifierCondition = + serde_json::from_str(r#"{"Patterns":["Microsoft.*"]}"#).unwrap(); + assert!(matches!(patterns, now_policy::PackageIdentifierCondition::Patterns(_))); + + for invalid in [ + "{}", + r#"{"Exact":[]}"#, + r#"{"Patterns":[]}"#, + r#"{"Exact":["Microsoft.VisualStudioCode"],"Patterns":["Microsoft.*"]}"#, + r#"{"Exact":["Microsoft.VisualStudioCode"],"Patterns":null}"#, + r#"{"Patterns":null,"Exact":["Microsoft.VisualStudioCode"]}"#, + r#"{"Patterns":["Microsoft.*"],"Exact":null}"#, + r#"{"Exact":null,"Patterns":["Microsoft.*"]}"#, + r#"{"Exact":["Microsoft.*"]}"#, + r#"{"Exact":["Git.Git","Git.Git"]}"#, + r#"{"Patterns":["Git.*","Git.*"]}"#, + r#"{"Exact":["Microsoft.VisualStudioCode"],"\u0045xact":["Git.Git"]}"#, + ] { + assert!( + serde_json::from_str::(invalid).is_err(), + "should reject {invalid}" + ); + } + + assert!( + serde_json::from_str::(r#"{"PackageIdentifiers":["Microsoft.VisualStudioCode"]}"#) + .is_err() + ); + let absent: now_policy::PolicyMatch = serde_json::from_str(r#"{"PackageIdentifiers":null}"#).unwrap(); + assert!( + serde_json::to_value(absent) + .unwrap() + .get("PackageIdentifiers") + .is_none() + ); +} + +#[test] +fn version_condition_requires_exactly_one_nonempty_mode() { + let exact: now_policy::VersionCondition = + serde_json::from_str(r#"{"Exact":["5.6.0.0","2026.09-preview"]}"#).unwrap(); + assert!(matches!(exact, now_policy::VersionCondition::Exact(_))); + + let range: now_policy::VersionCondition = + serde_json::from_str(r#"{"Range":{"MinVersion":"1.0.0","MaxVersion":"2.0.0"}}"#).unwrap(); + assert!(matches!(range, now_policy::VersionCondition::Range(_))); + assert!( + serde_json::from_str::( + r#"{"Range":{"MinVersion":"1.0.0-beta.1","IncludePrerelease":true}}"# + ) + .is_ok() + ); + + for invalid in [ + "{}", + r#"{"Exact":[]}"#, + r#"{"Range":{}}"#, + r#"{"Range":{"MinVersion":null,"MaxVersion":null}}"#, + r#"{"Range":{"MinVersion":"not-semver"}}"#, + r#"{"Range":{"MinVersion":"1.18446744073709551616.0"}}"#, + r#"{"Range":{"MinVersion":"1.0.0-١a"}}"#, + "{\"Range\":{\"MaxVersion\":\"1.0.0\\n\"}}", + r#"{"Exact":["1.0.0"],"Range":{"MinVersion":"1.0.0"}}"#, + r#"{"Exact":["1.0.0"],"Range":null}"#, + r#"{"Range":null,"Exact":["1.0.0"]}"#, + r#"{"Range":{"MinVersion":"1.0.0"},"Exact":null}"#, + r#"{"Exact":null,"Range":{"MinVersion":"1.0.0"}}"#, + r#"{"Exact":["1.0.0","1.0.0"]}"#, + r#"{"Exact":["1.0.0"],"\u0045xact":["2.0.0"]}"#, + ] { + assert!( + serde_json::from_str::(invalid).is_err(), + "should reject {invalid}" + ); + } + + for old in [ + r#"{"Versions":["1.0.0"]}"#, + r#"{"VersionRange":{"MinVersion":"1.0.0"}}"#, + ] { + assert!( + serde_json::from_str::(old).is_err(), + "should reject {old}" + ); + } + let absent: now_policy::PolicyMatch = serde_json::from_str(r#"{"Version":null}"#).unwrap(); + assert!(serde_json::to_value(absent).unwrap().get("Version").is_none()); +} + +#[test] +fn package_identifier_and_version_condition_bounds_apply_on_input_and_output() { + let identifiers = (0..=1024).map(|index| format!("Package.{index}")).collect::>(); + let identifier_json = serde_json::json!({ "Exact": identifiers }); + assert!(serde_json::from_value::(identifier_json).is_err()); + + let versions = (0..=256).map(|index| format!("1.0.{index}")).collect::>(); + let version_json = serde_json::json!({ "Exact": versions }); + assert!(serde_json::from_value::(version_json).is_err()); + + let identifiers = (0..=1024) + .map(|index| PackageIdentifier::parse(&format!("Package.{index}")).unwrap()) + .collect(); + assert!(serde_json::to_value(now_policy::PackageIdentifierCondition::Exact(identifiers)).is_err()); + + let versions = (0..=256) + .map(|index| VersionString::parse(&format!("1.0.{index}")).unwrap()) + .collect(); + assert!(serde_json::to_value(now_policy::VersionCondition::Exact(versions)).is_err()); + + assert!(serde_json::to_value(now_policy::VersionRange::default()).is_err()); + let invalid_range = now_policy::VersionRange { + min_version: Some(SemanticVersion::from("not-semver")), + max_version: None, + include_prerelease: false, + }; + assert!(serde_json::to_value(invalid_range).is_err()); + + let mut invalid_patterns = std::collections::BTreeSet::new(); + invalid_patterns.insert(StringPattern(String::new())); + assert!(serde_json::to_value(now_policy::PackageIdentifierCondition::Patterns(invalid_patterns)).is_err()); + + let mut invalid_versions = std::collections::BTreeSet::new(); + invalid_versions.insert(VersionString(String::new())); + assert!(serde_json::to_value(now_policy::VersionCondition::Exact(invalid_versions)).is_err()); +} + +#[test] +fn shared_boolean_characteristics_sample_has_expected_scalar_values() { + let path = samples_dir().join("boolean-characteristics.policy.json"); + let policy: PolicyDocument = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + let match_criteria = &policy.rules[0].match_criteria; + + assert_eq!(match_criteria.interactive, Some(false)); + assert_eq!(match_criteria.skip_hash_check, Some(true)); + assert_eq!(match_criteria.pre_release, Some(false)); + assert_eq!(match_criteria.has_custom_parameters, Some(true)); + assert_eq!(match_criteria.has_custom_install_location, Some(false)); + assert_eq!(match_criteria.has_pre_post_commands, Some(true)); + assert_eq!(match_criteria.has_kill_before_operation, Some(false)); + assert_eq!(match_criteria.has_uninstall_previous, Some(true)); + assert_eq!(match_criteria.managers.len(), 1); + assert_eq!(match_criteria.source_names.len(), 1); + assert_eq!(match_criteria.execution_elevation.len(), 1); + assert!(matches!( + match_criteria.package_identifiers.as_ref(), + Some(now_policy::PackageIdentifierCondition::Exact(_)) + )); + assert!(matches!( + match_criteria.version.as_ref(), + Some(now_policy::VersionCondition::Exact(_)) + )); } #[test] @@ -100,6 +708,14 @@ fn policy_text_newtypes_count_unicode_scalars_at_length_boundaries() { assert!(StringPattern::parse(&multibyte_scalar.repeat(256)).is_ok()); assert!(StringPattern::parse(&multibyte_scalar.repeat(257)).is_err()); + assert!(SourceName::parse(&multibyte_scalar.repeat(128)).is_ok()); + assert!(SourceName::parse(&multibyte_scalar.repeat(129)).is_err()); + assert_eq!(SourceName::parse("corp*").unwrap().as_ref(), "corp*"); + + assert!(PackageIdentifier::parse("Microsoft.VisualStudioCode").is_ok()); + assert!(PackageIdentifier::parse("Microsoft.*").is_err()); + assert!(PackageIdentifier::parse("Git.Git\n").is_err()); + assert!(VersionString::parse(&multibyte_scalar.repeat(128)).is_ok()); assert!(VersionString::parse(&multibyte_scalar.repeat(129)).is_err()); @@ -111,7 +727,6 @@ fn policy_text_newtypes_count_unicode_scalars_at_length_boundaries() { fn invalid_policy_unknown_field_fails_deserialization() { let value = serde_json::json!({ "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "test", "Publisher": "Test", @@ -120,7 +735,6 @@ fn invalid_policy_unknown_field_fails_deserialization() { }, "Enforcement": { "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny", "UnknownField": true }, "Rules": [] @@ -130,6 +744,41 @@ fn invalid_policy_unknown_field_fails_deserialization() { assert!(result.is_err(), "policy with unknown field should fail deserialization"); } +#[test] +fn removed_policy_members_fail_deserialization() { + let path = samples_dir().join("corporate-allowlist.policy.json"); + let valid: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + + for removed_member in [ + "PolicyType", + "RulePrecedence", + "PackageNames", + "Elevation", + "Sources", + "Versions", + "VersionRange", + ] { + let mut value = valid.clone(); + match removed_member { + "PolicyType" => value[removed_member] = serde_json::json!("PackageBrokerPolicy"), + "RulePrecedence" => value["Enforcement"][removed_member] = serde_json::json!("PriorityThenDeny"), + "PackageNames" => { + value["Rules"][0]["Match"][removed_member] = serde_json::json!(["Friendly package name"]); + } + "Elevation" => value["Rules"][0]["Match"][removed_member] = serde_json::json!(["Elevated"]), + "VersionRange" => { + value["Rules"][0]["Match"][removed_member] = serde_json::json!({ "MinVersion": "1.0.0" }); + } + _ => value["Rules"][0]["Match"][removed_member] = serde_json::json!(["winget"]), + } + + assert!( + serde_json::from_value::(value).is_err(), + "{removed_member} should be rejected as unknown" + ); + } +} + #[test] fn schema_field_is_rejected() { let path = samples_dir().join("corporate-allowlist.policy.json"); @@ -198,6 +847,43 @@ fn invalid_policy_fixture_fails_deserialization() { let content = std::fs::read_to_string(&path).unwrap(); let result: Result = serde_json::from_str(&content); assert!(result.is_err(), "invalid policy fixture should fail deserialization"); + + let mut corrected: serde_json::Value = serde_json::from_str(&content).unwrap(); + corrected["Enforcement"] + .as_object_mut() + .expect("Enforcement should be an object") + .remove("failureDecision"); + assert!( + serde_json::from_value::(corrected).is_ok(), + "fixture should be valid after removing its intentionally invalid member" + ); +} + +#[test] +fn duplicate_property_fixtures_fail_deserialization() { + let duplicate_dir = samples_dir().join("invalid/duplicates"); + let entries = std::fs::read_dir(&duplicate_dir) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", duplicate_dir.display())); + let mut fixture_count = 0; + + for entry in entries { + let path = entry.unwrap().path(); + if path.extension().is_none_or(|extension| extension != "json") { + continue; + } + + fixture_count += 1; + let content = + std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + let result: Result = serde_json::from_str(&content); + assert!( + result.is_err(), + "duplicate property fixture {} should fail deserialization", + path.display() + ); + } + + assert_eq!(fixture_count, 9, "all shared duplicate fixtures must be exercised"); } #[test] @@ -229,6 +915,33 @@ fn policy_schemas_are_repository_local_and_omit_document_schema() { } } +#[test] +fn policy_schemas_document_the_runtime_validity_window_invariant() { + for (schema, metadata_name) in [ + (now_policy::schema::policy_schema_json(), "PolicyMetadata"), + (now_policy::schema::policy_draft_schema_json(), "PolicyDraftMetadata"), + ] { + let metadata = &schema["definitions"][metadata_name]; + assert_eq!(metadata["additionalProperties"], false); + assert!( + metadata["description"] + .as_str() + .unwrap() + .contains("ValidFrom` must be strictly earlier") + ); + assert!( + metadata["properties"]["ValidUntil"]["description"] + .as_str() + .unwrap() + .contains("strictly later than `ValidFrom`") + ); + if metadata_name == "PolicyDraftMetadata" { + assert!(metadata["properties"].get("Revision").is_none()); + assert!(metadata["properties"].get("PublishedAt").is_none()); + } + } +} + #[test] fn committed_policy_enforces_revision_bounds_during_serialization_and_deserialization() { let path = samples_dir().join("corporate-allowlist.policy.json"); @@ -259,14 +972,44 @@ fn policy_match_schema_requires_at_least_one_property() { } #[test] -fn policy_match_schema_limits_boolean_arrays_to_one_item() { +fn policy_match_schema_uses_nullable_scalar_booleans() { let schema = now_policy::schema::policy_schema_json(); - let max_items = [ - "/definitions/PolicyMatch/properties/Interactive/maxItems", - "/$defs/PolicyMatch/properties/Interactive/maxItems", - ] - .into_iter() - .find_map(|path| schema.pointer(path).and_then(serde_json::Value::as_u64)); + let property_names = [ + "Interactive", + "SkipHashCheck", + "PreRelease", + "HasCustomParameters", + "HasCustomInstallLocation", + "HasPrePostCommands", + "HasKillBeforeOperation", + "HasUninstallPrevious", + ]; - assert_eq!(max_items, Some(1)); + for property_name in property_names { + let property = [ + format!("/definitions/PolicyRule/properties/Match/properties/{property_name}"), + format!("/$defs/PolicyRule/properties/Match/properties/{property_name}"), + ] + .into_iter() + .find_map(|path| schema.pointer(&path)) + .unwrap_or_else(|| panic!("missing PolicyMatch.{property_name} schema")); + let types = property["type"].as_array().expect("optional boolean type array"); + + assert!(types.iter().any(|value| value == "boolean")); + assert!(types.iter().any(|value| value == "null")); + assert!(property.get("items").is_none()); + assert!(property.get("maxItems").is_none()); + assert!(property.get("uniqueItems").is_none()); + } +} + +#[test] +fn standalone_policy_match_schema_requires_exactly_one_manager_for_source_names() { + let schema = serde_json::to_value(schemars::schema_for!(now_policy::PolicyMatch)).unwrap(); + let root = schema.as_object().expect("PolicyMatch schema should be an object"); + + assert!(root.contains_key("if")); + assert!(root.contains_key("then")); + assert_eq!(schema["then"]["properties"]["Managers"]["minItems"], 1); + assert_eq!(schema["then"]["properties"]["Managers"]["maxItems"], 1); } diff --git a/policies/test-data/package-broker/invalid/responses/policy-validation.valid-with-error.response.json b/policies/test-data/package-broker/invalid/responses/policy-validation.valid-with-error.response.json index 0503209..d30ba80 100644 --- a/policies/test-data/package-broker/invalid/responses/policy-validation.valid-with-error.response.json +++ b/policies/test-data/package-broker/invalid/responses/policy-validation.valid-with-error.response.json @@ -11,14 +11,12 @@ "IsValid": true, "CanonicalDraft": { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT" }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "DefaultDecision": "Deny" }, "Rules": [] }, diff --git a/policies/test-data/package-broker/requests/policy-replacement.create.request.json b/policies/test-data/package-broker/requests/policy-replacement.create.request.json index c1e94e6..d1b59d8 100644 --- a/policies/test-data/package-broker/requests/policy-replacement.create.request.json +++ b/policies/test-data/package-broker/requests/policy-replacement.create.request.json @@ -7,9 +7,8 @@ "WarningsAcknowledged": false, "Draft": { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT" }, - "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Enforcement": { "DefaultDecision": "Deny" }, "Rules": [] }, "ValidationReceipt": "receipt:sha256:create" diff --git a/policies/test-data/package-broker/requests/policy-replacement.overwrite.request.json b/policies/test-data/package-broker/requests/policy-replacement.overwrite.request.json index 8e08b9e..2ee71f9 100644 --- a/policies/test-data/package-broker/requests/policy-replacement.overwrite.request.json +++ b/policies/test-data/package-broker/requests/policy-replacement.overwrite.request.json @@ -7,9 +7,8 @@ "WarningsAcknowledged": true, "Draft": { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT" }, - "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Enforcement": { "DefaultDecision": "Deny" }, "Rules": [] }, "ValidationReceipt": "receipt:sha256:overwrite" diff --git a/policies/test-data/package-broker/requests/policy-replacement.repair.request.json b/policies/test-data/package-broker/requests/policy-replacement.repair.request.json index 9e7e6bb..bc5c6bd 100644 --- a/policies/test-data/package-broker/requests/policy-replacement.repair.request.json +++ b/policies/test-data/package-broker/requests/policy-replacement.repair.request.json @@ -7,9 +7,8 @@ "WarningsAcknowledged": false, "Draft": { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT" }, - "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Enforcement": { "DefaultDecision": "Deny" }, "Rules": [] }, "ValidationReceipt": "receipt:sha256:repair" diff --git a/policies/test-data/package-broker/requests/policy-replacement.replace-identity.request.json b/policies/test-data/package-broker/requests/policy-replacement.replace-identity.request.json index 257cf42..a3e636e 100644 --- a/policies/test-data/package-broker/requests/policy-replacement.replace-identity.request.json +++ b/policies/test-data/package-broker/requests/policy-replacement.replace-identity.request.json @@ -7,9 +7,8 @@ "WarningsAcknowledged": false, "Draft": { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "fabrikam.package-policy", "Publisher": "Fabrikam IT" }, - "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Enforcement": { "DefaultDecision": "Deny" }, "Rules": [] }, "ValidationReceipt": "receipt:sha256:replace" diff --git a/policies/test-data/package-broker/requests/policy-replacement.update.request.json b/policies/test-data/package-broker/requests/policy-replacement.update.request.json index ba20df6..42f4310 100644 --- a/policies/test-data/package-broker/requests/policy-replacement.update.request.json +++ b/policies/test-data/package-broker/requests/policy-replacement.update.request.json @@ -7,9 +7,8 @@ "WarningsAcknowledged": true, "Draft": { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT" }, - "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Enforcement": { "DefaultDecision": "Deny" }, "Rules": [] }, "ValidationReceipt": "receipt:sha256:update" diff --git a/policies/test-data/package-broker/requests/policy-validation.request.json b/policies/test-data/package-broker/requests/policy-validation.request.json index 2179ea6..b6a237b 100644 --- a/policies/test-data/package-broker/requests/policy-validation.request.json +++ b/policies/test-data/package-broker/requests/policy-validation.request.json @@ -3,14 +3,12 @@ "RequestVersion": "1.0", "Draft": { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT" }, "Enforcement": { "DefaultDecision": "Allow", - "RulePrecedence": "PriorityThenDeny", "AuditMode": true }, "Rules": [ @@ -20,9 +18,15 @@ "Priority": 100, "Decision": "Allow", "Match": { - "Managers": ["Winget"], - "PackageIdentifiers": ["Microsoft.VisualStudioCode"], - "SkipHashCheck": [true] + "Managers": [ + "Winget" + ], + "PackageIdentifiers": { + "Exact": [ + "Microsoft.VisualStudioCode" + ] + }, + "SkipHashCheck": true } } ], diff --git a/policies/test-data/package-broker/responses/policy-management.active.response.json b/policies/test-data/package-broker/responses/policy-management.active.response.json index 4a73225..aa201b3 100644 --- a/policies/test-data/package-broker/responses/policy-management.active.response.json +++ b/policies/test-data/package-broker/responses/policy-management.active.response.json @@ -14,7 +14,6 @@ "ElevationRequired": true, "Policy": { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT", @@ -22,8 +21,7 @@ "PublishedAt": "2026-08-29T00:00:00Z" }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "DefaultDecision": "Deny" }, "Rules": [] } diff --git a/policies/test-data/package-broker/responses/policy-replacement.response.json b/policies/test-data/package-broker/responses/policy-replacement.response.json index c1d2d6d..aef61ac 100644 --- a/policies/test-data/package-broker/responses/policy-replacement.response.json +++ b/policies/test-data/package-broker/responses/policy-replacement.response.json @@ -7,7 +7,6 @@ }, "Policy": { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT", @@ -15,8 +14,7 @@ "PublishedAt": "2026-08-29T01:00:00Z" }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "DefaultDecision": "Deny" }, "Rules": [] }, @@ -26,14 +24,12 @@ "IsValid": true, "CanonicalDraft": { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT" }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "DefaultDecision": "Deny" }, "Rules": [] }, @@ -49,7 +45,6 @@ "ElevationRequired": true, "Policy": { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT", @@ -57,8 +52,7 @@ "PublishedAt": "2026-08-29T01:00:00Z" }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "DefaultDecision": "Deny" }, "Rules": [] } diff --git a/policies/test-data/package-broker/responses/policy-stale-token.error.json b/policies/test-data/package-broker/responses/policy-stale-token.error.json index 300b6e9..2c2da3e 100644 --- a/policies/test-data/package-broker/responses/policy-stale-token.error.json +++ b/policies/test-data/package-broker/responses/policy-stale-token.error.json @@ -16,7 +16,6 @@ "ElevationRequired": true, "Policy": { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT", @@ -24,8 +23,7 @@ "PublishedAt": "2026-08-29T01:00:00Z" }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "DefaultDecision": "Deny" }, "Rules": [] } diff --git a/policies/test-data/package-broker/responses/policy-validation.invalid.response.json b/policies/test-data/package-broker/responses/policy-validation.invalid.response.json index 2924228..f2492b7 100644 --- a/policies/test-data/package-broker/responses/policy-validation.invalid.response.json +++ b/policies/test-data/package-broker/responses/policy-validation.invalid.response.json @@ -16,13 +16,13 @@ { "FindingVersion": "1.0", "Severity": "Error", "Code": "InvalidFieldType", "Path": "/Rules", "Message": "The property has the wrong JSON type." }, { "FindingVersion": "1.0", "Severity": "Error", "Code": "InvalidFieldValue", "Path": "/Rules/0/Priority", "RuleId": "duplicate", "Message": "The property value is invalid." }, { "FindingVersion": "1.0", "Severity": "Error", "Code": "DuplicateRuleId", "Path": "/Rules/1/Id", "RuleId": "duplicate", "Message": "Rule identifiers must be unique." }, - { "FindingVersion": "1.0", "Severity": "Error", "Code": "IneffectiveBooleanMatch", "Path": "/Rules/0/Match/Interactive", "RuleId": "duplicate", "Message": "A boolean match must contain only true or only false." }, - { "FindingVersion": "1.0", "Severity": "Error", "Code": "InvalidVersionRange", "Path": "/Rules/0/Match/VersionRange", "RuleId": "duplicate", "Message": "The version range is invalid." }, - { "FindingVersion": "1.0", "Severity": "Error", "Code": "EmptyVersionRange", "Path": "/Rules/0/Match/VersionRange", "RuleId": "duplicate", "Message": "The version range must specify a boundary." }, - { "FindingVersion": "1.0", "Severity": "Error", "Code": "InvalidWildcardPattern", "Path": "/Rules/0/Match/PackageIdentifiers/0", "RuleId": "duplicate", "Message": "The wildcard pattern is invalid." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "InvalidFieldType", "Path": "/Rules/0/Match/Interactive", "RuleId": "duplicate", "Message": "The request characteristic condition must be a scalar boolean." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "InvalidVersionRange", "Path": "/Rules/0/Match/Version/Range", "RuleId": "duplicate", "Message": "The semantic version range is invalid." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "EmptyVersionRange", "Path": "/Rules/0/Match/Version/Range", "RuleId": "duplicate", "Message": "The semantic version range must specify a boundary." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "InvalidWildcardPattern", "Path": "/Rules/0/Match/PackageIdentifiers/Patterns/0", "RuleId": "duplicate", "Message": "The wildcard pattern is invalid." }, { "FindingVersion": "1.0", "Severity": "Error", "Code": "ContradictoryConstraints", "Path": "/Rules/0/Constraints", "RuleId": "duplicate", "Message": "The constraints contradict each other." }, { "FindingVersion": "1.0", "Severity": "Error", "Code": "InvalidValidityInterval", "Path": "/Metadata/ValidUntil", "Message": "ValidUntil must be after ValidFrom." }, - { "FindingVersion": "1.0", "Severity": "Error", "Code": "UnsupportedPolicyType", "Path": "/PolicyType", "Message": "The policy type is not supported." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "UnknownField", "Path": "/PolicyType", "Message": "PolicyType is not part of the policy document contract." }, { "FindingVersion": "1.0", "Severity": "Error", "Code": "UnsupportedPolicyFormatVersion", "Path": "/PolicyFormatVersion", "Message": "The policy format version is not supported." } ] } diff --git a/policies/test-data/package-broker/responses/policy-validation.valid.response.json b/policies/test-data/package-broker/responses/policy-validation.valid.response.json index fd5703f..38fe345 100644 --- a/policies/test-data/package-broker/responses/policy-validation.valid.response.json +++ b/policies/test-data/package-broker/responses/policy-validation.valid.response.json @@ -11,14 +11,12 @@ "IsValid": true, "CanonicalDraft": { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT" }, "Enforcement": { "DefaultDecision": "Allow", - "RulePrecedence": "PriorityThenDeny", "AuditMode": true }, "Rules": [ @@ -28,9 +26,15 @@ "Priority": 100, "Decision": "Allow", "Match": { - "Managers": ["Winget"], - "PackageIdentifiers": ["Microsoft.VisualStudioCode"], - "SkipHashCheck": [true] + "Managers": [ + "Winget" + ], + "PackageIdentifiers": { + "Exact": [ + "Microsoft.VisualStudioCode" + ] + }, + "SkipHashCheck": true } } ] diff --git a/policies/test-data/package-broker/responses/policy.response.json b/policies/test-data/package-broker/responses/policy.response.json index a21f34f..59c53cc 100644 --- a/policies/test-data/package-broker/responses/policy.response.json +++ b/policies/test-data/package-broker/responses/policy.response.json @@ -7,7 +7,6 @@ }, "Policy": { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.desktop.standard-allowlist", "Publisher": "Contoso IT", @@ -16,8 +15,7 @@ "Description": "Fail-closed policy for standard workstation package installs." }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "DefaultDecision": "Deny" }, "Rules": [ { @@ -31,9 +29,7 @@ "Install", "Update" ], - "SkipHashCheck": [ - true - ] + "SkipHashCheck": true } }, { @@ -43,9 +39,7 @@ "Decision": "Deny", "Reason": "Custom package-manager parameters are not allowed in the workstation allow list.", "Match": { - "HasCustomParameters": [ - true - ] + "HasCustomParameters": true } }, { @@ -55,9 +49,7 @@ "Decision": "Deny", "Reason": "Pre and post operation commands are not allowed in the workstation allow list.", "Match": { - "HasPrePostCommands": [ - true - ] + "HasPrePostCommands": true } }, { @@ -74,12 +66,14 @@ "Managers": [ "Winget" ], - "Sources": [ + "SourceNames": [ "winget" ], - "PackageIdentifiers": [ - "Microsoft.VisualStudioCode" - ], + "PackageIdentifiers": { + "Exact": [ + "Microsoft.VisualStudioCode" + ] + }, "Scopes": [ "User", "Machine" @@ -112,12 +106,14 @@ "Managers": [ "Winget" ], - "Sources": [ + "SourceNames": [ "winget" ], - "PackageIdentifiers": [ - "Microsoft.PowerToys" - ], + "PackageIdentifiers": { + "Exact": [ + "Microsoft.PowerToys" + ] + }, "Scopes": [ "User", "Machine"