This commit is contained in:
master
2026-02-04 19:59:20 +02:00
parent 557feefdc3
commit 5548cf83bf
1479 changed files with 53557 additions and 40339 deletions

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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.

View File

@@ -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",
};
}

View File

@@ -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",
};
}

View 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;
}
}
}

View 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."));
}
}
}

View 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."));
}
}
}

View 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."));
}
}
}

View 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."));
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,8 @@
using System.Text.Json;
namespace StellaOps.Aoc;
public interface IAocGuard
{
AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null);
}

View File

@@ -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. |