part #2
This commit is contained in:
@@ -40,6 +40,6 @@ public sealed record AocError(
|
||||
throw new ArgumentNullException(nameof(exception));
|
||||
}
|
||||
|
||||
return FromResult(exception.Result, message);
|
||||
return FromResult(new AocGuardResult(false, exception.Violations), message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace StellaOps.Aoc;
|
||||
|
||||
public static class AocForbiddenKeys
|
||||
{
|
||||
private static readonly ImmutableHashSet<string> ForbiddenTopLevel = new[]
|
||||
private static readonly ImmutableHashSet<string> _forbiddenTopLevel = new[]
|
||||
{
|
||||
"severity",
|
||||
"cvss",
|
||||
@@ -18,7 +18,7 @@ public static class AocForbiddenKeys
|
||||
// handled separately by IsDerivedField() and produce ERR_AOC_006
|
||||
}.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static bool IsForbiddenTopLevel(string propertyName) => ForbiddenTopLevel.Contains(propertyName);
|
||||
public static bool IsForbiddenTopLevel(string propertyName) => _forbiddenTopLevel.Contains(propertyName);
|
||||
|
||||
public static bool IsDerivedField(string propertyName)
|
||||
=> propertyName.StartsWith("effective_", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace StellaOps.Aoc;
|
||||
|
||||
public sealed record AocGuardOptions
|
||||
{
|
||||
private static readonly ImmutableHashSet<string> DefaultRequiredTopLevel = new[]
|
||||
private static readonly ImmutableHashSet<string> _defaultRequiredTopLevel = new[]
|
||||
{
|
||||
"tenant",
|
||||
"source",
|
||||
@@ -14,7 +14,7 @@ public sealed record AocGuardOptions
|
||||
"linkset",
|
||||
}.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static readonly ImmutableHashSet<string> DefaultAllowedTopLevel = DefaultRequiredTopLevel
|
||||
private static readonly ImmutableHashSet<string> _defaultAllowedTopLevel = _defaultRequiredTopLevel
|
||||
.Union(new[]
|
||||
{
|
||||
"_id",
|
||||
@@ -33,12 +33,12 @@ public sealed record AocGuardOptions
|
||||
|
||||
public static AocGuardOptions Default { get; } = new();
|
||||
|
||||
public ImmutableHashSet<string> RequiredTopLevelFields { get; init; } = DefaultRequiredTopLevel;
|
||||
public ImmutableHashSet<string> RequiredTopLevelFields { get; init; } = _defaultRequiredTopLevel;
|
||||
|
||||
/// <summary>
|
||||
/// Optional allowlist for top-level fields. Unknown fields trigger ERR_AOC_007.
|
||||
/// </summary>
|
||||
public ImmutableHashSet<string> AllowedTopLevelFields { get; init; } = DefaultAllowedTopLevel;
|
||||
public ImmutableHashSet<string> AllowedTopLevelFields { get; init; } = _defaultAllowedTopLevel;
|
||||
|
||||
/// <summary>
|
||||
/// When true, signature metadata is required under upstream.signature.
|
||||
|
||||
@@ -14,21 +14,3 @@ public enum AocViolationCode
|
||||
InvalidTenant,
|
||||
InvalidSignatureMetadata,
|
||||
}
|
||||
|
||||
public static class AocViolationCodeExtensions
|
||||
{
|
||||
public static string ToErrorCode(this AocViolationCode code) => code switch
|
||||
{
|
||||
AocViolationCode.ForbiddenField => "ERR_AOC_001",
|
||||
AocViolationCode.MergeAttempt => "ERR_AOC_002",
|
||||
AocViolationCode.IdempotencyViolation => "ERR_AOC_003",
|
||||
AocViolationCode.MissingProvenance => "ERR_AOC_004",
|
||||
AocViolationCode.SignatureInvalid => "ERR_AOC_005",
|
||||
AocViolationCode.DerivedFindingDetected => "ERR_AOC_006",
|
||||
AocViolationCode.UnknownField => "ERR_AOC_007",
|
||||
AocViolationCode.MissingRequiredField => "ERR_AOC_008",
|
||||
AocViolationCode.InvalidTenant => "ERR_AOC_009",
|
||||
AocViolationCode.InvalidSignatureMetadata => "ERR_AOC_010",
|
||||
_ => "ERR_AOC_000",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Aoc;
|
||||
|
||||
public static class AocViolationCodeExtensions
|
||||
{
|
||||
public static string ToErrorCode(this AocViolationCode code) => code switch
|
||||
{
|
||||
AocViolationCode.ForbiddenField => "ERR_AOC_001",
|
||||
AocViolationCode.MergeAttempt => "ERR_AOC_002",
|
||||
AocViolationCode.IdempotencyViolation => "ERR_AOC_003",
|
||||
AocViolationCode.MissingProvenance => "ERR_AOC_004",
|
||||
AocViolationCode.SignatureInvalid => "ERR_AOC_005",
|
||||
AocViolationCode.DerivedFindingDetected => "ERR_AOC_006",
|
||||
AocViolationCode.UnknownField => "ERR_AOC_007",
|
||||
AocViolationCode.MissingRequiredField => "ERR_AOC_008",
|
||||
AocViolationCode.InvalidTenant => "ERR_AOC_009",
|
||||
AocViolationCode.InvalidSignatureMetadata => "ERR_AOC_010",
|
||||
_ => "ERR_AOC_000",
|
||||
};
|
||||
}
|
||||
45
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.Base64.cs
Normal file
45
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.Base64.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Aoc;
|
||||
|
||||
public sealed partial class AocWriteGuard
|
||||
{
|
||||
private static bool IsBase64Payload(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryDecodeBase64(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalized = value.Replace('-', '+').Replace('_', '/');
|
||||
switch (normalized.Length % 4)
|
||||
{
|
||||
case 2:
|
||||
normalized += "==";
|
||||
break;
|
||||
case 3:
|
||||
normalized += "=";
|
||||
break;
|
||||
}
|
||||
|
||||
return TryDecodeBase64(normalized);
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
Convert.FromBase64String(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.Content.cs
Normal file
43
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.Content.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Aoc;
|
||||
|
||||
public sealed partial class AocWriteGuard
|
||||
{
|
||||
private static void ValidateContent(
|
||||
JsonElement document,
|
||||
ImmutableArray<AocViolation>.Builder violations)
|
||||
{
|
||||
if (document.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (!content.TryGetProperty("raw", out var raw) || raw.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.MissingProvenance,
|
||||
"/content/raw",
|
||||
"Raw upstream payload must be preserved."));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.MissingRequiredField,
|
||||
"/content",
|
||||
"Content metadata is required."));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateLinkset(
|
||||
JsonElement document,
|
||||
ImmutableArray<AocViolation>.Builder violations)
|
||||
{
|
||||
if (!document.TryGetProperty("linkset", out var linkset) || linkset.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.MissingRequiredField,
|
||||
"/linkset",
|
||||
"Linkset metadata is required."));
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.Signature.cs
Normal file
79
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.Signature.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Aoc;
|
||||
|
||||
public sealed partial class AocWriteGuard
|
||||
{
|
||||
private static void ValidateSignature(
|
||||
JsonElement signature,
|
||||
ImmutableArray<AocViolation>.Builder violations,
|
||||
AocGuardOptions options)
|
||||
{
|
||||
if (!signature.TryGetProperty("present", out var presentElement) ||
|
||||
presentElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.InvalidSignatureMetadata,
|
||||
"/upstream/signature/present",
|
||||
"Signature metadata must include 'present' boolean."));
|
||||
return;
|
||||
}
|
||||
|
||||
var signaturePresent = presentElement.GetBoolean();
|
||||
|
||||
if (!signaturePresent)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signature.TryGetProperty("format", out var formatElement) ||
|
||||
formatElement.ValueKind != JsonValueKind.String ||
|
||||
string.IsNullOrWhiteSpace(formatElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.InvalidSignatureMetadata,
|
||||
"/upstream/signature/format",
|
||||
"Signature format is required when signature is present."));
|
||||
}
|
||||
else
|
||||
{
|
||||
var format = formatElement.GetString()!.Trim();
|
||||
if (options.AllowedSignatureFormats.Count > 0 &&
|
||||
!options.AllowedSignatureFormats.Contains(format))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.InvalidSignatureMetadata,
|
||||
"/upstream/signature/format",
|
||||
$"Signature format '{format}' is not permitted."));
|
||||
}
|
||||
}
|
||||
|
||||
if (!signature.TryGetProperty("sig", out var sigElement) ||
|
||||
sigElement.ValueKind != JsonValueKind.String ||
|
||||
string.IsNullOrWhiteSpace(sigElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.SignatureInvalid,
|
||||
"/upstream/signature/sig",
|
||||
"Signature payload is required when signature is present."));
|
||||
}
|
||||
else if (!IsBase64Payload(sigElement.GetString()!))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.InvalidSignatureMetadata,
|
||||
"/upstream/signature/sig",
|
||||
"Signature payload must be base64 or base64url encoded."));
|
||||
}
|
||||
|
||||
if (!signature.TryGetProperty("key_id", out var keyIdElement) ||
|
||||
keyIdElement.ValueKind != JsonValueKind.String ||
|
||||
string.IsNullOrWhiteSpace(keyIdElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.InvalidSignatureMetadata,
|
||||
"/upstream/signature/key_id",
|
||||
"Signature key identifier is required when signature is present."));
|
||||
}
|
||||
}
|
||||
}
|
||||
87
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.TopLevel.cs
Normal file
87
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.TopLevel.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Aoc;
|
||||
|
||||
public sealed partial class AocWriteGuard
|
||||
{
|
||||
private static void ValidateTopLevelFields(
|
||||
JsonElement document,
|
||||
IEnumerable<string> allowedTopLevelFields,
|
||||
ImmutableArray<AocViolation>.Builder violations)
|
||||
{
|
||||
foreach (var property in document.EnumerateObject())
|
||||
{
|
||||
if (AocForbiddenKeys.IsForbiddenTopLevel(property.Name))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.ForbiddenField,
|
||||
$"/{property.Name}",
|
||||
$"Field '{property.Name}' is forbidden in AOC documents."));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (AocForbiddenKeys.IsDerivedField(property.Name))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.DerivedFindingDetected,
|
||||
$"/{property.Name}",
|
||||
$"Derived field '{property.Name}' must not be written during ingestion."));
|
||||
}
|
||||
|
||||
if (!allowedTopLevelFields.Contains(property.Name))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.UnknownField,
|
||||
$"/{property.Name}",
|
||||
$"Field '{property.Name}' is not allowed in AOC documents."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateRequiredFields(
|
||||
JsonElement document,
|
||||
IEnumerable<string> requiredTopLevelFields,
|
||||
AocGuardOptions options,
|
||||
ImmutableArray<AocViolation>.Builder violations)
|
||||
{
|
||||
foreach (var required in requiredTopLevelFields.OrderBy(name => name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (options.RequireTenant && string.Equals(required, "tenant", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!document.TryGetProperty(required, out var element) || element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.MissingRequiredField,
|
||||
$"/{required}",
|
||||
$"Required field '{required}' is missing."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateTenant(
|
||||
JsonElement document,
|
||||
AocGuardOptions options,
|
||||
ImmutableArray<AocViolation>.Builder violations)
|
||||
{
|
||||
if (!options.RequireTenant)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.TryGetProperty("tenant", out var tenantElement) ||
|
||||
tenantElement.ValueKind != JsonValueKind.String ||
|
||||
string.IsNullOrWhiteSpace(tenantElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.InvalidTenant,
|
||||
"/tenant",
|
||||
"Tenant must be a non-empty string."));
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.Upstream.cs
Normal file
48
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.Upstream.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Aoc;
|
||||
|
||||
public sealed partial class AocWriteGuard
|
||||
{
|
||||
private static void ValidateUpstream(
|
||||
JsonElement document,
|
||||
AocGuardOptions options,
|
||||
ImmutableArray<AocViolation>.Builder violations)
|
||||
{
|
||||
if (document.TryGetProperty("upstream", out var upstream) && upstream.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (!upstream.TryGetProperty("content_hash", out var contentHash) ||
|
||||
contentHash.ValueKind != JsonValueKind.String ||
|
||||
string.IsNullOrWhiteSpace(contentHash.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.MissingProvenance,
|
||||
"/upstream/content_hash",
|
||||
"Upstream content hash is required."));
|
||||
}
|
||||
|
||||
if (!upstream.TryGetProperty("signature", out var signature) || signature.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
if (options.RequireSignatureMetadata)
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.MissingProvenance,
|
||||
"/upstream/signature",
|
||||
"Signature metadata is required."));
|
||||
}
|
||||
}
|
||||
else if (options.RequireSignatureMetadata)
|
||||
{
|
||||
ValidateSignature(signature, violations, options);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.MissingRequiredField,
|
||||
"/upstream",
|
||||
"Upstream metadata is required."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Aoc;
|
||||
|
||||
public interface IAocGuard
|
||||
{
|
||||
AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null);
|
||||
}
|
||||
|
||||
public sealed class AocWriteGuard : IAocGuard
|
||||
public sealed partial class AocWriteGuard : IAocGuard
|
||||
{
|
||||
public AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null)
|
||||
{
|
||||
@@ -20,174 +14,13 @@ public sealed class AocWriteGuard : IAocGuard
|
||||
var allowedTopLevelFields = (options.AllowedTopLevelFields ?? AocGuardOptions.Default.AllowedTopLevelFields)
|
||||
.Union(requiredTopLevelFields);
|
||||
|
||||
foreach (var property in document.EnumerateObject())
|
||||
{
|
||||
if (AocForbiddenKeys.IsForbiddenTopLevel(property.Name))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.ForbiddenField, $"/{property.Name}", $"Field '{property.Name}' is forbidden in AOC documents."));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (AocForbiddenKeys.IsDerivedField(property.Name))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.DerivedFindingDetected, $"/{property.Name}", $"Derived field '{property.Name}' must not be written during ingestion."));
|
||||
}
|
||||
|
||||
if (!allowedTopLevelFields.Contains(property.Name))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.UnknownField, $"/{property.Name}", $"Field '{property.Name}' is not allowed in AOC documents."));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var required in requiredTopLevelFields.OrderBy(name => name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (options.RequireTenant && string.Equals(required, "tenant", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!document.TryGetProperty(required, out var element) || element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, $"/{required}", $"Required field '{required}' is missing."));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.RequireTenant)
|
||||
{
|
||||
if (!document.TryGetProperty("tenant", out var tenantElement) ||
|
||||
tenantElement.ValueKind != JsonValueKind.String ||
|
||||
string.IsNullOrWhiteSpace(tenantElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidTenant, "/tenant", "Tenant must be a non-empty string."));
|
||||
}
|
||||
}
|
||||
|
||||
if (document.TryGetProperty("upstream", out var upstream) && upstream.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (!upstream.TryGetProperty("content_hash", out var contentHash) || contentHash.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(contentHash.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/upstream/content_hash", "Upstream content hash is required."));
|
||||
}
|
||||
|
||||
if (!upstream.TryGetProperty("signature", out var signature) || signature.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
if (options.RequireSignatureMetadata)
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/upstream/signature", "Signature metadata is required."));
|
||||
}
|
||||
}
|
||||
else if (options.RequireSignatureMetadata)
|
||||
{
|
||||
ValidateSignature(signature, violations, options);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/upstream", "Upstream metadata is required."));
|
||||
}
|
||||
|
||||
if (document.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (!content.TryGetProperty("raw", out var raw) || raw.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/content/raw", "Raw upstream payload must be preserved."));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/content", "Content metadata is required."));
|
||||
}
|
||||
|
||||
if (!document.TryGetProperty("linkset", out var linkset) || linkset.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/linkset", "Linkset metadata is required."));
|
||||
}
|
||||
ValidateTopLevelFields(document, allowedTopLevelFields, violations);
|
||||
ValidateRequiredFields(document, requiredTopLevelFields, options, violations);
|
||||
ValidateTenant(document, options, violations);
|
||||
ValidateUpstream(document, options, violations);
|
||||
ValidateContent(document, violations);
|
||||
ValidateLinkset(document, violations);
|
||||
|
||||
return AocGuardResult.FromViolations(violations);
|
||||
}
|
||||
|
||||
private static void ValidateSignature(JsonElement signature, ImmutableArray<AocViolation>.Builder violations, AocGuardOptions options)
|
||||
{
|
||||
if (!signature.TryGetProperty("present", out var presentElement) || presentElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/present", "Signature metadata must include 'present' boolean."));
|
||||
return;
|
||||
}
|
||||
|
||||
var signaturePresent = presentElement.GetBoolean();
|
||||
|
||||
if (!signaturePresent)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signature.TryGetProperty("format", out var formatElement) || formatElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(formatElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/format", "Signature format is required when signature is present."));
|
||||
}
|
||||
else
|
||||
{
|
||||
var format = formatElement.GetString()!.Trim();
|
||||
if (options.AllowedSignatureFormats.Count > 0 &&
|
||||
!options.AllowedSignatureFormats.Contains(format))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/format", $"Signature format '{format}' is not permitted."));
|
||||
}
|
||||
}
|
||||
|
||||
if (!signature.TryGetProperty("sig", out var sigElement) || sigElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(sigElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.SignatureInvalid, "/upstream/signature/sig", "Signature payload is required when signature is present."));
|
||||
}
|
||||
else if (!IsBase64Payload(sigElement.GetString()!))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/sig", "Signature payload must be base64 or base64url encoded."));
|
||||
}
|
||||
|
||||
if (!signature.TryGetProperty("key_id", out var keyIdElement) || keyIdElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(keyIdElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/key_id", "Signature key identifier is required when signature is present."));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsBase64Payload(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryDecodeBase64(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalized = value.Replace('-', '+').Replace('_', '/');
|
||||
switch (normalized.Length % 4)
|
||||
{
|
||||
case 2:
|
||||
normalized += "==";
|
||||
break;
|
||||
case 3:
|
||||
normalized += "=";
|
||||
break;
|
||||
}
|
||||
|
||||
return TryDecodeBase64(normalized);
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
Convert.FromBase64String(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
src/Aoc/__Libraries/StellaOps.Aoc/IAocGuard.cs
Normal file
8
src/Aoc/__Libraries/StellaOps.Aoc/IAocGuard.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Aoc;
|
||||
|
||||
public interface IAocGuard
|
||||
{
|
||||
AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null);
|
||||
}
|
||||
@@ -8,3 +8,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0036-M | DONE | Revalidated maintainability for StellaOps.Aoc (2026-01-06). |
|
||||
| AUDIT-0036-T | DONE | Revalidated test coverage for StellaOps.Aoc (2026-01-06). |
|
||||
| AUDIT-0036-A | DONE | Applied error code fixes, deterministic ordering, and guard validation hardening. |
|
||||
| REMED-06 | DONE | SOLID review notes refreshed 2026-02-04. |
|
||||
| REMED-08 | DONE | Private field naming fixed; blocking async removed; IAocGuard extracted; AocWriteGuard and violation code mapping split into partials/files; `dotnet test src/Aoc/__Tests/StellaOps.Aoc.Tests/StellaOps.Aoc.Tests.csproj` passed (11 tests) 2026-02-04. |
|
||||
|
||||
Reference in New Issue
Block a user