From e3c4bce4ddb18262482b1882e4c03d66321ba8fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 16 Sep 2026 03:46:13 +0900 Subject: [PATCH 01/14] fix(policy): reject duplicate JSON members Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../BrokerSerializer.cs | 71 +++++- .../Devolutions.Now.Policy.Api/README.md | 1 + .../BrokerClientTests.cs | 36 +++ .../PolicyManagementClientTests.cs | 210 ++++++++++++++++++ .../PolicyTests.cs | 142 ++++++++++++ .../PolicyJsonInput.cs | 154 +++++++++++++ .../PolicySerializer.cs | 104 ++++++++- .../Devolutions.Now.Policy.Model/README.md | 3 +- .../duplicate-constraints-field.policy.json | 28 +++ .../duplicate-match-field.policy.json | 25 +++ .../duplicate-metadata-id.policy.json | 16 ++ .../duplicate-metadata-publisher.policy.json | 16 ++ ...icy-format-version-conflicting.policy.json | 16 ++ ...-policy-format-version-escaped.policy.json | 16 ++ ...ate-policy-format-version-same.policy.json | 16 ++ .../duplicates/duplicate-rule-id.policy.json | 25 +++ .../rust/now-policy/tests/policy_samples.rs | 27 +++ 17 files changed, 893 insertions(+), 13 deletions(-) create mode 100644 policies/dotnet/Devolutions.Now.Policy.Model/PolicyJsonInput.cs create mode 100644 policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-constraints-field.policy.json create mode 100644 policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-match-field.policy.json create mode 100644 policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-metadata-id.policy.json create mode 100644 policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-metadata-publisher.policy.json create mode 100644 policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-policy-format-version-conflicting.policy.json create mode 100644 policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-policy-format-version-escaped.policy.json create mode 100644 policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-policy-format-version-same.policy.json create mode 100644 policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-rule-id.policy.json diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs index f4e9723..e4da542 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; @@ -264,11 +268,76 @@ 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) + { + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.PackageRequest)); + 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( + 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.StatusResponse)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerSerializerContext.Default.CancelResponse)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerErrorSerializerContext.Default.ErrorResponse)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyDocument)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyDraftDocument)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyMetadata)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyDraftMetadata)); + 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.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)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.InvalidPolicyDiagnostics)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + BrokerPolicySerializerContext.Default.PolicyManagementSnapshot)); } private static void AttachSemanticValidation(JsonTypeInfo typeInfo) diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/README.md b/policies/dotnet/Devolutions.Now.Policy.Api/README.md index 9dc5edd..9c7d8a6 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. Escaped names are decoded and compared with ordinal, case-sensitive equality. Serialization output is unchanged. - `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..d918975 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() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs index ec91b92..b8cd419 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs @@ -1,13 +1,16 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Devolutions.Now.Policy.Api; using Devolutions.Now.Policy.Client; using Xunit; using PolicyDocument = Devolutions.Now.Policy.Model.PolicyDocument; using PolicyDraftDocument = Devolutions.Now.Policy.Model.PolicyDraftDocument; +using PolicyMetadata = Devolutions.Now.Policy.Model.PolicyMetadata; namespace Devolutions.Now.Policy.Client.Tests; @@ -223,6 +226,207 @@ 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 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() { @@ -615,6 +819,12 @@ 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 JsonNode ReplaceProperty(JsonNode source, string propertyName, JsonNode value) { var copy = source.DeepClone(); diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index ce0fa99..c63b4f1 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 }, @@ -106,6 +113,141 @@ public void Policy_and_draft_parsers_reject_schema_member() () => PolicySerializer.DeserializePolicyDraftDocumentStrict(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.ThrowsAny(() => PolicySerializer.DeserializePolicyDocument(json)); + Assert.Throws(() => PolicySerializer.DeserializePolicyDocumentStrict(json)); + Assert.Throws(() => PolicySerializer.DeserializeStrict(json)); + Assert.Throws( + () => JsonSerializer.Deserialize(json, PolicySerializer.Options)); + Assert.Throws( + () => JsonSerializer.Deserialize(json, PolicySerializer.StrictOptions)); + } + + [Fact] + public void All_draft_deserialization_entry_points_reject_nested_escaped_duplicate_properties() + { + const string Json = """ + { + "PolicyFormatVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "duplicate.test", + "\u0049d": "duplicate.test", + "Publisher": "Test" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [] + } + """; + + Assert.Throws(() => PolicyDraftDocument.ParseJson(Json)); + Assert.Throws(() => PolicySerializer.DeserializePolicyDraftDocumentStrict(Json)); + Assert.Throws(() => PolicySerializer.DeserializeStrict(Json)); + Assert.Throws( + () => JsonSerializer.Deserialize(Json, PolicySerializer.Options)); + Assert.Throws( + () => JsonSerializer.Deserialize(Json, PolicySerializer.StrictOptions)); + } + + [Fact] + public void Duplicate_property_comparison_is_ordinal_and_case_sensitive() + { + var json = MinimalPolicyJson( + """ + "Revision": 1, + """, + """ + "policyFormatVersion": "1.1.0", + "Rules": [] + """); + + Assert.NotNull(PolicySerializer.DeserializePolicyDocument(json)); + Assert.Throws(() => PolicySerializer.DeserializePolicyDocumentStrict(json)); + } + + [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.DeserializePolicyDocument(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.DeserializePolicyDocument(json)); + } + + [Fact] + public void Invalid_surrogate_property_names_remain_json_errors() + { + var json = MinimalPolicyJson( + """ + "Revision": 1, + "\uD800": true, + """, + """ + "Rules": [] + """); + + Assert.ThrowsAny(() => PolicySerializer.DeserializePolicyDocument(json)); + Assert.ThrowsAny(() => PolicySerializer.DeserializePolicyDocumentStrict(json)); + Assert.ThrowsAny( + () => JsonSerializer.Deserialize(json, PolicySerializer.Options)); + Assert.ThrowsAny( + () => JsonSerializer.Deserialize(json, PolicySerializer.StrictOptions)); + } + + [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] public void Invalid_policy_fixture_is_rejected_by_parser() { 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..184e7d7 --- /dev/null +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJsonInput.cs @@ -0,0 +1,154 @@ +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) + : JsonConverter, IDuplicatePropertyNameRejectingConverter +{ + private readonly ConditionalWeakTable> _effectiveTypeInfos = new(); + + public override T? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + PolicyJsonInput.RejectDuplicatePropertyNames(ref reader); + return JsonSerializer.Deserialize(ref reader, EffectiveTypeInfo(options)); + } + + public override void Write( + Utf8JsonWriter writer, + T value, + JsonSerializerOptions options) + { + 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/PolicySerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs index 7ad7f7c..90a5488 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs @@ -7,9 +7,17 @@ namespace Devolutions.Now.Policy.Model; public static class PolicySerializer { - public static readonly JsonSerializerOptions Options = CreateOptions(PolicySerializerContext.Default); - - public static readonly JsonSerializerOptions StrictOptions = CreateOptions(PolicyStrictSerializerContext.Default); + /// + /// Source-generated policy JSON options. Deserialization rejects duplicate property names + /// throughout the input using ordinal, case-sensitive name comparison. + /// + public static readonly JsonSerializerOptions Options = CreateOptions(strict: false); + + /// + /// Source-generated strict policy JSON options. Deserialization rejects unknown and duplicate + /// property names throughout the input using ordinal, case-sensitive name comparison. + /// + public static readonly JsonSerializerOptions StrictOptions = CreateOptions(strict: true); public static string Serialize(PolicyDocument value) { @@ -23,14 +31,23 @@ public static string Serialize(PolicyDraftDocument value) return JsonSerializer.Serialize(value, PolicySerializerContext.Default.PolicyDraftDocument); } - public static PolicyDocument? DeserializePolicyDocument(string json) => - Validate(JsonSerializer.Deserialize(json, PolicySerializerContext.Default.PolicyDocument)); + public static PolicyDocument? DeserializePolicyDocument(string json) + { + PolicyJsonInput.RejectDuplicatePropertyNames(json, PolicySerializerContext.Default.Options); + return Validate(JsonSerializer.Deserialize(json, PolicySerializerContext.Default.PolicyDocument)); + } - public static PolicyDocument? DeserializePolicyDocumentStrict(string json) => - Validate(JsonSerializer.Deserialize(json, PolicyStrictSerializerContext.Default.PolicyDocument)); + public static PolicyDocument? DeserializePolicyDocumentStrict(string json) + { + PolicyJsonInput.RejectDuplicatePropertyNames(json, PolicyStrictSerializerContext.Default.Options); + return Validate(JsonSerializer.Deserialize(json, PolicyStrictSerializerContext.Default.PolicyDocument)); + } - public static PolicyDraftDocument? DeserializePolicyDraftDocumentStrict(string json) => - Validate(JsonSerializer.Deserialize(json, PolicyStrictSerializerContext.Default.PolicyDraftDocument)); + public static PolicyDraftDocument? DeserializePolicyDraftDocumentStrict(string json) + { + PolicyJsonInput.RejectDuplicatePropertyNames(json, PolicyStrictSerializerContext.Default.Options); + return Validate(JsonSerializer.Deserialize(json, PolicyStrictSerializerContext.Default.PolicyDraftDocument)); + } public static string Serialize(T value) { @@ -40,6 +57,7 @@ public static string Serialize(T value) public static T? DeserializeStrict(string json) { + PolicyJsonInput.RejectDuplicatePropertyNames(json, PolicyStrictSerializerContext.Default.Options); var value = JsonSerializer.Deserialize(json, StrictTypeInfo()); ValidateSemanticValue(value); return value; @@ -234,12 +252,76 @@ private static JsonTypeInfo StrictTypeInfo() => private static JsonTypeInfo Cast(JsonTypeInfo jsonTypeInfo) => (JsonTypeInfo)jsonTypeInfo; - private static JsonSerializerOptions CreateOptions(JsonSerializerContext context) => - new(context.Options) + private static JsonSerializerOptions CreateOptions(bool strict) + { + JsonSerializerContext context = strict + ? PolicyStrictSerializerContext.Default + : PolicySerializerContext.Default; + var options = new JsonSerializerOptions(context.Options) { TypeInfoResolver = context.WithAddedModifier(AttachSemanticValidation), }; + if (strict) + { + AddDuplicateRejectingConverters(options, PolicyStrictSerializerContext.Default); + } + else + { + AddDuplicateRejectingConverters(options, PolicySerializerContext.Default); + } + + return options; + } + + private static void AddDuplicateRejectingConverters( + JsonSerializerOptions options, + PolicySerializerContext context) + { + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyDocument)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyDraftDocument)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyMetadata)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyDraftMetadata)); + 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.VersionRange)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyConstraints)); + } + + private static void AddDuplicateRejectingConverters( + JsonSerializerOptions options, + PolicyStrictSerializerContext context) + { + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyDocument)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyDraftDocument)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyMetadata)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyDraftMetadata)); + 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.VersionRange)); + options.Converters.Add(new DuplicatePropertyNameRejectingConverter( + context.PolicyConstraints)); + } + private static void AttachSemanticValidation(JsonTypeInfo typeInfo) { if (typeInfo.Kind != JsonTypeInfoKind.Object) diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/README.md b/policies/dotnet/Devolutions.Now.Policy.Model/README.md index 2e552dd..99ed1fb 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Model/README.md @@ -21,7 +21,8 @@ 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. +- `PolicySerializer.cs` defines shared source-generated `JsonSerializerOptions`, including strict parsing that rejects unknown JSON members and JSON null for non-nullable policy members or collection elements. +- All policy deserialization entry points, including the non-strict compatibility helper and public serializer options, reject duplicate property names before typed deserialization. The 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. 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..7b36bd8 --- /dev/null +++ b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-constraints-field.policy.json @@ -0,0 +1,28 @@ +{ + "PolicyFormatVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "duplicate.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "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..eaf2a83 --- /dev/null +++ b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-match-field.policy.json @@ -0,0 +1,25 @@ +{ + "PolicyFormatVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "duplicate.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "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..0616537 --- /dev/null +++ b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-metadata-id.policy.json @@ -0,0 +1,16 @@ +{ + "PolicyFormatVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "duplicate.test", + "\u0049d": "duplicate.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "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..4a18ddf --- /dev/null +++ b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-metadata-publisher.policy.json @@ -0,0 +1,16 @@ +{ + "PolicyFormatVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "duplicate.test", + "Publisher": "First", + "Publisher": "Second", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "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..f68e75e --- /dev/null +++ b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-policy-format-version-conflicting.policy.json @@ -0,0 +1,16 @@ +{ + "PolicyFormatVersion": "1.0.0", + "PolicyFormatVersion": "1.1.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "duplicate.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "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..c4e3fa3 --- /dev/null +++ b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-policy-format-version-escaped.policy.json @@ -0,0 +1,16 @@ +{ + "PolicyFormatVersion": "1.0.0", + "PolicyFormatVersi\u006fn": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "duplicate.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "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..357e82f --- /dev/null +++ b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-policy-format-version-same.policy.json @@ -0,0 +1,16 @@ +{ + "PolicyFormatVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "duplicate.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "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..1fd01f1 --- /dev/null +++ b/policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-rule-id.policy.json @@ -0,0 +1,25 @@ +{ + "PolicyFormatVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "duplicate.test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "first.rule", + "Id": "second.rule", + "Priority": 1, + "Decision": "Deny", + "Match": { + "Operations": ["Install"] + } + } + ] +} diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index 549e8a3..4a3618e 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -200,6 +200,33 @@ fn invalid_policy_fixture_fails_deserialization() { assert!(result.is_err(), "invalid policy fixture should fail deserialization"); } +#[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, 8, "all shared duplicate fixtures must be exercised"); +} + #[test] fn policy_schema_generates_valid_json() { let schema = now_policy::schema::policy_schema_json(); From bb34e4b1cc5dbdb24f44bae4c4a5156c48f4f3ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 16 Sep 2026 04:02:42 +0900 Subject: [PATCH 02/14] fix(policy): cover nested broker DTOs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../BrokerSerializer.cs | 28 +++++++++ .../PolicyManagementClientTests.cs | 57 +++++++++++++++++++ .../Devolutions.Now.Policy.Model/README.md | 2 +- 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs index e4da542..059c2f5 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs @@ -280,8 +280,18 @@ private static JsonSerializerOptions CreateOptions(bool writeIndented) 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( @@ -294,6 +304,8 @@ private static void AddDuplicateRejectingConverters(JsonSerializerOptions option 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( @@ -306,12 +318,28 @@ private static void AddDuplicateRejectingConverters(JsonSerializerOptions option 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)); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs index b8cd419..faf4c4f 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs @@ -2,6 +2,7 @@ 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; @@ -319,6 +320,39 @@ public void Public_broker_options_reject_duplicates_in_direct_policy_management_ } } + [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() { @@ -825,6 +859,29 @@ private static string WithEscapedPolicyFormatVersionDuplicate(string json) => "\"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(); diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/README.md b/policies/dotnet/Devolutions.Now.Policy.Model/README.md index 99ed1fb..9b16966 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Model/README.md @@ -24,7 +24,7 @@ Architecture - `PolicySerializer.cs` defines shared source-generated `JsonSerializerOptions`, including strict parsing that rejects unknown JSON members and JSON null for non-nullable policy members or collection elements. - All policy deserialization entry points, including the non-strict compatibility helper and public serializer options, reject duplicate property names before typed deserialization. The 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. From 5069cca72bcad2c01d27a7c4659973c6fc5eb92c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 13:20:06 +0900 Subject: [PATCH 03/14] feat(policy)!: finalize strict policy contract Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../BrokerSerializer.cs | 59 +- .../PolicyManagementModels.cs | 1 - .../Devolutions.Now.Policy.Api/README.md | 2 +- .../BrokerClientTests.cs | 15 +- .../MetaModelTests.cs | 21 +- .../PolicyManagementClientTests.cs | 61 +- .../SchemaValidationTests.cs | 43 + .../PolicyTests.cs | 884 +++++++++++++++++- .../Devolutions.Now.Policy.Model/Enums.cs | 7 - .../PolicyModels.cs | 269 ++++-- .../PolicySerializer.cs | 216 ++++- .../Devolutions.Now.Policy.Model/README.md | 23 +- .../openapi/now-policy-api.yaml | 658 +++++++------ policies/rust/now-policy-api/src/lib.rs | 45 +- .../rust/now-policy-api/src/management.rs | 4 +- .../now-policy-server-template/src/server.rs | 94 +- policies/rust/now-policy/README.md | 14 +- .../boolean-characteristics.policy.json | 52 ++ .../samples/corporate-allowlist.policy.json | 38 +- .../samples/deny-risky-options.policy.json | 22 +- .../duplicate-boolean-match-field.policy.json | 23 + .../duplicate-constraints-field.policy.json | 4 +- .../duplicate-match-field.policy.json | 4 +- .../duplicate-metadata-id.policy.json | 4 +- .../duplicate-metadata-publisher.policy.json | 4 +- ...icy-format-version-conflicting.policy.json | 4 +- ...-policy-format-version-escaped.policy.json | 4 +- ...ate-policy-format-version-same.policy.json | 4 +- .../duplicates/duplicate-rule-id.policy.json | 4 +- .../invalid-failure-decision.policy.json | 14 +- .../samples/powershell-advanced.policy.json | 32 +- .../powershell-current-user.policy.json | 22 +- .../samples/scenario-coverage.policy.json | 100 +- .../devolutions.now-policy-draft.schema.json | 633 ++++++++----- .../schema/devolutions.now-policy.schema.json | 633 ++++++++----- policies/rust/now-policy/src/lib.rs | 2 - policies/rust/now-policy/src/markers.rs | 53 -- policies/rust/now-policy/src/newtypes.rs | 128 +++ policies/rust/now-policy/src/policy.rs | 740 ++++++++++++--- .../rust/now-policy/tests/policy_samples.rs | 534 ++++++++++- ...-validation.valid-with-error.response.json | 4 +- .../policy-replacement.create.request.json | 3 +- .../policy-replacement.overwrite.request.json | 3 +- .../policy-replacement.repair.request.json | 3 +- ...-replacement.replace-identity.request.json | 3 +- .../policy-replacement.update.request.json | 3 +- .../requests/policy-validation.request.json | 14 +- .../policy-management.active.response.json | 4 +- .../policy-replacement.response.json | 12 +- .../responses/policy-stale-token.error.json | 4 +- .../policy-validation.invalid.response.json | 2 +- .../policy-validation.valid.response.json | 14 +- .../responses/policy.response.json | 36 +- 53 files changed, 4244 insertions(+), 1335 deletions(-) create mode 100644 policies/rust/now-policy/assets/samples/boolean-characteristics.policy.json create mode 100644 policies/rust/now-policy/assets/samples/invalid/duplicates/duplicate-boolean-match-field.policy.json delete mode 100644 policies/rust/now-policy/src/markers.rs diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs index 059c2f5..4c1b082 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs @@ -53,6 +53,14 @@ private static void ValidateSemanticValue(object? value) case PolicyDraftDocument draft: PolicySerializer.ValidateRequiredCollectionElements(draft); break; + case PolicyMetadata + or PolicyRule + or PolicyMatch + or PackageIdentifierCondition + or VersionCondition + or PolicyConstraints: + PolicySerializer.ValidateSemanticValue(value); + break; case PolicyResponse response: PolicySerializer.ValidateRequiredCollectionElements(response.Policy); break; @@ -77,24 +85,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) : @@ -354,6 +368,10 @@ private static void AddDuplicateRejectingConverters(JsonSerializerOptions option 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( @@ -375,6 +393,11 @@ private static void AttachSemanticValidation(JsonTypeInfo typeInfo) return; } + if (typeInfo.Type.Assembly == typeof(PolicyDocument).Assembly) + { + typeInfo.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow; + } + PolicySerializer.ConfigureCanonicalSerialization(typeInfo); 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..28c2f8d 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs @@ -135,7 +135,6 @@ public enum PolicyFindingCode 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 9c7d8a6..4ba702d 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Api/README.md @@ -33,7 +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. Escaped names are decoded and compared with ordinal, case-sensitive equality. Serialization output is unchanged. +- 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 d918975..95952ee 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs @@ -383,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) @@ -398,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"); @@ -409,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 faf4c4f..038c6a0 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs @@ -9,9 +9,12 @@ 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 PolicyMetadata = Devolutions.Now.Policy.Model.PolicyMetadata; +using VersionCondition = Devolutions.Now.Policy.Model.VersionCondition; namespace Devolutions.Now.Policy.Client.Tests; @@ -320,6 +323,42 @@ public void Public_broker_options_reject_duplicates_in_direct_policy_management_ } } + [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)); + } + } + [Fact] public void Public_broker_options_reject_duplicates_in_every_direct_broker_object_type() { @@ -782,13 +821,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)); } } @@ -916,6 +963,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 c63b4f1..ec1a91b 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -37,6 +37,28 @@ public static IEnumerable DuplicatePropertySamples() => { 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() { @@ -70,7 +92,10 @@ public async Task Created_policy_validates_against_rust_schema() { Operations = [Operation.Install], Managers = [ManagerName.Winget], - PackageIdentifiers = ["Microsoft.VisualStudioCode"], + PackageIdentifiers = new PackageIdentifierCondition + { + Exact = ["Microsoft.VisualStudioCode"], + }, }, }); @@ -136,15 +161,13 @@ public void All_draft_deserialization_entry_points_reject_nested_escaped_duplica const string Json = """ { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "duplicate.test", "\u0049d": "duplicate.test", "Publisher": "Test" }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "DefaultDecision": "Deny" }, "Rules": [] } @@ -171,7 +194,9 @@ public void Duplicate_property_comparison_is_ordinal_and_case_sensitive() "Rules": [] """); - Assert.NotNull(PolicySerializer.DeserializePolicyDocument(json)); + var exception = Assert.Throws( + () => PolicySerializer.DeserializePolicyDocument(json)); + Assert.DoesNotContain("Duplicate JSON property name", exception.Message, StringComparison.Ordinal); Assert.Throws(() => PolicySerializer.DeserializePolicyDocumentStrict(json)); } @@ -213,6 +238,142 @@ public void Duplicate_preprocessing_observes_the_serializer_depth_limit() Assert.ThrowsAny(() => PolicySerializer.DeserializePolicyDocument(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.DeserializeStrict(json)), + ( + JsonNode.Parse(policy.ToDraft().ToJson())!, + json => PolicySerializer.DeserializeStrict(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 Non_strict_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.DeserializePolicyDocument(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() { @@ -307,7 +468,6 @@ public void Negative_priority_is_rejected_by_parser() [Theory] [InlineData("PolicyFormatVersion")] - [InlineData("PolicyType")] [InlineData("Metadata")] [InlineData("Enforcement")] [InlineData("Rules")] @@ -316,7 +476,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")] @@ -334,7 +493,6 @@ public void Missing_rust_required_property_is_rejected_by_parser(string property [Theory] [InlineData("PolicyFormatVersion")] - [InlineData("PolicyType")] [InlineData("Metadata")] [InlineData("Enforcement")] [InlineData("Rules")] @@ -343,7 +501,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")] @@ -361,8 +518,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"); @@ -458,52 +615,685 @@ public void Draft_conversion_enforces_revision_bounds() } [Fact] - public void Mixed_boolean_match_values_are_rejected() + public void Draft_conversions_reject_invalid_union_conditions_before_cloning() { - var document = JsonNode.Parse( - File.ReadAllText(Path.Combine(SamplesDir, "corporate-allowlist.policy.json")))!; - document["Rules"]![0]!["Match"]!["Interactive"] = new JsonArray(false, true); + 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()); - Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); + 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 = null, + }; + Assert.Throws( + () => draft.ToPolicyDocument(1, DateTimeOffset.UtcNow)); } - [Fact] - public void Direct_policy_match_and_rule_deserialization_reject_mixed_boolean_values() + [Theory] + [MemberData(nameof(BooleanMatchProperties))] + public void Boolean_match_characteristics_accept_omitted_null_false_and_true(string propertyName) { - const string MatchJson = """{"Interactive":[false,true]}"""; - const string RuleJson = - """{"Id":"test.rule","Priority":1,"Decision":"Allow","Match":{"Interactive":[false,true]}}"""; + var omitted = PolicySerializer.DeserializeStrict("{}")!; + Assert.Null(GetBooleanMatch(omitted, propertyName)); + Assert.DoesNotContain($"\"{propertyName}\"", PolicySerializer.Serialize(omitted)); - Assert.Throws(() => PolicySerializer.DeserializeStrict(MatchJson)); - Assert.Throws(() => PolicySerializer.DeserializeStrict(RuleJson)); - Assert.NotNull(PolicySerializer.DeserializeStrict("""{"Interactive":[]}""")); + var explicitNull = PolicySerializer.DeserializeStrict( + $$"""{"{{propertyName}}":null}""")!; + Assert.Null(GetBooleanMatch(explicitNull, propertyName)); + Assert.DoesNotContain($"\"{propertyName}\"", PolicySerializer.Serialize(explicitNull)); - var match = new PolicyMatch { Interactive = [false, true] }; - Assert.Throws(() => PolicySerializer.Serialize(match)); - Assert.Throws(() => JsonSerializer.Serialize(match, PolicySerializer.Options)); + foreach (var expected in new[] { false, true }) + { + var match = PolicySerializer.DeserializeStrict( + $$"""{"{{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.DeserializeStrict(matchJson)); + Assert.Throws(() => PolicySerializer.DeserializeStrict(ruleJson)); + Assert.Throws( + () => JsonSerializer.Deserialize(matchJson, PolicySerializer.Options)); + Assert.Throws( + () => JsonSerializer.Deserialize(matchJson, PolicySerializer.StrictOptions)); + } + } + + [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( - () => JsonSerializer.Deserialize(MatchJson, PolicySerializer.Options)); + () => PolicySerializer.DeserializeStrict(rule.ToJsonString())); + + rule["Match"] = new JsonObject + { + ["Operations"] = new JsonArray("Install"), + [propertyName] = null, + }; + Assert.NotNull(PolicySerializer.DeserializeStrict(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.DeserializeStrict(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.DeserializeStrict("{}")!; + Assert.Equal(0, GetCollectionMatchCount(omitted, propertyName)); + Assert.DoesNotContain($"\"{propertyName}\"", PolicySerializer.Serialize(omitted)); + + var empty = PolicySerializer.DeserializeStrict( + $$"""{"{{propertyName}}":[]}""")!; + Assert.Equal(0, GetCollectionMatchCount(empty, propertyName)); + Assert.DoesNotContain($"\"{propertyName}\"", PolicySerializer.Serialize(empty)); + foreach (var options in new[] { PolicySerializer.Options, PolicySerializer.StrictOptions }) + { + Assert.DoesNotContain( + $"\"{propertyName}\"", + JsonSerializer.Serialize(empty, 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.DeserializeStrict(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( - () => JsonSerializer.Deserialize(MatchJson, PolicySerializer.StrictOptions)); + () => PolicySerializer.DeserializeStrict(emptyOnly.ToJsonString())); + + emptyOnly["Match"] = new JsonObject + { + [propertyName] = new JsonArray(), + ["Interactive"] = false, + }; + var rule = PolicySerializer.DeserializeStrict(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.DeserializeStrict(emptyOnly.ToJsonString())); + } + + [Fact] + public void Source_names_require_managers_and_preserve_exact_literal_names() + { + const string WithoutManager = """ + { + "Id": "source.rule", + "Priority": 1, + "Decision": "Allow", + "Match": { "SourceNames": ["corp*"] } + } + """; + var exception = Assert.Throws( + () => PolicySerializer.DeserializeStrict(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.DeserializeStrict(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()); + + 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.DeserializeStrict(MultipleManagers)); + Assert.Contains("$.Match.SourceNames", exception.Message, StringComparison.Ordinal); + } + + [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 void Package_identifier_condition_requires_exactly_one_nonempty_mode() + { + const string Exact = """{"Exact":["Microsoft.VisualStudioCode"]}"""; + var exact = PolicySerializer.DeserializeStrict(Exact)!; + Assert.Equal(["Microsoft.VisualStudioCode"], exact.Exact); + Assert.Null(exact.Patterns); + + const string Patterns = """{"Patterns":["Microsoft.*"]}"""; + var patterns = PolicySerializer.DeserializeStrict(Patterns)!; + Assert.Equal(["Microsoft.*"], patterns.Patterns); + Assert.Null(patterns.Exact); + + 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":["Microsoft.VisualStudioCode"],"\u0045xact":["Git.Git"]}""", + }) + { + Assert.Throws( + () => PolicySerializer.DeserializeStrict(invalid)); + } + + const string OldFlatList = """{"PackageIdentifiers":["Microsoft.VisualStudioCode"]}"""; + Assert.Throws(() => PolicySerializer.DeserializeStrict(OldFlatList)); + + var absent = PolicySerializer.DeserializeStrict( + """{"PackageIdentifiers":null}""")!; + Assert.DoesNotContain("\"PackageIdentifiers\"", PolicySerializer.Serialize(absent)); + + var match = PolicySerializer.DeserializeStrict( + """{"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.DeserializeStrict(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.DeserializeStrict(Range)!; + Assert.Equal("1.0.0", range.Range!.MinVersion); + Assert.Null(range.Exact); + + foreach (var invalid in new[] + { + "{}", + """{"Exact":[]}""", + """{"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"],"\u0045xact":["2.0.0"]}""", + }) + { + Assert.Throws( + () => PolicySerializer.DeserializeStrict(invalid)); + } + + foreach (var old in new[] + { + """{"Versions":["1.0.0"]}""", + """{"VersionRange":{"MinVersion":"1.0.0"}}""", + }) + { + Assert.Throws(() => PolicySerializer.DeserializeStrict(old)); + } + + var absent = PolicySerializer.DeserializeStrict("""{"Version":null}""")!; + Assert.DoesNotContain("\"Version\"", PolicySerializer.Serialize(absent)); + } + + [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 + { + ["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.DeserializeStrict(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.DeserializeStrict(AllowWithoutConstraints)); + + const string DenyWithoutConstraints = """ + { + "Id": "deny.rule", + "Priority": 1, + "Decision": "Deny", + "Match": { "Operations": ["Install"] } + } + """; + Assert.NotNull(PolicySerializer.DeserializeStrict(DenyWithoutConstraints)); + + const string DenyWithNullConstraints = """ + { + "Id": "deny.rule", + "Priority": 1, + "Decision": "Deny", + "Match": { "Operations": ["Install"] }, + "Constraints": null + } + """; + var deny = PolicySerializer.DeserializeStrict(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.DeserializeStrict(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); + + foreach (var options in new[] { PolicySerializer.Options, PolicySerializer.StrictOptions }) + { + exception = Assert.Throws( + () => JsonSerializer.Deserialize(DenyWithConstraints, options)); + Assert.Contains("$.Constraints", exception.Message, StringComparison.Ordinal); + Assert.Throws(() => JsonSerializer.Serialize(allow, 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(); @@ -603,6 +1393,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)!; @@ -614,7 +1430,6 @@ private static string MinimalPolicyJson(string revision, string rules) return $$""" { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "test.policy", "Publisher": "Test", @@ -622,8 +1437,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..b6b31ba 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs @@ -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/PolicyModels.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs index 09004fb..ba458f3 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,7 +129,6 @@ public static PolicyDocument Create(string id, string publisher, Decision defaul Enforcement = new PolicyEnforcement { DefaultDecision = defaultDecision, - RulePrecedence = RulePrecedence.PriorityThenDeny, }, }; } @@ -146,10 +141,10 @@ public static PolicyDocument ParseJson(string json) 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,7 +194,6 @@ public static PolicyDraftDocument Create( Enforcement = new PolicyEnforcement { DefaultDecision = defaultDecision, - RulePrecedence = RulePrecedence.PriorityThenDeny, }, }; } @@ -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), @@ -290,16 +280,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 +318,201 @@ 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; } = []; + public PackageIdentifierCondition? PackageIdentifiers { get; set; } - [JsonPropertyName("PackageNames")] - public List PackageNames { get; set; } = []; - - [JsonPropertyName("Versions")] - public List Versions { 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; + } + } + + [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; + } + } + + [JsonIgnore] + internal bool ExactSpecified { get; private set; } + + [JsonIgnore] + internal bool PatternsSpecified { get; private set; } } public sealed class VersionRange @@ -401,6 +527,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 +600,6 @@ internal static PolicyMetadata ToCommittedMetadata( internal static PolicyEnforcement Enforcement(PolicyEnforcement value) => new() { DefaultDecision = value.DefaultDecision, - RulePrecedence = value.RulePrecedence, AuditMode = value.AuditMode, }; @@ -494,31 +620,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 90a5488..246000e 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs @@ -22,13 +22,13 @@ public static class PolicySerializer 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) @@ -63,7 +63,7 @@ public static string Serialize(T value) return value; } - private static void ValidateSemanticValue(object? value) + internal static void ValidateSemanticValue(object? value) { switch (value) { @@ -82,6 +82,12 @@ 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 PolicyConstraints constraints: ValidateRequiredCollectionElements(constraints, "$"); break; @@ -112,7 +118,15 @@ private static void ValidateRequiredCollectionElements(IReadOnlyList 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"); @@ -145,31 +159,109 @@ 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"); + RejectBoundedStrings(match.SourceNames, 1, 128, $"{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 PolicyDocument? Validate(PolicyDocument? policy) + private static void ValidatePackageIdentifierCondition( + PackageIdentifierCondition identifiers, + string path) { - if (policy is not null) + if (identifiers.ExactSpecified == identifiers.PatternsSpecified) { - ValidateRequiredCollectionElements(policy); + 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"); + } + 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"); } + } - return policy; + 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 PolicyDraftDocument? Validate(PolicyDraftDocument? policy) + 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"); + } + } + + 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) { @@ -179,12 +271,14 @@ private static void ValidateRequiredCollectionElements(PolicyMatch match, string return policy; } - private static void RejectBooleanMatch(IReadOnlyList values, string path) + private static PolicyDraftDocument? Validate(PolicyDraftDocument? policy) { - if (values.Count > 1) + if (policy is not null) { - throw new JsonException($"The JSON array at {path} must contain at most one value."); + ValidateRequiredCollectionElements(policy); } + + return policy; } private static void ValidatePolicyRevision(uint revision) @@ -225,17 +319,25 @@ 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 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 StrictTypeInfo() => typeof(T) == typeof(PolicyDocument) ? Cast(PolicyStrictSerializerContext.Default.PolicyDocument) : @@ -245,6 +347,8 @@ private static JsonTypeInfo StrictTypeInfo() => 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(PackageIdentifierCondition) ? Cast(PolicyStrictSerializerContext.Default.PackageIdentifierCondition) : + typeof(T) == typeof(VersionCondition) ? Cast(PolicyStrictSerializerContext.Default.VersionCondition) : 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."); @@ -292,6 +396,10 @@ private static void AddDuplicateRejectingConverters( 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( @@ -316,6 +424,10 @@ private static void AddDuplicateRejectingConverters( 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( @@ -329,15 +441,43 @@ private static void AttachSemanticValidation(JsonTypeInfo typeInfo) return; } + ConfigureCanonicalSerialization(typeInfo); typeInfo.OnSerializing = ValidateSemanticValue; typeInfo.OnDeserialized = ValidateSemanticValue; } + + 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); } [JsonSourceGenerationOptions( WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - RespectNullableAnnotations = true)] + RespectNullableAnnotations = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)] [JsonSerializable(typeof(PolicyDocument))] [JsonSerializable(typeof(PolicyDraftDocument))] [JsonSerializable(typeof(PolicyMetadata))] @@ -345,6 +485,8 @@ private static void AttachSemanticValidation(JsonTypeInfo typeInfo) [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 PolicySerializerContext : JsonSerializerContext; @@ -361,6 +503,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 diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/README.md b/policies/dotnet/Devolutions.Now.Policy.Model/README.md index 9b16966..1a3ec16 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Model/README.md @@ -20,18 +20,35 @@ 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. +- `Enums.cs` defines policy-level enums such as operation, manager, scope, architecture, elevation, and decision. - `PolicySerializer.cs` defines shared source-generated `JsonSerializerOptions`, including strict parsing that rejects unknown JSON members and JSON null for non-nullable policy members or collection elements. -- All policy deserialization entry points, including the non-strict compatibility helper and public serializer options, reject duplicate property names before typed deserialization. The check covers every nested object, decodes escaped names before comparing them, and uses ordinal, case-sensitive equality to match canonical property-name handling. +- All policy deserialization entry points, including the compatibility helper and public serializer options, reject unknown and duplicate property names before information can be discarded. 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 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. +`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. + 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`; and implements the explicit package/version/source modes. +- 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; 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..5f425e2 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 @@ -1247,7 +1215,6 @@ components: - InvalidWildcardPattern - ContradictoryConstraints - InvalidValidityInterval - - UnsupportedPolicyType - UnsupportedPolicyFormatVersion - AuditModeEnabled - DefaultAllow @@ -1299,7 +1266,6 @@ components: InvalidDiagnostics: enum: - null - nullable: true Policy: $ref: '#/components/schemas/PolicyDocument' State: @@ -1311,11 +1277,9 @@ components: InvalidDiagnostics: enum: - null - nullable: true Policy: enum: - null - nullable: true State: enum: - Missing @@ -1337,7 +1301,6 @@ components: Policy: enum: - null - nullable: true State: enum: - Invalid @@ -1348,7 +1311,6 @@ components: ReadOnlyReason: enum: - null - nullable: true WriteCapability: enum: - Writable @@ -1375,21 +1337,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 +1586,6 @@ components: CanonicalDraft: enum: - null - nullable: true Findings: minItems: 1 not: @@ -1644,7 +1602,6 @@ components: ValidationReceipt: enum: - null - nullable: true PolicyValidationResultFields: type: object properties: @@ -1653,7 +1610,6 @@ components: - $ref: '#/components/schemas/PolicyDraftDocument' - enum: - null - nullable: true default: null Findings: type: array @@ -1668,7 +1624,6 @@ components: - $ref: '#/components/schemas/PolicyValidationReceipt' - enum: - null - nullable: true default: null ValidatorVersion: type: string @@ -1698,9 +1653,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 +1678,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 +1697,6 @@ components: - $ref: '#/components/schemas/Scope' - enum: - null - nullable: true SkipHashCheck: description: Skip package hash verification. type: boolean @@ -1762,13 +1719,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 +1741,6 @@ components: - $ref: '#/components/schemas/VersionString' - enum: - null - nullable: true additionalProperties: false required: - Id @@ -1799,9 +1755,10 @@ components: minLength: 1 Url: description: Optional source URL. - type: string + type: + - string + - 'null' maxLength: 2048 - nullable: true additionalProperties: false required: - Name @@ -1815,27 +1772,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 +1883,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 +1925,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: @@ -2057,12 +2016,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 +2130,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 +2139,6 @@ components: additionalProperties: false required: - PolicyFormatVersion - - PolicyType - Metadata - Enforcement - Rules @@ -2175,10 +2162,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,7 +2171,6 @@ components: additionalProperties: false required: - PolicyFormatVersion - - PolicyType - Metadata - Enforcement - Rules @@ -2198,9 +2180,10 @@ components: properties: Description: description: Human-readable description. - type: string + type: + - string + - 'null' maxLength: 512 - nullable: true Id: description: Unique policy identifier. allOf: @@ -2216,41 +2199,42 @@ components: - $ref: '#/components/schemas/PolicyModelHttpUrl' - enum: - null - nullable: true ValidFrom: description: Policy becomes active at this time. - type: string + type: + - string + - 'null' format: date-time - nullable: true ValidUntil: description: Policy expires at this time. - type: string + 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 +2247,16 @@ 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. 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 +2282,18 @@ components: - $ref: '#/components/schemas/PolicyModelHttpUrl' - enum: - null - nullable: true ValidFrom: description: Policy becomes active at this time. - type: string + type: + - string + - 'null' format: date-time - nullable: true ValidUntil: description: Policy expires at this time. - type: string + type: + - string + - 'null' format: date-time - nullable: true additionalProperties: false required: - Id @@ -2453,13 +2306,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 +2327,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 +2564,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,22 +2589,48 @@ 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 + 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. type: object @@ -2527,16 +2641,18 @@ components: default: false MaxVersion: description: Maximum version (inclusive). - type: string + type: + - string + - 'null' maxLength: 128 minLength: 1 - nullable: true MinVersion: description: Minimum version (inclusive). - type: string + type: + - string + - 'null' maxLength: 128 minLength: 1 - nullable: true additionalProperties: false PolicyModelVersionString: description: A short constrained string for version values. 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..bf57878 100644 --- a/policies/rust/now-policy-api/src/management.rs +++ b/policies/rust/now-policy-api/src/management.rs @@ -102,7 +102,6 @@ pub enum PolicyFindingCode { InvalidWildcardPattern, ContradictoryConstraints, InvalidValidityInterval, - UnsupportedPolicyType, UnsupportedPolicyFormatVersion, AuditModeEnabled, DefaultAllow, @@ -975,9 +974,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..efe6c67 100644 --- a/policies/rust/now-policy/README.md +++ b/policies/rust/now-policy/README.md @@ -8,4 +8,16 @@ 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. +`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, and adopt the explicit package/version/source modes. 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 index 7b36bd8..2ec9f10 100644 --- 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 @@ -1,6 +1,5 @@ { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "duplicate.test", "Publisher": "Test", @@ -8,8 +7,7 @@ "PublishedAt": "2026-01-01T00:00:00Z" }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "DefaultDecision": "Deny" }, "Rules": [ { 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 index eaf2a83..26aa6a4 100644 --- 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 @@ -1,6 +1,5 @@ { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "duplicate.test", "Publisher": "Test", @@ -8,8 +7,7 @@ "PublishedAt": "2026-01-01T00:00:00Z" }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "DefaultDecision": "Deny" }, "Rules": [ { 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 index 0616537..8c4e3d3 100644 --- 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 @@ -1,6 +1,5 @@ { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "duplicate.test", "\u0049d": "duplicate.test", @@ -9,8 +8,7 @@ "PublishedAt": "2026-01-01T00:00:00Z" }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "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 index 4a18ddf..fe6c531 100644 --- 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 @@ -1,6 +1,5 @@ { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "duplicate.test", "Publisher": "First", @@ -9,8 +8,7 @@ "PublishedAt": "2026-01-01T00:00:00Z" }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "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 index f68e75e..06ce143 100644 --- 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 @@ -1,7 +1,6 @@ { "PolicyFormatVersion": "1.0.0", "PolicyFormatVersion": "1.1.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "duplicate.test", "Publisher": "Test", @@ -9,8 +8,7 @@ "PublishedAt": "2026-01-01T00:00:00Z" }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "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 index c4e3fa3..187a208 100644 --- 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 @@ -1,7 +1,6 @@ { "PolicyFormatVersion": "1.0.0", "PolicyFormatVersi\u006fn": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "duplicate.test", "Publisher": "Test", @@ -9,8 +8,7 @@ "PublishedAt": "2026-01-01T00:00:00Z" }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "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 index 357e82f..df85433 100644 --- 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 @@ -1,7 +1,6 @@ { "PolicyFormatVersion": "1.0.0", "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "duplicate.test", "Publisher": "Test", @@ -9,8 +8,7 @@ "PublishedAt": "2026-01-01T00:00:00Z" }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "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 index 1fd01f1..bf4c20c 100644 --- 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 @@ -1,6 +1,5 @@ { "PolicyFormatVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "duplicate.test", "Publisher": "Test", @@ -8,8 +7,7 @@ "PublishedAt": "2026-01-01T00:00:00Z" }, "Enforcement": { - "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny" + "DefaultDecision": "Deny" }, "Rules": [ { 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..1689cb6 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 @@ -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.", @@ -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" + ] + }, + { + "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" + ] + }, { - "$ref": "#/definitions/PolicyMatch" + "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,12 +699,53 @@ ], "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.", @@ -583,14 +809,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 +820,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..bdb8611 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -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,177 +226,6 @@ "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.", @@ -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" + ] + }, + { + "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" + ] + }, { - "$ref": "#/definitions/PolicyMatch" + "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,12 +713,53 @@ ], "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.", @@ -597,14 +823,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 +834,6 @@ }, "required": [ "PolicyFormatVersion", - "PolicyType", "Metadata", "Enforcement", "Rules" 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..bb824af 100644 --- a/policies/rust/now-policy/src/newtypes.rs +++ b/policies/rust/now-policy/src/newtypes.rs @@ -377,6 +377,134 @@ 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)] pub struct VersionString(#[schemars(length(min = 1, max = 128))] pub String); diff --git a/policies/rust/now-policy/src/policy.rs b/policies/rust/now-policy/src/policy.rs index 43c4968..865330b 100644 --- a/policies/rust/now-policy/src/policy.rs +++ b/policies/rust/now-policy/src/policy.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::{ Architecture, CustomParameterString, Decision, Elevation, HttpUrl, ManagerName, ModelValidationError, Operation, - PackageBrokerPolicy, PolicyFormatVersion, ResourceId, Scope, StringPattern, VersionString, + PackageIdentifier, PolicyFormatVersion, ResourceId, Scope, SourceName, StringPattern, VersionString, }; const MAX_POLICY_REVISION: u32 = 2_147_483_647; @@ -24,9 +24,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 +40,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 +59,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,7 +86,6 @@ 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), enforcement: self.enforcement, rules: self.rules, @@ -225,6 +217,9 @@ impl PolicyDraftMetadata { } /// 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 +228,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 +260,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 +411,297 @@ 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, - - /// 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, + pub source_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: BTreeSet, + #[serde(default)] + managers: BTreeSet, + #[serde(default)] + source_names: BTreeSet, + #[serde(default)] + package_identifiers: Option, + #[serde(default)] + version: Option, + #[serde(default)] + scopes: BTreeSet, + #[serde(default)] + architectures: BTreeSet, + #[serde(default)] + execution_elevation: BTreeSet, + #[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, +} + +fn validate_policy_match(value: &PolicyMatch) -> Result<(), &'static str> { + 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()) +impl From for PolicyMatch { + fn from(value: PolicyMatchWire) -> Self { + Self { + operations: value.operations, + managers: value.managers, + source_names: value.source_names, + package_identifiers: value.package_identifiers, + version: value.version, + scopes: value.scopes, + architectures: value.architectures, + execution_elevation: value.execution_elevation, + 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, + } + } } -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<'de> Deserialize<'de> for PolicyMatch { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = Self::from(PolicyMatchWire::deserialize(deserializer)?); + validate_policy_match(&value).map_err(serde::de::Error::custom)?; + Ok(value) } +} - values.serialize(serializer) +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,22 +709,222 @@ 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() + } +} + +/// Mutually exclusive package-identifier matching mode. +#[derive(Debug, Clone)] +pub enum PackageIdentifierCondition { + Exact(BTreeSet), + Patterns(BTreeSet), +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +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) => 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) => 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) => 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::() + } + } + ] + }) } } @@ -519,7 +949,7 @@ pub struct VersionRange { pub include_prerelease: bool, } -/// Constraints applied after a rule matches. +/// 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 4a3618e..aac88bd 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, PackageIdentifier, PolicyDocument, 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,423 @@ 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 boolean_match_characteristics_accept_omitted_null_false_and_true() { + let property_names = [ + "Interactive", + "SkipHashCheck", + "PreRelease", + "HasCustomParameters", + "HasCustomInstallLocation", + "HasPrePostCommands", + "HasKillBeforeOperation", + "HasUninstallPrevious", + ]; - let result: Result = serde_json::from_value(value); - assert!(result.is_err()); + 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 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*") + ); - let empty: now_policy::PolicyMatch = serde_json::from_value(serde_json::json!({ "Interactive": [] })).unwrap(); - assert!(empty.interactive.is_empty()); + 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 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":["Microsoft.VisualStudioCode"],"\u0045xact":["Git.Git"]}"#, + ] { + assert!( + serde_json::from_str::(invalid).is_err(), + "should reject {invalid}" + ); + } - let mut invalid = now_policy::PolicyMatch::default(); - invalid.interactive.extend([false, true]); - assert!(serde_json::to_value(invalid).is_err()); + 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(_))); + + for invalid in [ + "{}", + r#"{"Exact":[]}"#, + 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"],"\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()); +} + +#[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 +507,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 +526,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 +534,6 @@ fn invalid_policy_unknown_field_fails_deserialization() { }, "Enforcement": { "DefaultDecision": "Deny", - "RulePrecedence": "PriorityThenDeny", "UnknownField": true }, "Rules": [] @@ -130,6 +543,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 +646,16 @@ 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] @@ -224,7 +682,7 @@ fn duplicate_property_fixtures_fail_deserialization() { ); } - assert_eq!(fixture_count, 8, "all shared duplicate fixtures must be exercised"); + assert_eq!(fixture_count, 9, "all shared duplicate fixtures must be exercised"); } #[test] @@ -286,14 +744,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..af75171 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 @@ -22,7 +22,7 @@ { "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": "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" From fd50c67b0f5fa8591d5b76759a6d283a25b5082a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 13:30:46 +0900 Subject: [PATCH 04/14] fix(policy): migrate validation findings Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Api/PolicyManagementModels.cs | 1 - policies/rust/now-policy-api/openapi/now-policy-api.yaml | 1 - policies/rust/now-policy-api/src/management.rs | 1 - .../responses/policy-validation.invalid.response.json | 8 ++++---- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs index 28c2f8d..61626b1 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs @@ -129,7 +129,6 @@ public enum PolicyFindingCode InvalidFieldType, InvalidFieldValue, DuplicateRuleId, - IneffectiveBooleanMatch, InvalidVersionRange, EmptyVersionRange, InvalidWildcardPattern, 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 5f425e2..e9babed 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -1209,7 +1209,6 @@ components: - InvalidFieldType - InvalidFieldValue - DuplicateRuleId - - IneffectiveBooleanMatch - InvalidVersionRange - EmptyVersionRange - InvalidWildcardPattern diff --git a/policies/rust/now-policy-api/src/management.rs b/policies/rust/now-policy-api/src/management.rs index bf57878..6311d3c 100644 --- a/policies/rust/now-policy-api/src/management.rs +++ b/policies/rust/now-policy-api/src/management.rs @@ -96,7 +96,6 @@ pub enum PolicyFindingCode { InvalidFieldType, InvalidFieldValue, DuplicateRuleId, - IneffectiveBooleanMatch, InvalidVersionRange, EmptyVersionRange, InvalidWildcardPattern, 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 af75171..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,10 +16,10 @@ { "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": "UnknownField", "Path": "/PolicyType", "Message": "PolicyType is not part of the policy document contract." }, From 35a5082a633f72e584d0d1847f15abbc026db135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 13:46:45 +0900 Subject: [PATCH 05/14] fix(policy): reject duplicate filter values Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../PolicyTests.cs | 24 +++++ .../PolicySerializer.cs | 45 +++++++--- policies/rust/now-policy/src/policy.rs | 88 ++++++++++++++----- .../rust/now-policy/tests/policy_samples.rs | 30 +++++++ 4 files changed, 153 insertions(+), 34 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index ec1a91b..8a96bbe 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -831,6 +831,27 @@ public void Empty_collection_only_match_is_not_an_effective_rule_criterion( Assert.NotNull(PolicySerializer.DeserializeStrict(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.DeserializeStrict(match.ToJsonString())); + } + [Fact] public void Source_names_require_managers_and_preserve_exact_literal_names() { @@ -927,6 +948,8 @@ public void Package_identifier_condition_requires_exactly_one_nonempty_mode() """{"Patterns":["Microsoft.*"],"Exact":null}""", """{"Exact":null,"Patterns":["Microsoft.*"]}""", """{"Exact":["Microsoft.*"]}""", + """{"Exact":["Git.Git","Git.Git"]}""", + """{"Patterns":["Git.*","Git.*"]}""", """{"Exact":["Microsoft.VisualStudioCode"],"\u0045xact":["Git.Git"]}""", }) { @@ -969,6 +992,7 @@ public void Version_condition_requires_exactly_one_nonempty_mode() """{"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"]}""", }) { diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs index 246000e..937dfac 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs @@ -159,6 +159,12 @@ private static void ValidateRequiredCollectionElements(PolicyConstraints constra private static void ValidateRequiredCollectionElements(PolicyMatch match, string path) { + 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"); RejectBoundedStrings(match.SourceNames, 1, 128, $"{path}.SourceNames"); if (match.SourceNames.Count > 0 && match.Managers.Count != 1) { @@ -195,6 +201,7 @@ private static void ValidatePackageIdentifierCondition( 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) { @@ -203,6 +210,7 @@ private static void ValidatePackageIdentifierCondition( 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"); } } @@ -240,6 +248,7 @@ private static void ValidateVersionCondition(VersionCondition version, string pa 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"); } } @@ -301,6 +310,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, @@ -340,18 +361,18 @@ private static JsonTypeInfo TypeInfo() } 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(PackageIdentifierCondition) ? Cast(PolicyStrictSerializerContext.Default.PackageIdentifierCondition) : - typeof(T) == typeof(VersionCondition) ? Cast(PolicyStrictSerializerContext.Default.VersionCondition) : - 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."); + 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(PackageIdentifierCondition) ? Cast(PolicyStrictSerializerContext.Default.PackageIdentifierCondition) : + typeof(T) == typeof(VersionCondition) ? Cast(PolicyStrictSerializerContext.Default.VersionCondition) : + 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 Cast(JsonTypeInfo jsonTypeInfo) => (JsonTypeInfo)jsonTypeInfo; diff --git a/policies/rust/now-policy/src/policy.rs b/policies/rust/now-policy/src/policy.rs index 865330b..0b03a9f 100644 --- a/policies/rust/now-policy/src/policy.rs +++ b/policies/rust/now-policy/src/policy.rs @@ -565,21 +565,21 @@ fn enforce_source_names_schema(schema: &mut Schema) { #[serde(deny_unknown_fields)] struct PolicyMatchWire { #[serde(default)] - operations: BTreeSet, + operations: Vec, #[serde(default)] - managers: BTreeSet, + managers: Vec, #[serde(default)] - source_names: BTreeSet, + source_names: Vec, #[serde(default)] package_identifiers: Option, #[serde(default)] version: Option, #[serde(default)] - scopes: BTreeSet, + scopes: Vec, #[serde(default)] - architectures: BTreeSet, + architectures: Vec, #[serde(default)] - execution_elevation: BTreeSet, + execution_elevation: Vec, #[serde(default)] interactive: Option, #[serde(default)] @@ -642,17 +642,49 @@ fn validate_policy_match(value: &PolicyMatch) -> Result<(), &'static str> { Ok(()) } -impl From for PolicyMatch { - fn from(value: PolicyMatchWire) -> Self { - Self { - operations: value.operations, - managers: value.managers, - source_names: value.source_names, +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(()) +} + +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", + )?; + reject_duplicate_values( + &value.source_names, + "PolicyMatch.SourceNames must not contain duplicate 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, - architectures: value.architectures, - execution_elevation: value.execution_elevation, + 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, @@ -661,7 +693,9 @@ impl From for PolicyMatch { 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) } } @@ -670,9 +704,7 @@ impl<'de> Deserialize<'de> for PolicyMatch { where D: serde::Deserializer<'de>, { - let value = Self::from(PolicyMatchWire::deserialize(deserializer)?); - validate_policy_match(&value).map_err(serde::de::Error::custom)?; - Ok(value) + Self::try_from(PolicyMatchWire::deserialize(deserializer)?).map_err(serde::de::Error::custom) } } @@ -749,11 +781,19 @@ impl<'de> Deserialize<'de> for PackageIdentifierCondition { 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) => Ok(Self::Exact(values.into_iter().collect())), + 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) => Ok(Self::Patterns(values.into_iter().collect())), + 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())) + } } } } @@ -857,7 +897,11 @@ impl<'de> Deserialize<'de> for VersionCondition { 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) => Ok(Self::Exact(values.into_iter().collect())), + 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)), } } diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index aac88bd..ca5edef 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -321,6 +321,33 @@ fn empty_collection_only_match_is_not_an_effective_rule_criterion() { } } +#[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#"{ @@ -392,6 +419,8 @@ fn package_identifier_condition_requires_exactly_one_nonempty_mode() { 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!( @@ -431,6 +460,7 @@ fn version_condition_requires_exactly_one_nonempty_mode() { 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!( From 1403030d03ddf969d1a79ddd4ac55ad3ed71bf34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 13:56:52 +0900 Subject: [PATCH 06/14] fix(policy): validate semantic version ranges Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../PolicyTests.cs | 16 ++++ .../PolicySerializer.cs | 37 +++++++- .../openapi/now-policy-api.yaml | 39 +++++--- .../devolutions.now-policy-draft.schema.json | 62 ++++++++++--- .../schema/devolutions.now-policy.schema.json | 62 ++++++++++--- policies/rust/now-policy/src/newtypes.rs | 2 +- policies/rust/now-policy/src/policy.rs | 91 ++++++++++++++++--- .../rust/now-policy/tests/policy_samples.rs | 22 ++++- 8 files changed, 275 insertions(+), 56 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index 8a96bbe..687289e 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -982,11 +982,17 @@ public void Version_condition_requires_exactly_one_nonempty_mode() var range = PolicySerializer.DeserializeStrict(Range)!; Assert.Equal("1.0.0", range.Range!.MinVersion); Assert.Null(range.Exact); + Assert.NotNull(PolicySerializer.DeserializeStrict( + """{"Range":{"MinVersion":"1.0.0-beta.1","IncludePrerelease":true}}""")); foreach (var invalid in new[] { "{}", """{"Exact":[]}""", + """{"Range":{}}""", + """{"Range":{"MinVersion":null,"MaxVersion":null}}""", + """{"Range":{"MinVersion":"not-semver"}}""", + """{"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"]}""", @@ -1011,6 +1017,11 @@ public void Version_condition_requires_exactly_one_nonempty_mode() var absent = PolicySerializer.DeserializeStrict("""{"Version":null}""")!; Assert.DoesNotContain("\"Version\"", PolicySerializer.Serialize(absent)); + + Assert.Throws( + () => PolicySerializer.Serialize(new VersionRange())); + Assert.Throws( + () => PolicySerializer.Serialize(new VersionRange { MinVersion = "not-semver" })); } [Fact] @@ -1059,6 +1070,11 @@ public async Task Rust_schemas_enforce_package_identifier_and_version_modes() { new JsonObject(), new JsonObject { ["Exact"] = new JsonArray() }, + new JsonObject { ["Range"] = new JsonObject() }, + new JsonObject + { + ["Range"] = new JsonObject { ["MinVersion"] = "not-semver" }, + }, new JsonObject { ["Exact"] = new JsonArray("1.0.0"), diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs index 937dfac..cfd6f13 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs @@ -2,10 +2,11 @@ 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 { /// /// Source-generated policy JSON options. Deserialization rejects duplicate property names @@ -88,6 +89,9 @@ internal static void ValidateSemanticValue(object? value) case VersionCondition version: ValidateVersionCondition(version, "$"); break; + case VersionRange range: + ValidateVersionRange(range, "$"); + break; case PolicyConstraints constraints: ValidateRequiredCollectionElements(constraints, "$"); break; @@ -250,6 +254,32 @@ private static void ValidateVersionCondition(VersionCondition version, string pa 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))) + { + throw new JsonException( + $"The JSON string at {path}.{name} must be a canonical semantic version."); + } + } } private static bool IsEmpty(PolicyMatch match) => @@ -492,6 +522,11 @@ or nameof(PolicyMatch.SourceNames) or nameof(PolicyMatch.Scopes) or nameof(PolicyMatch.Architectures) or nameof(PolicyMatch.ExecutionElevation); + + [GeneratedRegex( + @"^(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-]+)*))?\z", + RegexOptions.CultureInvariant)] + private static partial Regex SemanticVersionRegex(); } [JsonSourceGenerationOptions( 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 e9babed..52d1747 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -2594,6 +2594,14 @@ components: 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]\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-]+)*))?(?![\s\S]) PolicyModelSourceName: description: |- Exact configured package source name. @@ -2631,7 +2639,7 @@ components: required: - Range PolicyModelVersionRange: - description: Semantic version range for matching. + description: Nonempty semantic-version range for matching. type: object properties: IncludePrerelease: @@ -2640,19 +2648,28 @@ components: default: false MaxVersion: description: Maximum version (inclusive). - type: - - string - - 'null' - maxLength: 128 - minLength: 1 + anyOf: + - $ref: '#/components/schemas/PolicyModelSemanticVersion' + - enum: + - null MinVersion: description: Minimum version (inclusive). - type: - - string - - 'null' - maxLength: 128 - minLength: 1 + 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/schema/devolutions.now-policy-draft.schema.json b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json index 1689cb6..255b2a9 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 @@ -699,6 +699,12 @@ ], "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]\\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-]+)*))?(?![\\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, @@ -748,7 +754,29 @@ }, "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, @@ -756,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" 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 bdb8611..294b98b 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -713,6 +713,12 @@ ], "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]\\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-]+)*))?(?![\\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, @@ -762,7 +768,29 @@ }, "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, @@ -770,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" diff --git a/policies/rust/now-policy/src/newtypes.rs b/policies/rust/now-policy/src/newtypes.rs index bb824af..ceadcd9 100644 --- a/policies/rust/now-policy/src/newtypes.rs +++ b/policies/rust/now-policy/src/newtypes.rs @@ -42,7 +42,7 @@ 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-]+)*))?$" + 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-]+)*))?(?![\s\S])" ) )] pub String, diff --git a/policies/rust/now-policy/src/policy.rs b/policies/rust/now-policy/src/policy.rs index 0b03a9f..694e2d8 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, - PackageIdentifier, PolicyFormatVersion, ResourceId, Scope, SourceName, StringPattern, VersionString, + PackageIdentifier, PolicyFormatVersion, ResourceId, Scope, SemanticVersion, SourceName, StringPattern, + VersionString, }; const MAX_POLICY_REVISION: u32 = 2_147_483_647; @@ -972,27 +973,95 @@ impl JsonSchema for VersionCondition { } } -/// Semantic version range for matching. -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +/// Nonempty semantic-version range for matching. +#[derive(Debug, Clone, Default, JsonSchema)] #[schemars(rename = "VersionRange")] -#[serde(rename_all = "PascalCase")] -#[serde(deny_unknown_fields)] +#[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, } +#[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")] diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index ca5edef..e7605e6 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -6,8 +6,8 @@ use std::path::PathBuf; use chrono::{TimeZone, Utc}; use now_policy::{ - CURRENT_POLICY_FORMAT_VERSION, CustomParameterString, PackageIdentifier, PolicyDocument, SourceName, StringPattern, - VersionString, + CURRENT_POLICY_FORMAT_VERSION, CustomParameterString, PackageIdentifier, PolicyDocument, SemanticVersion, + SourceName, StringPattern, VersionString, }; fn samples_dir() -> PathBuf { @@ -451,10 +451,20 @@ fn version_condition_requires_exactly_one_nonempty_mode() { 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"}}"#, + "{\"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"]}"#, @@ -501,6 +511,14 @@ fn package_identifier_and_version_condition_bounds_apply_on_input_and_output() { .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()); } #[test] From 8bdc8ad35244fc886943acb99430ed169e185f6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 14:05:13 +0900 Subject: [PATCH 07/14] fix(policy): validate broker version ranges Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Api/BrokerSerializer.cs | 1 + .../PolicyManagementClientTests.cs | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs index 4c1b082..1bc4c59 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs @@ -58,6 +58,7 @@ or PolicyRule or PolicyMatch or PackageIdentifierCondition or VersionCondition + or VersionRange or PolicyConstraints: PolicySerializer.ValidateSemanticValue(value); break; diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs index 038c6a0..d002920 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs @@ -15,6 +15,7 @@ using PolicyDraftDocument = Devolutions.Now.Policy.Model.PolicyDraftDocument; 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; @@ -356,6 +357,17 @@ public void Public_broker_options_enforce_standalone_policy_condition_invariants () => 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)); } } From 0b7680f0183dd3bb1aab80dda4641347df8be77b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 14:20:35 +0900 Subject: [PATCH 08/14] fix(policy): align semantic version bounds Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../PolicyTests.cs | 8 +++++++ .../Devolutions.Now.Policy.Model/Enums.cs | 2 +- .../PolicySerializer.cs | 23 ++++++++++++++++++- .../openapi/now-policy-api.yaml | 4 ++-- .../devolutions.now-policy-draft.schema.json | 4 ++-- .../schema/devolutions.now-policy.schema.json | 4 ++-- policies/rust/now-policy/src/enums.rs | 2 +- policies/rust/now-policy/src/newtypes.rs | 20 +++++++++++++--- .../rust/now-policy/tests/policy_samples.rs | 1 + 9 files changed, 56 insertions(+), 12 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index 687289e..5972393 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -992,6 +992,7 @@ public void Version_condition_requires_exactly_one_nonempty_mode() """{"Range":{}}""", """{"Range":{"MinVersion":null,"MaxVersion":null}}""", """{"Range":{"MinVersion":"not-semver"}}""", + """{"Range":{"MinVersion":"1.18446744073709551616.0"}}""", """{"Range":{"MaxVersion":"1.0.0\n"}}""", """{"Exact":["1.0.0"],"Range":{"MinVersion":"1.0.0"}}""", """{"Exact":["1.0.0"],"Range":null}""", @@ -1076,6 +1077,13 @@ public async Task Rust_schemas_enforce_package_identifier_and_version_modes() ["Range"] = new JsonObject { ["MinVersion"] = "not-semver" }, }, new JsonObject + { + ["Range"] = new JsonObject + { + ["MinVersion"] = "1.18446744073709551616.0", + }, + }, + new JsonObject { ["Exact"] = new JsonArray("1.0.0"), ["Range"] = new JsonObject { ["MinVersion"] = "1.0.0" }, diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs b/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs index b6b31ba..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 { diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs index cfd6f13..34076e7 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -274,7 +275,9 @@ private static void ValidateVersionRange(VersionRange range, string path) }) { if (value is not null - && (value.Length > 128 || !SemanticVersionRegex().IsMatch(value))) + && (value.Length > 128 + || !SemanticVersionRegex().IsMatch(value) + || !SemanticVersionCoreFitsUInt64(value))) { throw new JsonException( $"The JSON string at {path}.{name} must be a canonical semantic version."); @@ -282,6 +285,24 @@ private static void ValidateVersionRange(VersionRange range, string path) } } + 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 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 52d1747..ca7bf93 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -1974,7 +1974,7 @@ components: - Allow - Deny PolicyModelElevation: - description: Requested elevation level. + description: Effective package-operation execution privilege. type: string enum: - Standard @@ -2601,7 +2601,7 @@ components: Validated at deserialization time using the `semver` crate. type: string maxLength: 128 - pattern: ^(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-]+)*))?(?![\s\S]) + 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]\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-]+)*))?(?![\s\S]) PolicyModelSourceName: description: |- Exact configured package source name. 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 255b2a9..f5a55ed 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" @@ -702,7 +702,7 @@ "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]\\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-]+)*))?(?![\\s\\S])", + "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]\\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-]+)*))?(?![\\s\\S])", "type": "string" }, "SourceName": { 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 294b98b..348b32a 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" @@ -716,7 +716,7 @@ "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]\\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-]+)*))?(?![\\s\\S])", + "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]\\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-]+)*))?(?![\\s\\S])", "type": "string" }, "SourceName": { 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/newtypes.rs b/policies/rust/now-policy/src/newtypes.rs index ceadcd9..1af461d 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]\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-]+)*))?(?![\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-]+)*))?(?![\s\S])" - ) + regex(pattern = SEMANTIC_VERSION_PATTERN) )] pub String, ); diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index e7605e6..5e208ba 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -464,6 +464,7 @@ fn version_condition_requires_exactly_one_nonempty_mode() { r#"{"Range":{}}"#, r#"{"Range":{"MinVersion":null,"MaxVersion":null}}"#, r#"{"Range":{"MinVersion":"not-semver"}}"#, + r#"{"Range":{"MinVersion":"1.18446744073709551616.0"}}"#, "{\"Range\":{\"MaxVersion\":\"1.0.0\\n\"}}", r#"{"Exact":["1.0.0"],"Range":{"MinVersion":"1.0.0"}}"#, r#"{"Exact":["1.0.0"],"Range":null}"#, From 6bd8c827067a0fe686282e1cfb9329ac7b90e4b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 14:32:26 +0900 Subject: [PATCH 09/14] fix(policy): require ASCII semantic versions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs | 5 +++++ .../dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs | 2 +- policies/rust/now-policy-api/openapi/now-policy-api.yaml | 2 +- .../schema/devolutions.now-policy-draft.schema.json | 2 +- .../now-policy/schema/devolutions.now-policy.schema.json | 2 +- policies/rust/now-policy/src/newtypes.rs | 2 +- policies/rust/now-policy/tests/policy_samples.rs | 1 + 7 files changed, 11 insertions(+), 5 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index 5972393..5866ea0 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -993,6 +993,7 @@ public void Version_condition_requires_exactly_one_nonempty_mode() """{"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}""", @@ -1084,6 +1085,10 @@ public async Task Rust_schemas_enforce_package_identifier_and_version_modes() }, }, 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" }, diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs index 34076e7..7cc8a33 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs @@ -545,7 +545,7 @@ or nameof(PolicyMatch.Architectures) or nameof(PolicyMatch.ExecutionElevation); [GeneratedRegex( - @"^(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-]+)*))?\z", + @"^(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(); } 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 ca7bf93..9639090 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -2601,7 +2601,7 @@ components: 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]\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-]+)*))?(?![\s\S]) + 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. 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 f5a55ed..49f0488 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 @@ -702,7 +702,7 @@ "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]\\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-]+)*))?(?![\\s\\S])", + "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": { 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 348b32a..33774fb 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -716,7 +716,7 @@ "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]\\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-]+)*))?(?![\\s\\S])", + "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": { diff --git a/policies/rust/now-policy/src/newtypes.rs b/policies/rust/now-policy/src/newtypes.rs index 1af461d..0470e9c 100644 --- a/policies/rust/now-policy/src/newtypes.rs +++ b/policies/rust/now-policy/src/newtypes.rs @@ -16,7 +16,7 @@ const SEMANTIC_VERSION_PATTERN: &str = concat!( u64_component_pattern!(), r"\.", u64_component_pattern!(), - r"(?:-((?: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-]+)*))?(?![\s\S])" + 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. diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index 5e208ba..14e4dd6 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -465,6 +465,7 @@ fn version_condition_requires_exactly_one_nonempty_mode() { 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}"#, From 5db0fe916c9f4f722b53bd5c6c7005d1fe6f8fb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 14:41:36 +0900 Subject: [PATCH 10/14] fix(policy): validate pattern and version output Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- policies/rust/now-policy/src/newtypes.rs | 18 ++++++++++++++++-- .../rust/now-policy/tests/policy_samples.rs | 8 ++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/policies/rust/now-policy/src/newtypes.rs b/policies/rust/now-policy/src/newtypes.rs index 0470e9c..163a6b6 100644 --- a/policies/rust/now-policy/src/newtypes.rs +++ b/policies/rust/now-policy/src/newtypes.rs @@ -354,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 { @@ -371,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; @@ -520,7 +527,7 @@ impl std::fmt::Display for PackageIdentifier { } /// 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 { @@ -537,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/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index 14e4dd6..46805c1 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -521,6 +521,14 @@ fn package_identifier_and_version_condition_bounds_apply_on_input_and_output() { 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] From 76bf0e8e3f7bd2a5173c98009027f53336774b40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 14:59:33 +0900 Subject: [PATCH 11/14] fix(policy): enforce source-name collection bound Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../PolicyTests.cs | 22 +++++++++++++++++++ .../PolicySerializer.cs | 9 +++++++- policies/rust/now-policy/src/policy.rs | 8 +++++++ .../rust/now-policy/tests/policy_samples.rs | 22 +++++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index 5866ea0..df8f9a6 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -905,6 +905,28 @@ public void Source_names_require_managers_and_preserve_exact_literal_names() Assert.Contains("$.Match.SourceNames", exception.Message, StringComparison.Ordinal); } + [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.DeserializeStrict(MatchJson(128))!; + Assert.Equal(128, maximum.SourceNames.Count); + Assert.NotEmpty(PolicySerializer.Serialize(maximum)); + + Assert.Throws( + () => PolicySerializer.DeserializeStrict(MatchJson(129))); + + maximum.SourceNames.Add("source-128"); + Assert.Throws(() => PolicySerializer.Serialize(maximum)); + } + [Fact] public void Rust_schemas_require_exactly_one_manager_for_source_names() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs index 7cc8a33..f8e4655 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs @@ -9,6 +9,8 @@ namespace Devolutions.Now.Policy.Model; public static partial class PolicySerializer { + private const int MaxSourceNames = 128; + /// /// Source-generated policy JSON options. Deserialization rejects duplicate property names /// throughout the input using ordinal, case-sensitive name comparison. @@ -170,7 +172,12 @@ private static void ValidateRequiredCollectionElements(PolicyMatch match, string RejectDuplicateElements(match.Scopes, $"{path}.Scopes"); RejectDuplicateElements(match.Architectures, $"{path}.Architectures"); RejectDuplicateElements(match.ExecutionElevation, $"{path}.ExecutionElevation"); - RejectBoundedStrings(match.SourceNames, 1, 128, $"{path}.SourceNames"); + 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( diff --git a/policies/rust/now-policy/src/policy.rs b/policies/rust/now-policy/src/policy.rs index 694e2d8..4a26148 100644 --- a/policies/rust/now-policy/src/policy.rs +++ b/policies/rust/now-policy/src/policy.rs @@ -636,7 +636,12 @@ struct PolicyMatchRef<'a> { has_uninstall_previous: Option, } +const MAX_SOURCE_NAMES: usize = 128; + fn validate_policy_match(value: &PolicyMatch) -> Result<(), &'static str> { + 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"); } @@ -667,6 +672,9 @@ impl TryFrom for PolicyMatch { &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, diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index 46805c1..3aa0e6d 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -399,6 +399,28 @@ fn source_names_require_managers_and_preserve_exact_literal_names() { assert!(error.contains("SourceNames"), "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 = From 26039bc97e0d22d7d70ccbfd32637abc0fbf7223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 15:15:51 +0900 Subject: [PATCH 12/14] fix(policy): enforce manager collection bound Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../PolicyTests.cs | 24 ++++++++++ .../PolicySerializer.cs | 6 +++ policies/rust/now-policy/src/policy.rs | 7 +++ .../rust/now-policy/tests/policy_samples.rs | 46 ++++++++++++++++++- 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index df8f9a6..98ffde2 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -905,6 +905,30 @@ public void Source_names_require_managers_and_preserve_exact_literal_names() 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.DeserializeStrict(MatchJson(managerNames.Take(16)))!; + Assert.Equal(16, maximum.Managers.Count); + Assert.NotEmpty(PolicySerializer.Serialize(maximum)); + + Assert.Throws( + () => PolicySerializer.DeserializeStrict(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() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs index f8e4655..d3dccd8 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs @@ -9,6 +9,7 @@ namespace Devolutions.Now.Policy.Model; public static partial class PolicySerializer { + private const int MaxManagers = 16; private const int MaxSourceNames = 128; /// @@ -172,6 +173,11 @@ private static void ValidateRequiredCollectionElements(PolicyMatch match, string 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( diff --git a/policies/rust/now-policy/src/policy.rs b/policies/rust/now-policy/src/policy.rs index 4a26148..2fffb47 100644 --- a/policies/rust/now-policy/src/policy.rs +++ b/policies/rust/now-policy/src/policy.rs @@ -636,9 +636,13 @@ struct PolicyMatchRef<'a> { 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"); } @@ -668,6 +672,9 @@ impl TryFrom for PolicyMatch { &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", diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index 3aa0e6d..4480125 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -6,8 +6,8 @@ use std::path::PathBuf; use chrono::{TimeZone, Utc}; use now_policy::{ - CURRENT_POLICY_FORMAT_VERSION, CustomParameterString, PackageIdentifier, PolicyDocument, SemanticVersion, - SourceName, StringPattern, VersionString, + CURRENT_POLICY_FORMAT_VERSION, CustomParameterString, ManagerName, PackageIdentifier, PolicyDocument, + SemanticVersion, SourceName, StringPattern, VersionString, }; fn samples_dir() -> PathBuf { @@ -399,6 +399,48 @@ fn source_names_require_managers_and_preserve_exact_literal_names() { 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) From e7c789498011a8a7c8cd6547a01204221876b369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 16:00:18 +0900 Subject: [PATCH 13/14] feat(policy): enforce validity window invariants Also add atomic .NET condition mode switching and preserve structured validation paths across broker response nesting. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../BrokerSerializer.cs | 58 +++-- .../PolicyManagementClientTests.cs | 89 +++++++ .../PolicyTests.cs | 205 ++++++++++++++++ .../PolicyJsonInput.cs | 8 +- .../PolicyModels.cs | 56 +++++ .../PolicySerializer.cs | 80 +++++-- .../Devolutions.Now.Policy.Model/README.md | 10 +- .../openapi/now-policy-api.yaml | 24 +- policies/rust/now-policy/README.md | 4 +- .../devolutions.now-policy-draft.schema.json | 6 +- .../schema/devolutions.now-policy.schema.json | 6 +- policies/rust/now-policy/src/policy.rs | 226 ++++++++++++++++-- .../rust/now-policy/tests/policy_samples.rs | 106 ++++++++ 13 files changed, 802 insertions(+), 76 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs index 1bc4c59..8181b26 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs @@ -54,6 +54,7 @@ private static void ValidateSemanticValue(object? value) PolicySerializer.ValidateRequiredCollectionElements(draft); break; case PolicyMetadata + or PolicyDraftMetadata or PolicyRule or PolicyMatch or PackageIdentifierCondition @@ -63,13 +64,13 @@ or VersionRange 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); @@ -133,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) { @@ -169,7 +172,7 @@ private static void ValidateManagement(PolicyManagementSnapshot management) if (management.Policy is { } policy) { - PolicySerializer.ValidateRequiredCollectionElements(policy); + PolicySerializer.ValidateRequiredCollectionElements(policy, $"{path}.Policy"); } } @@ -182,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); @@ -207,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 { @@ -226,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) { @@ -356,13 +363,17 @@ private static void AddDuplicateRejectingConverters(JsonSerializerOptions option options.Converters.Add(new DuplicatePropertyNameRejectingConverter( BrokerErrorSerializerContext.Default.ErrorDetail)); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( - BrokerPolicySerializerContext.Default.PolicyDocument)); + BrokerPolicySerializerContext.Default.PolicyDocument, + static value => ValidateSemanticValue(value))); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( - BrokerPolicySerializerContext.Default.PolicyDraftDocument)); + BrokerPolicySerializerContext.Default.PolicyDraftDocument, + static value => ValidateSemanticValue(value))); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( - BrokerPolicySerializerContext.Default.PolicyMetadata)); + BrokerPolicySerializerContext.Default.PolicyMetadata, + static value => ValidateSemanticValue(value))); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( - BrokerPolicySerializerContext.Default.PolicyDraftMetadata)); + BrokerPolicySerializerContext.Default.PolicyDraftMetadata, + static value => ValidateSemanticValue(value))); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( BrokerPolicySerializerContext.Default.PolicyEnforcement)); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( @@ -380,11 +391,13 @@ private static void AddDuplicateRejectingConverters(JsonSerializerOptions option options.Converters.Add(new DuplicatePropertyNameRejectingConverter( BrokerPolicySerializerContext.Default.PolicyFinding)); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( - BrokerPolicySerializerContext.Default.PolicyValidationResult)); + BrokerPolicySerializerContext.Default.PolicyValidationResult, + static value => ValidateSemanticValue(value))); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( BrokerPolicySerializerContext.Default.InvalidPolicyDiagnostics)); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( - BrokerPolicySerializerContext.Default.PolicyManagementSnapshot)); + BrokerPolicySerializerContext.Default.PolicyManagementSnapshot, + static value => ValidateSemanticValue(value))); } private static void AttachSemanticValidation(JsonTypeInfo typeInfo) @@ -399,6 +412,15 @@ private static void AttachSemanticValidation(JsonTypeInfo typeInfo) 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.Client.Tests/PolicyManagementClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs index d002920..fdf2e06 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs @@ -13,6 +13,7 @@ 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; @@ -371,6 +372,94 @@ public void Public_broker_options_enforce_standalone_policy_condition_invariants } } + [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() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index 98ffde2..480527c 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -614,6 +614,139 @@ public void Draft_conversion_enforces_revision_bounds() () => draft.ToPolicyDocument((uint)int.MaxValue + 1, publishedAt)); } + [Fact] + public void Validity_windows_accept_absent_one_sided_and_ordered_instants() + { + 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.DeserializeStrict(metadataJson)); + Assert.NotNull(PolicySerializer.DeserializeStrict(draftMetadataJson)); + Assert.NotNull(JsonSerializer.Deserialize(metadataJson, PolicySerializer.Options)); + Assert.NotNull(JsonSerializer.Deserialize( + draftMetadataJson, + PolicySerializer.StrictOptions)); + } + + var explicitNull = PolicySerializer.DeserializeStrict( + """ + { + "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.DeserializeStrict(metadataJson)); + Assert.Equal("$.ValidUntil", exception.Path); + Assert.Contains("$.ValidUntil", exception.Message, StringComparison.Ordinal); + Assert.Contains("$.ValidFrom", exception.Message, StringComparison.Ordinal); + Assert.Throws( + () => PolicySerializer.DeserializeStrict(draftMetadataJson)); + exception = Assert.Throws( + () => JsonSerializer.Deserialize(metadataJson, PolicySerializer.Options)); + Assert.Equal("$.ValidUntil", exception.Path); + Assert.Throws( + () => JsonSerializer.Deserialize( + draftMetadataJson, + PolicySerializer.StrictOptions)); + + 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.DeserializePolicyDocument(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.StrictOptions)); + } + [Fact] public void Draft_conversions_reject_invalid_union_conditions_before_cloning() { @@ -631,6 +764,13 @@ public void Draft_conversions_reject_invalid_union_conditions_before_cloning() }); 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"], @@ -653,6 +793,14 @@ public void Draft_conversions_reject_invalid_union_conditions_before_cloning() 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"], @@ -970,6 +1118,41 @@ public void Rust_schemas_require_exactly_one_manager_for_source_names() } } + [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() { @@ -983,6 +1166,17 @@ public void Package_identifier_condition_requires_exactly_one_nonempty_mode() 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[] { "{}", @@ -1031,6 +1225,17 @@ public void Version_condition_requires_exactly_one_nonempty_mode() Assert.NotNull(PolicySerializer.DeserializeStrict( """{"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[] { "{}", diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJsonInput.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJsonInput.cs index 184e7d7..4ba3be2 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJsonInput.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJsonInput.cs @@ -106,7 +106,8 @@ private static void ProcessToken( internal interface IDuplicatePropertyNameRejectingConverter; internal sealed class DuplicatePropertyNameRejectingConverter( - JsonTypeInfo fallbackTypeInfo) + JsonTypeInfo fallbackTypeInfo, + Action? validate = null) : JsonConverter, IDuplicatePropertyNameRejectingConverter { private readonly ConditionalWeakTable> _effectiveTypeInfos = new(); @@ -117,7 +118,9 @@ internal sealed class DuplicatePropertyNameRejectingConverter( JsonSerializerOptions options) { PolicyJsonInput.RejectDuplicatePropertyNames(ref reader); - return JsonSerializer.Deserialize(ref reader, EffectiveTypeInfo(options)); + var value = JsonSerializer.Deserialize(ref reader, EffectiveTypeInfo(options)); + validate?.Invoke(value); + return value; } public override void Write( @@ -125,6 +128,7 @@ public override void Write( T value, JsonSerializerOptions options) { + validate?.Invoke(value); JsonSerializer.Serialize(writer, value, EffectiveTypeInfo(options)); } diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs index ba458f3..10d5bff 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs @@ -244,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; } @@ -267,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; } @@ -471,6 +487,26 @@ public VersionRange? Range } } + /// 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; } @@ -508,6 +544,26 @@ public List? Patterns } } + /// 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; } diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs index d3dccd8..a899b7c 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs @@ -77,6 +77,10 @@ internal 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); @@ -102,24 +106,38 @@ internal static void ValidateSemanticValue(object? value) } } - 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}]"); } } @@ -362,6 +380,23 @@ private static void ValidatePolicyRevision(uint revision) } } + private static void ValidateValidityWindow( + DateTimeOffset? validFrom, + DateTimeOffset? validUntil, + string path) + { + if (validFrom is not null + && validUntil is not null + && validFrom.Value >= validUntil.Value) + { + throw new JsonException( + $"The JSON value at {path}.ValidUntil must be strictly later than {path}.ValidFrom.", + $"{path}.ValidUntil", + lineNumber: null, + bytePositionInLine: null); + } + } + private static void RejectNullElements(IReadOnlyList values, string path) where T : class { @@ -468,13 +503,17 @@ private static void AddDuplicateRejectingConverters( PolicySerializerContext context) { options.Converters.Add(new DuplicatePropertyNameRejectingConverter( - context.PolicyDocument)); + context.PolicyDocument, + static value => ValidateSemanticValue(value))); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( - context.PolicyDraftDocument)); + context.PolicyDraftDocument, + static value => ValidateSemanticValue(value))); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( - context.PolicyMetadata)); + context.PolicyMetadata, + static value => ValidateSemanticValue(value))); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( - context.PolicyDraftMetadata)); + context.PolicyDraftMetadata, + static value => ValidateSemanticValue(value))); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( context.PolicyEnforcement)); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( @@ -496,13 +535,17 @@ private static void AddDuplicateRejectingConverters( PolicyStrictSerializerContext context) { options.Converters.Add(new DuplicatePropertyNameRejectingConverter( - context.PolicyDocument)); + context.PolicyDocument, + static value => ValidateSemanticValue(value))); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( - context.PolicyDraftDocument)); + context.PolicyDraftDocument, + static value => ValidateSemanticValue(value))); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( - context.PolicyMetadata)); + context.PolicyMetadata, + static value => ValidateSemanticValue(value))); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( - context.PolicyDraftMetadata)); + context.PolicyDraftMetadata, + static value => ValidateSemanticValue(value))); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( context.PolicyEnforcement)); options.Converters.Add(new DuplicatePropertyNameRejectingConverter( @@ -527,6 +570,13 @@ private static void AttachSemanticValidation(JsonTypeInfo typeInfo) } 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; } diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/README.md b/policies/dotnet/Devolutions.Now.Policy.Model/README.md index 1a3ec16..edad1b0 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Model/README.md @@ -28,11 +28,13 @@ Architecture `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. `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. +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. Omitted or explicit `null` does not narrow matching. The former `Versions` and `VersionRange` match properties are rejected as unknown input. +`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. @@ -46,8 +48,8 @@ Policy documents are JSON-only. `PolicyDocument.ParseYaml`, which was public in 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`; and implements the explicit package/version/source modes. -- 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; and may host incomplete rules transiently but must not serialize or save them. +- 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 9639090..74d8cef 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -2174,7 +2174,9 @@ components: - 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: @@ -2199,13 +2201,17 @@ components: - enum: - null ValidFrom: - description: Policy becomes active at this time. + 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 ValidUntil: - description: Policy expires at this time. + description: |- + Instant after which the policy is inactive. When both bounds are present, this must be + strictly later than `ValidFrom`. type: - string - 'null' @@ -2247,7 +2253,9 @@ components: maxLength: 128 pattern: ^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?![\s\S]) PolicyModelPolicyMetadata: - description: Policy metadata. + description: |- + Policy metadata. When both validity bounds are present, `ValidFrom` must be strictly earlier + than `ValidUntil`. type: object properties: Description: @@ -2282,13 +2290,17 @@ components: - enum: - null ValidFrom: - description: Policy becomes active at this time. + 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 ValidUntil: - description: Policy expires at this time. + description: |- + Instant after which the policy is inactive. When both bounds are present, this must be + strictly later than `ValidFrom`. type: - string - 'null' diff --git a/policies/rust/now-policy/README.md b/policies/rust/now-policy/README.md index efe6c67..ac3be66 100644 --- a/policies/rust/now-policy/README.md +++ b/policies/rust/now-policy/README.md @@ -8,6 +8,8 @@ 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. +`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. @@ -20,4 +22,4 @@ Rule precedence is fixed by the policy format and is not represented by a JSON f `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, and adopt the explicit package/version/source modes. 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. +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/schema/devolutions.now-policy-draft.schema.json b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json index 49f0488..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 @@ -197,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.", @@ -233,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", @@ -241,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", 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 33774fb..428db05 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -228,7 +228,7 @@ }, "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.", @@ -276,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", @@ -284,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", diff --git a/policies/rust/now-policy/src/policy.rs b/policies/rust/now-policy/src/policy.rs index 2fffb47..4d57bce 100644 --- a/policies/rust/now-policy/src/policy.rs +++ b/policies/rust/now-policy/src/policy.rs @@ -87,18 +87,19 @@ impl PolicyDraftDocument { Ok(PolicyDocument { policy_format_version: self.policy_format_version, - 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, @@ -118,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. @@ -147,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, @@ -171,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. @@ -203,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, @@ -213,7 +219,179 @@ 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) } } diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index 4480125..4f7c7b9 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -80,6 +80,85 @@ fn draft_conversion_enforces_revision_bounds() { assert!(draft.into_policy_document(2_147_483_648, published_at).is_err()); } +#[test] +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} + }}"# + ) + } + + fn draft_metadata(validity: &str) -> String { + format!( + r#"{{ + "Id":"validity.test", + "Publisher":"Test" + {validity} + }}"# + ) + } + + 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::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 = [ @@ -836,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"); From 3cfab7610675fa9e54beeb896bb3a11c794facd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 18:31:51 +0900 Subject: [PATCH 14/14] refactor(policy): collapse serializer API Remove the redundant strict/non-strict model contexts and entry points now that every policy input uses the closed contract. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../PolicyTests.cs | 176 ++++++++---------- .../PolicyModels.cs | 4 +- .../PolicySerializer.cs | 134 +++---------- .../Devolutions.Now.Policy.Model/README.md | 4 +- 4 files changed, 107 insertions(+), 211 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index 480527c..3c419de 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -101,7 +101,7 @@ public async Task Created_policy_validates_against_rust_schema() 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); @@ -135,7 +135,7 @@ 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] @@ -146,13 +146,9 @@ public void All_policy_deserialization_entry_points_reject_duplicate_properties( Assert.False(JsonSerializer.IsReflectionEnabledByDefault); Assert.Throws(() => PolicyDocument.ParseJson(json)); - Assert.ThrowsAny(() => PolicySerializer.DeserializePolicyDocument(json)); - Assert.Throws(() => PolicySerializer.DeserializePolicyDocumentStrict(json)); - Assert.Throws(() => PolicySerializer.DeserializeStrict(json)); + Assert.Throws(() => PolicySerializer.Deserialize(json)); Assert.Throws( () => JsonSerializer.Deserialize(json, PolicySerializer.Options)); - Assert.Throws( - () => JsonSerializer.Deserialize(json, PolicySerializer.StrictOptions)); } [Fact] @@ -174,12 +170,9 @@ public void All_draft_deserialization_entry_points_reject_nested_escaped_duplica """; Assert.Throws(() => PolicyDraftDocument.ParseJson(Json)); - Assert.Throws(() => PolicySerializer.DeserializePolicyDraftDocumentStrict(Json)); - Assert.Throws(() => PolicySerializer.DeserializeStrict(Json)); + Assert.Throws(() => PolicySerializer.Deserialize(Json)); Assert.Throws( () => JsonSerializer.Deserialize(Json, PolicySerializer.Options)); - Assert.Throws( - () => JsonSerializer.Deserialize(Json, PolicySerializer.StrictOptions)); } [Fact] @@ -195,9 +188,8 @@ public void Duplicate_property_comparison_is_ordinal_and_case_sensitive() """); var exception = Assert.Throws( - () => PolicySerializer.DeserializePolicyDocument(json)); + () => PolicySerializer.Deserialize(json)); Assert.DoesNotContain("Duplicate JSON property name", exception.Message, StringComparison.Ordinal); - Assert.Throws(() => PolicySerializer.DeserializePolicyDocumentStrict(json)); } [Fact] @@ -216,7 +208,7 @@ public void Escaped_surrogate_pairs_match_literal_unicode_property_names() """); var exception = Assert.Throws( - () => PolicySerializer.DeserializePolicyDocument(json)); + () => PolicySerializer.Deserialize(json)); Assert.Contains("Duplicate JSON property name", exception.Message, StringComparison.Ordinal); } @@ -235,7 +227,7 @@ public void Duplicate_preprocessing_observes_the_serializer_depth_limit() "Rules": [] """); - Assert.ThrowsAny(() => PolicySerializer.DeserializePolicyDocument(json)); + Assert.ThrowsAny(() => PolicySerializer.Deserialize(json)); } [Theory] @@ -254,10 +246,10 @@ public void Removed_policy_members_are_rejected_as_unknown(string member) { ( JsonNode.Parse(policy.ToJson())!, - json => PolicySerializer.DeserializeStrict(json)), + json => PolicySerializer.Deserialize(json)), ( JsonNode.Parse(policy.ToDraft().ToJson())!, - json => PolicySerializer.DeserializeStrict(json)), + json => PolicySerializer.Deserialize(json)), }; foreach (var (document, parse) in documents) @@ -283,7 +275,7 @@ public void Removed_policy_members_are_rejected_as_unknown(string member) } [Fact] - public void Non_strict_policy_inputs_reject_removed_members_instead_of_broadening_rules() + 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")))!; @@ -296,7 +288,7 @@ public void Non_strict_policy_inputs_reject_removed_members_instead_of_broadenin var committedJson = committed.ToJsonString(); Assert.Throws( - () => PolicySerializer.DeserializePolicyDocument(committedJson)); + () => PolicySerializer.Deserialize(committedJson)); Assert.Throws( () => JsonSerializer.Deserialize( committedJson, @@ -386,12 +378,9 @@ public void Invalid_surrogate_property_names_remain_json_errors() "Rules": [] """); - Assert.ThrowsAny(() => PolicySerializer.DeserializePolicyDocument(json)); - Assert.ThrowsAny(() => PolicySerializer.DeserializePolicyDocumentStrict(json)); + Assert.ThrowsAny(() => PolicySerializer.Deserialize(json)); Assert.ThrowsAny( () => JsonSerializer.Deserialize(json, PolicySerializer.Options)); - Assert.ThrowsAny( - () => JsonSerializer.Deserialize(json, PolicySerializer.StrictOptions)); } [Fact] @@ -652,15 +641,15 @@ public void Validity_windows_accept_absent_one_sided_and_ordered_instants() } """; - Assert.NotNull(PolicySerializer.DeserializeStrict(metadataJson)); - Assert.NotNull(PolicySerializer.DeserializeStrict(draftMetadataJson)); + Assert.NotNull(PolicySerializer.Deserialize(metadataJson)); + Assert.NotNull(PolicySerializer.Deserialize(draftMetadataJson)); Assert.NotNull(JsonSerializer.Deserialize(metadataJson, PolicySerializer.Options)); Assert.NotNull(JsonSerializer.Deserialize( draftMetadataJson, - PolicySerializer.StrictOptions)); + PolicySerializer.Options)); } - var explicitNull = PolicySerializer.DeserializeStrict( + var explicitNull = PolicySerializer.Deserialize( """ { "Id": "validity.test", @@ -718,25 +707,25 @@ public void Validity_windows_reject_equal_and_inverted_instants(string validFrom """; var exception = Assert.Throws( - () => PolicySerializer.DeserializeStrict(metadataJson)); + () => 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.DeserializeStrict(draftMetadataJson)); + () => PolicySerializer.Deserialize(draftMetadataJson)); exception = Assert.Throws( () => JsonSerializer.Deserialize(metadataJson, PolicySerializer.Options)); Assert.Equal("$.ValidUntil", exception.Path); Assert.Throws( () => JsonSerializer.Deserialize( draftMetadataJson, - PolicySerializer.StrictOptions)); + 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.DeserializePolicyDocument(policyJson)); + Assert.Throws(() => PolicySerializer.Deserialize(policyJson)); exception = Assert.Throws( () => JsonSerializer.Deserialize(policyJson, PolicySerializer.Options)); Assert.Equal("$.Metadata.ValidUntil", exception.Path); @@ -744,7 +733,7 @@ public void Validity_windows_reject_equal_and_inverted_instants(string validFrom Assert.Throws( () => JsonSerializer.Deserialize( draftJson, - PolicySerializer.StrictOptions)); + PolicySerializer.Options)); } [Fact] @@ -814,18 +803,18 @@ public void Draft_conversions_reject_invalid_union_conditions_before_cloning() [MemberData(nameof(BooleanMatchProperties))] public void Boolean_match_characteristics_accept_omitted_null_false_and_true(string propertyName) { - var omitted = PolicySerializer.DeserializeStrict("{}")!; + var omitted = PolicySerializer.Deserialize("{}")!; Assert.Null(GetBooleanMatch(omitted, propertyName)); Assert.DoesNotContain($"\"{propertyName}\"", PolicySerializer.Serialize(omitted)); - var explicitNull = PolicySerializer.DeserializeStrict( + 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.DeserializeStrict( + var match = PolicySerializer.Deserialize( $$"""{"{{propertyName}}":{{expected.ToString().ToLowerInvariant()}}}""")!; Assert.Equal(expected, GetBooleanMatch(match, propertyName)); @@ -844,12 +833,12 @@ public void Boolean_match_characteristics_reject_legacy_arrays_and_wrong_types(s var ruleJson = $$"""{"Id":"test.rule","Priority":1,"Decision":"Allow","Match":{{matchJson}}}"""; - Assert.Throws(() => PolicySerializer.DeserializeStrict(matchJson)); - Assert.Throws(() => PolicySerializer.DeserializeStrict(ruleJson)); + Assert.Throws(() => PolicySerializer.Deserialize(matchJson)); + Assert.Throws(() => PolicySerializer.Deserialize(ruleJson)); Assert.Throws( () => JsonSerializer.Deserialize(matchJson, PolicySerializer.Options)); Assert.Throws( - () => JsonSerializer.Deserialize(matchJson, PolicySerializer.StrictOptions)); + () => JsonSerializer.Deserialize(matchJson, PolicySerializer.Options)); } } @@ -865,14 +854,14 @@ public void Null_only_boolean_match_is_not_an_effective_rule_criterion(string pr ["Match"] = new JsonObject { [propertyName] = null }, }; Assert.Throws( - () => PolicySerializer.DeserializeStrict(rule.ToJsonString())); + () => PolicySerializer.Deserialize(rule.ToJsonString())); rule["Match"] = new JsonObject { ["Operations"] = new JsonArray("Install"), [propertyName] = null, }; - Assert.NotNull(PolicySerializer.DeserializeStrict(rule.ToJsonString())); + Assert.NotNull(PolicySerializer.Deserialize(rule.ToJsonString())); } [Fact] @@ -888,7 +877,7 @@ public void Boolean_match_characteristics_round_trip_in_representative_mixed_mat } """; - var match = PolicySerializer.DeserializeStrict(Json)!; + var match = PolicySerializer.Deserialize(Json)!; Assert.Equal([Operation.Install], match.Operations); Assert.False(match.Interactive); Assert.True(match.SkipHashCheck); @@ -912,21 +901,18 @@ public void Collection_match_filters_accept_empty_input_and_canonicalize_to_omit string propertyName, string elementJson) { - var omitted = PolicySerializer.DeserializeStrict("{}")!; + var omitted = PolicySerializer.Deserialize("{}")!; Assert.Equal(0, GetCollectionMatchCount(omitted, propertyName)); Assert.DoesNotContain($"\"{propertyName}\"", PolicySerializer.Serialize(omitted)); - var empty = PolicySerializer.DeserializeStrict( + var empty = PolicySerializer.Deserialize( $$"""{"{{propertyName}}":[]}""")!; Assert.Equal(0, GetCollectionMatchCount(empty, propertyName)); Assert.DoesNotContain($"\"{propertyName}\"", PolicySerializer.Serialize(empty)); - foreach (var options in new[] { PolicySerializer.Options, PolicySerializer.StrictOptions }) - { - Assert.DoesNotContain( - $"\"{propertyName}\"", - JsonSerializer.Serialize(empty, options), - StringComparison.Ordinal); - } + Assert.DoesNotContain( + $"\"{propertyName}\"", + JsonSerializer.Serialize(empty, PolicySerializer.Options), + StringComparison.Ordinal); var populatedJson = new JsonObject { @@ -936,7 +922,7 @@ public void Collection_match_filters_accept_empty_input_and_canonicalize_to_omit { populatedJson[nameof(PolicyMatch.Managers)] = new JsonArray("Winget"); } - var populated = PolicySerializer.DeserializeStrict(populatedJson.ToJsonString())!; + var populated = PolicySerializer.Deserialize(populatedJson.ToJsonString())!; Assert.Equal(1, GetCollectionMatchCount(populated, propertyName)); var serialized = JsonNode.Parse(PolicySerializer.Serialize(populated))!; Assert.Single(serialized[propertyName]!.AsArray()); @@ -956,14 +942,14 @@ public void Empty_collection_only_match_is_not_an_effective_rule_criterion( ["Match"] = new JsonObject { [propertyName] = new JsonArray() }, }; Assert.Throws( - () => PolicySerializer.DeserializeStrict(emptyOnly.ToJsonString())); + () => PolicySerializer.Deserialize(emptyOnly.ToJsonString())); emptyOnly["Match"] = new JsonObject { [propertyName] = new JsonArray(), ["Interactive"] = false, }; - var rule = PolicySerializer.DeserializeStrict(emptyOnly.ToJsonString())!; + var rule = PolicySerializer.Deserialize(emptyOnly.ToJsonString())!; var serialized = JsonNode.Parse(PolicySerializer.Serialize(rule))!; Assert.Null(serialized["Match"]![propertyName]); Assert.False(serialized["Match"]!["Interactive"]!.GetValue()); @@ -976,7 +962,7 @@ public void Empty_collection_only_match_is_not_an_effective_rule_criterion( { emptyOnly["Match"]!["Managers"] = new JsonArray("Winget"); } - Assert.NotNull(PolicySerializer.DeserializeStrict(emptyOnly.ToJsonString())); + Assert.NotNull(PolicySerializer.Deserialize(emptyOnly.ToJsonString())); } [Theory] @@ -997,7 +983,7 @@ public void Collection_match_filters_reject_duplicate_values( } Assert.Throws( - () => PolicySerializer.DeserializeStrict(match.ToJsonString())); + () => PolicySerializer.Deserialize(match.ToJsonString())); } [Fact] @@ -1012,7 +998,7 @@ public void Source_names_require_managers_and_preserve_exact_literal_names() } """; var exception = Assert.Throws( - () => PolicySerializer.DeserializeStrict(WithoutManager)); + () => PolicySerializer.Deserialize(WithoutManager)); Assert.Contains("$.Match.SourceNames", exception.Message, StringComparison.Ordinal); const string WithManager = """ @@ -1026,7 +1012,7 @@ public void Source_names_require_managers_and_preserve_exact_literal_names() } } """; - var rule = PolicySerializer.DeserializeStrict(WithManager)!; + var rule = PolicySerializer.Deserialize(WithManager)!; Assert.Equal([ManagerName.Winget], rule.Match.Managers); Assert.Equal(["corp*", "PSGallery"], rule.Match.SourceNames); @@ -1049,7 +1035,7 @@ public void Source_names_require_managers_and_preserve_exact_literal_names() } """; exception = Assert.Throws( - () => PolicySerializer.DeserializeStrict(MultipleManagers)); + () => PolicySerializer.Deserialize(MultipleManagers)); Assert.Contains("$.Match.SourceNames", exception.Message, StringComparison.Ordinal); } @@ -1066,12 +1052,12 @@ static string MatchJson(IEnumerable managers) => managers.Select(name => JsonValue.Create(name)).ToArray()), }.ToJsonString(); - var maximum = PolicySerializer.DeserializeStrict(MatchJson(managerNames.Take(16)))!; + var maximum = PolicySerializer.Deserialize(MatchJson(managerNames.Take(16)))!; Assert.Equal(16, maximum.Managers.Count); Assert.NotEmpty(PolicySerializer.Serialize(maximum)); Assert.Throws( - () => PolicySerializer.DeserializeStrict(MatchJson(managerNames))); + () => PolicySerializer.Deserialize(MatchJson(managerNames))); maximum.Managers.Add(ManagerName.Vcpkg); Assert.Throws(() => PolicySerializer.Serialize(maximum)); @@ -1088,12 +1074,12 @@ static string MatchJson(int count) => Enumerable.Range(0, count).Select(index => JsonValue.Create($"source-{index}")).ToArray()), }.ToJsonString(); - var maximum = PolicySerializer.DeserializeStrict(MatchJson(128))!; + var maximum = PolicySerializer.Deserialize(MatchJson(128))!; Assert.Equal(128, maximum.SourceNames.Count); Assert.NotEmpty(PolicySerializer.Serialize(maximum)); Assert.Throws( - () => PolicySerializer.DeserializeStrict(MatchJson(129))); + () => PolicySerializer.Deserialize(MatchJson(129))); maximum.SourceNames.Add("source-128"); Assert.Throws(() => PolicySerializer.Serialize(maximum)); @@ -1157,12 +1143,12 @@ public async Task Rust_schemas_document_the_runtime_validity_window_invariant() public void Package_identifier_condition_requires_exactly_one_nonempty_mode() { const string Exact = """{"Exact":["Microsoft.VisualStudioCode"]}"""; - var exact = PolicySerializer.DeserializeStrict(Exact)!; + var exact = PolicySerializer.Deserialize(Exact)!; Assert.Equal(["Microsoft.VisualStudioCode"], exact.Exact); Assert.Null(exact.Patterns); const string Patterns = """{"Patterns":["Microsoft.*"]}"""; - var patterns = PolicySerializer.DeserializeStrict(Patterns)!; + var patterns = PolicySerializer.Deserialize(Patterns)!; Assert.Equal(["Microsoft.*"], patterns.Patterns); Assert.Null(patterns.Exact); @@ -1194,17 +1180,17 @@ public void Package_identifier_condition_requires_exactly_one_nonempty_mode() }) { Assert.Throws( - () => PolicySerializer.DeserializeStrict(invalid)); + () => PolicySerializer.Deserialize(invalid)); } const string OldFlatList = """{"PackageIdentifiers":["Microsoft.VisualStudioCode"]}"""; - Assert.Throws(() => PolicySerializer.DeserializeStrict(OldFlatList)); + Assert.Throws(() => PolicySerializer.Deserialize(OldFlatList)); - var absent = PolicySerializer.DeserializeStrict( + var absent = PolicySerializer.Deserialize( """{"PackageIdentifiers":null}""")!; Assert.DoesNotContain("\"PackageIdentifiers\"", PolicySerializer.Serialize(absent)); - var match = PolicySerializer.DeserializeStrict( + var match = PolicySerializer.Deserialize( """{"PackageIdentifiers":{"Patterns":["Microsoft.*"]}}""")!; Assert.Equal(["Microsoft.*"], match.PackageIdentifiers!.Patterns); Assert.Contains("\"Patterns\"", PolicySerializer.Serialize(match), StringComparison.Ordinal); @@ -1214,15 +1200,15 @@ public void Package_identifier_condition_requires_exactly_one_nonempty_mode() public void Version_condition_requires_exactly_one_nonempty_mode() { const string Exact = """{"Exact":["5.6.0.0","2026.09-preview"]}"""; - var exact = PolicySerializer.DeserializeStrict(Exact)!; + 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.DeserializeStrict(Range)!; + var range = PolicySerializer.Deserialize(Range)!; Assert.Equal("1.0.0", range.Range!.MinVersion); Assert.Null(range.Exact); - Assert.NotNull(PolicySerializer.DeserializeStrict( + 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" }); @@ -1256,7 +1242,7 @@ public void Version_condition_requires_exactly_one_nonempty_mode() }) { Assert.Throws( - () => PolicySerializer.DeserializeStrict(invalid)); + () => PolicySerializer.Deserialize(invalid)); } foreach (var old in new[] @@ -1265,10 +1251,10 @@ public void Version_condition_requires_exactly_one_nonempty_mode() """{"VersionRange":{"MinVersion":"1.0.0"}}""", }) { - Assert.Throws(() => PolicySerializer.DeserializeStrict(old)); + Assert.Throws(() => PolicySerializer.Deserialize(old)); } - var absent = PolicySerializer.DeserializeStrict("""{"Version":null}""")!; + var absent = PolicySerializer.Deserialize("""{"Version":null}""")!; Assert.DoesNotContain("\"Version\"", PolicySerializer.Serialize(absent)); Assert.Throws( @@ -1392,7 +1378,7 @@ public void Constraints_are_valid_only_for_allow_rules() "Constraints": { "AllowInteractive": false } } """; - var allow = PolicySerializer.DeserializeStrict(AllowWithConstraints)!; + var allow = PolicySerializer.Deserialize(AllowWithConstraints)!; Assert.NotNull(allow.Constraints); Assert.Contains("\"Constraints\"", PolicySerializer.Serialize(allow), StringComparison.Ordinal); @@ -1404,7 +1390,7 @@ public void Constraints_are_valid_only_for_allow_rules() "Match": { "Operations": ["Install"] } } """; - Assert.NotNull(PolicySerializer.DeserializeStrict(AllowWithoutConstraints)); + Assert.NotNull(PolicySerializer.Deserialize(AllowWithoutConstraints)); const string DenyWithoutConstraints = """ { @@ -1414,7 +1400,7 @@ public void Constraints_are_valid_only_for_allow_rules() "Match": { "Operations": ["Install"] } } """; - Assert.NotNull(PolicySerializer.DeserializeStrict(DenyWithoutConstraints)); + Assert.NotNull(PolicySerializer.Deserialize(DenyWithoutConstraints)); const string DenyWithNullConstraints = """ { @@ -1425,7 +1411,7 @@ public void Constraints_are_valid_only_for_allow_rules() "Constraints": null } """; - var deny = PolicySerializer.DeserializeStrict(DenyWithNullConstraints)!; + var deny = PolicySerializer.Deserialize(DenyWithNullConstraints)!; Assert.DoesNotContain("\"Constraints\"", PolicySerializer.Serialize(deny), StringComparison.Ordinal); const string DenyWithConstraints = """ @@ -1439,20 +1425,17 @@ public void Constraints_are_valid_only_for_allow_rules() } """; var exception = Assert.Throws( - () => PolicySerializer.DeserializeStrict(DenyWithConstraints)); + () => 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); - foreach (var options in new[] { PolicySerializer.Options, PolicySerializer.StrictOptions }) - { - exception = Assert.Throws( - () => JsonSerializer.Deserialize(DenyWithConstraints, options)); - Assert.Contains("$.Constraints", exception.Message, StringComparison.Ordinal); - Assert.Throws(() => JsonSerializer.Serialize(allow, options)); - } + exception = Assert.Throws( + () => JsonSerializer.Deserialize(DenyWithConstraints, PolicySerializer.Options)); + Assert.Contains("$.Constraints", exception.Message, StringComparison.Ordinal); + Assert.Throws(() => JsonSerializer.Serialize(allow, PolicySerializer.Options)); } [Fact] @@ -1627,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)); } } @@ -1647,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] diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs index 10d5bff..c8114c2 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs @@ -135,7 +135,7 @@ public static PolicyDocument Create(string id, string publisher, Decision defaul public static PolicyDocument ParseJson(string json) { - return PolicySerializer.DeserializePolicyDocumentStrict(json) + return PolicySerializer.Deserialize(json) ?? throw new JsonException("policy document was null"); } @@ -200,7 +200,7 @@ public static PolicyDraftDocument Create( public static PolicyDraftDocument ParseJson(string json) { - return PolicySerializer.DeserializePolicyDraftDocumentStrict(json) + return PolicySerializer.Deserialize(json) ?? throw new JsonException("policy draft document was null"); } diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs index a899b7c..627b503 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs @@ -13,16 +13,10 @@ public static partial class PolicySerializer private const int MaxSourceNames = 128; /// - /// Source-generated policy JSON options. Deserialization rejects duplicate property names - /// throughout the input using ordinal, case-sensitive name comparison. - /// - public static readonly JsonSerializerOptions Options = CreateOptions(strict: false); - - /// - /// Source-generated strict policy JSON options. Deserialization rejects unknown and duplicate + /// 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 StrictOptions = CreateOptions(strict: true); + public static readonly JsonSerializerOptions Options = CreateOptions(); public static string Serialize(PolicyDocument value) { @@ -36,34 +30,19 @@ public static string Serialize(PolicyDraftDocument value) return JsonSerializer.Serialize(value, TypeInfo()); } - public static PolicyDocument? DeserializePolicyDocument(string json) - { - PolicyJsonInput.RejectDuplicatePropertyNames(json, PolicySerializerContext.Default.Options); - return Validate(JsonSerializer.Deserialize(json, PolicySerializerContext.Default.PolicyDocument)); - } - - public static PolicyDocument? DeserializePolicyDocumentStrict(string json) - { - PolicyJsonInput.RejectDuplicatePropertyNames(json, PolicyStrictSerializerContext.Default.Options); - return Validate(JsonSerializer.Deserialize(json, PolicyStrictSerializerContext.Default.PolicyDocument)); - } - - public static PolicyDraftDocument? DeserializePolicyDraftDocumentStrict(string json) - { - PolicyJsonInput.RejectDuplicatePropertyNames(json, PolicyStrictSerializerContext.Default.Options); - return 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) { - PolicyJsonInput.RejectDuplicatePropertyNames(json, PolicyStrictSerializerContext.Default.Options); - var value = JsonSerializer.Deserialize(json, StrictTypeInfo()); + PolicyJsonInput.RejectDuplicatePropertyNames(json, PolicySerializerContext.Default.Options); + var value = JsonSerializer.Deserialize(json, DeserializationTypeInfo()); ValidateSemanticValue(value); return value; } @@ -459,42 +438,31 @@ private static JsonTypeInfo TypeInfo() return Cast(Options.GetTypeInfo(typeof(T))); } - 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(PackageIdentifierCondition) ? Cast(PolicyStrictSerializerContext.Default.PackageIdentifierCondition) : - typeof(T) == typeof(VersionCondition) ? Cast(PolicyStrictSerializerContext.Default.VersionCondition) : - 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 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(bool strict) + private static JsonSerializerOptions CreateOptions() { - JsonSerializerContext context = strict - ? PolicyStrictSerializerContext.Default - : PolicySerializerContext.Default; - var options = new JsonSerializerOptions(context.Options) + var options = new JsonSerializerOptions(PolicySerializerContext.Default.Options) { - TypeInfoResolver = context.WithAddedModifier(AttachSemanticValidation), + TypeInfoResolver = PolicySerializerContext.Default.WithAddedModifier(AttachSemanticValidation), }; - if (strict) - { - AddDuplicateRejectingConverters(options, PolicyStrictSerializerContext.Default); - } - else - { - AddDuplicateRejectingConverters(options, PolicySerializerContext.Default); - } - + AddDuplicateRejectingConverters(options, PolicySerializerContext.Default); return options; } @@ -530,38 +498,6 @@ private static void AddDuplicateRejectingConverters( context.PolicyConstraints)); } - private static void AddDuplicateRejectingConverters( - JsonSerializerOptions options, - PolicyStrictSerializerContext 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) @@ -629,22 +565,4 @@ or nameof(PolicyMatch.Architectures) [JsonSerializable(typeof(VersionCondition))] [JsonSerializable(typeof(VersionRange))] [JsonSerializable(typeof(PolicyConstraints))] -internal sealed partial class PolicySerializerContext : JsonSerializerContext; - -[JsonSourceGenerationOptions( - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - RespectNullableAnnotations = true, - UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)] -[JsonSerializable(typeof(PolicyDocument))] -[JsonSerializable(typeof(PolicyDraftDocument))] -[JsonSerializable(typeof(PolicyMetadata))] -[JsonSerializable(typeof(PolicyDraftMetadata))] -[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 edad1b0..acb46a0 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Model/README.md @@ -21,8 +21,8 @@ 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, and decision. -- `PolicySerializer.cs` defines shared source-generated `JsonSerializerOptions`, including strict parsing that rejects unknown JSON members and JSON null for non-nullable policy members or collection elements. -- All policy deserialization entry points, including the compatibility helper and public serializer options, reject unknown and duplicate property names before information can be discarded. 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. +- `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 recommended strict policy parsing entry point.