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

@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using StellaOps.Aoc;
namespace StellaOps.Aoc.AspNetCore.Routing;
public sealed partial class AocGuardEndpointFilter<TRequest>
{
private AocGuardOptions ResolveOptions()
{
if (_guardOptions is not null)
{
return _guardOptions;
}
return _options?.Value ?? AocGuardOptions.Default;
}
private static bool TryGetArgument(EndpointFilterInvocationContext context, out TRequest argument)
{
for (var i = 0; i < context.Arguments.Count; i++)
{
if (context.Arguments[i] is TRequest typedArgument)
{
argument = typedArgument;
return true;
}
}
argument = default!;
return false;
}
private void ValidatePayload(object payload, IAocGuard guard, AocGuardOptions options)
{
if (payload is JsonElement jsonElement)
{
guard.ValidateOrThrow(jsonElement, options);
return;
}
if (payload is JsonDocument jsonDocument)
{
using (jsonDocument)
{
guard.ValidateOrThrow(jsonDocument.RootElement, options);
}
return;
}
var element = JsonSerializer.SerializeToElement(payload, _serializerOptions);
guard.ValidateOrThrow(element, options);
}
}

View File

@@ -1,32 +1,38 @@
using HttpResults = Microsoft.AspNetCore.Http.Results;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Aoc;
using StellaOps.Aoc.AspNetCore.Results;
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Aoc;
using StellaOps.Aoc.AspNetCore.Results;
using HttpResults = Microsoft.AspNetCore.Http.Results;
namespace StellaOps.Aoc.AspNetCore.Routing;
public sealed class AocGuardEndpointFilter<TRequest> : IEndpointFilter
public sealed partial class AocGuardEndpointFilter<TRequest> : IEndpointFilter
{
private readonly IAocGuard _guard;
private readonly ILogger<AocGuardEndpointFilter<TRequest>> _logger;
private readonly Func<TRequest, IEnumerable<object?>> _payloadSelector;
private readonly JsonSerializerOptions _serializerOptions;
private readonly IOptions<AocGuardOptions>? _options;
private readonly AocGuardOptions? _guardOptions;
public AocGuardEndpointFilter(
IAocGuard guard,
ILogger<AocGuardEndpointFilter<TRequest>> logger,
Func<TRequest, IEnumerable<object?>> payloadSelector,
JsonSerializerOptions? serializerOptions,
IOptions<AocGuardOptions>? options,
AocGuardOptions? guardOptions)
{
_guard = guard ?? throw new ArgumentNullException(nameof(guard));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_payloadSelector = payloadSelector ?? throw new ArgumentNullException(nameof(payloadSelector));
_serializerOptions = serializerOptions ?? new JsonSerializerOptions(JsonSerializerDefaults.Web);
_options = options;
_guardOptions = guardOptions;
}
@@ -39,8 +45,7 @@ public sealed class AocGuardEndpointFilter<TRequest> : IEndpointFilter
if (!TryGetArgument(context, out var request))
{
var logger = context.HttpContext.RequestServices.GetService<ILogger<AocGuardEndpointFilter<TRequest>>>();
logger?.LogWarning("AOC guard filter did not find request argument of type {RequestType}.", typeof(TRequest).FullName);
_logger.LogWarning("AOC guard filter did not find request argument of type {RequestType}.", typeof(TRequest).FullName);
return HttpResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "AOC guard payload missing",
@@ -54,16 +59,14 @@ public sealed class AocGuardEndpointFilter<TRequest> : IEndpointFilter
}
catch (Exception ex)
{
var logger = context.HttpContext.RequestServices.GetService<ILogger<AocGuardEndpointFilter<TRequest>>>();
logger?.LogError(ex, "AOC guard payload selector failed for {RequestType}.", typeof(TRequest).FullName);
_logger.LogError(ex, "AOC guard payload selector failed for {RequestType}.", typeof(TRequest).FullName);
return HttpResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "AOC guard payload selector failed",
detail: "Request payload could not be extracted for validation.");
}
var guard = context.HttpContext.RequestServices.GetRequiredService<IAocGuard>();
var options = ResolveOptions(context.HttpContext.RequestServices);
var options = ResolveOptions();
foreach (var payload in payloads)
{
@@ -74,7 +77,7 @@ public sealed class AocGuardEndpointFilter<TRequest> : IEndpointFilter
try
{
ValidatePayload(payload, guard, options);
ValidatePayload(payload, _guard, options);
}
catch (AocGuardException exception)
{
@@ -82,8 +85,7 @@ public sealed class AocGuardEndpointFilter<TRequest> : IEndpointFilter
}
catch (Exception ex)
{
var logger = context.HttpContext.RequestServices.GetService<ILogger<AocGuardEndpointFilter<TRequest>>>();
logger?.LogError(ex, "AOC guard payload validation failed for {RequestType}.", typeof(TRequest).FullName);
_logger.LogError(ex, "AOC guard payload validation failed for {RequestType}.", typeof(TRequest).FullName);
return HttpResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "AOC guard payload invalid",
@@ -93,51 +95,4 @@ public sealed class AocGuardEndpointFilter<TRequest> : IEndpointFilter
return await next(context).ConfigureAwait(false);
}
private AocGuardOptions ResolveOptions(IServiceProvider services)
{
if (_guardOptions is not null)
{
return _guardOptions;
}
var options = services.GetService<IOptions<AocGuardOptions>>();
return options?.Value ?? AocGuardOptions.Default;
}
private static bool TryGetArgument(EndpointFilterInvocationContext context, out TRequest argument)
{
for (var i = 0; i < context.Arguments.Count; i++)
{
if (context.Arguments[i] is TRequest typedArgument)
{
argument = typedArgument;
return true;
}
}
argument = default!;
return false;
}
private void ValidatePayload(object payload, IAocGuard guard, AocGuardOptions options)
{
if (payload is JsonElement jsonElement)
{
guard.ValidateOrThrow(jsonElement, options);
return;
}
if (payload is JsonDocument jsonDocument)
{
using (jsonDocument)
{
guard.ValidateOrThrow(jsonDocument.RootElement, options);
}
return;
}
var element = JsonSerializer.SerializeToElement(payload, _serializerOptions);
guard.ValidateOrThrow(element, options);
}
}

View File

@@ -1,8 +1,11 @@
using Microsoft.AspNetCore.Builder;
using System;
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Aoc;
namespace StellaOps.Aoc.AspNetCore.Routing;
@@ -28,7 +31,17 @@ public static class AocGuardEndpointFilterExtensions
{
endpointBuilder.FilterFactories.Add((routeContext, next) =>
{
var filter = new AocGuardEndpointFilter<TRequest>(payloadSelector, serializerOptions, guardOptions);
var services = routeContext.ApplicationServices;
var guard = services.GetRequiredService<IAocGuard>();
var logger = services.GetRequiredService<ILogger<AocGuardEndpointFilter<TRequest>>>();
var options = services.GetService<IOptions<AocGuardOptions>>();
var filter = new AocGuardEndpointFilter<TRequest>(
guard,
logger,
payloadSelector,
serializerOptions,
options,
guardOptions);
return invocationContext => filter.InvokeAsync(invocationContext, next);
});
});

View File

@@ -8,3 +8,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0039-M | DONE | Revalidated maintainability for StellaOps.Aoc.AspNetCore (2026-01-06). |
| AUDIT-0039-T | DONE | Revalidated test coverage for StellaOps.Aoc.AspNetCore (2026-01-06). |
| AUDIT-0039-A | DONE | Hardened guard filter error handling and added tests. |
| REMED-06 | DONE | SOLID review notes refreshed 2026-02-04. |
| REMED-08 | DONE | AocGuardEndpointFilter uses constructor injection with helper partials; service locator removed; `dotnet test src/Aoc/__Tests/StellaOps.Aoc.AspNetCore.Tests/StellaOps.Aoc.AspNetCore.Tests.csproj` passed (8 tests) 2026-02-04. |

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

View File

@@ -4,7 +4,7 @@ using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Aoc;
using StellaOps.Aoc.AspNetCore.Routing;
using StellaOps.TestKit;
@@ -14,11 +14,18 @@ namespace StellaOps.Aoc.AspNetCore.Tests;
public sealed class AocGuardEndpointFilterTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task ReturnsProblem_WhenRequestMissing()
{
var httpContext = BuildHttpContext(new TestAocGuard());
var filter = new AocGuardEndpointFilter<GuardPayload>(_ => Array.Empty<object?>(), null, null);
var guard = new TestAocGuard();
var httpContext = BuildHttpContext();
var filter = new AocGuardEndpointFilter<GuardPayload>(
guard,
NullLogger<AocGuardEndpointFilter<GuardPayload>>.Instance,
_ => Array.Empty<object?>(),
null,
null,
null);
var context = new TestEndpointFilterInvocationContext(httpContext, Array.Empty<object?>());
var result = await filter.InvokeAsync(context, _ => new ValueTask<object?>(TypedResults.Ok()));
@@ -28,11 +35,18 @@ public sealed class AocGuardEndpointFilterTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task ReturnsProblem_WhenPayloadSelectorThrows()
{
var httpContext = BuildHttpContext(new TestAocGuard());
var filter = new AocGuardEndpointFilter<GuardPayload>(_ => throw new InvalidOperationException("boom"), null, null);
var guard = new TestAocGuard();
var httpContext = BuildHttpContext();
var filter = new AocGuardEndpointFilter<GuardPayload>(
guard,
NullLogger<AocGuardEndpointFilter<GuardPayload>>.Instance,
_ => throw new InvalidOperationException("boom"),
null,
null,
null);
var context = new TestEndpointFilterInvocationContext(httpContext, new object?[] { new GuardPayload(new JsonElement()) });
var result = await filter.InvokeAsync(context, _ => new ValueTask<object?>(TypedResults.Ok()));
@@ -42,11 +56,18 @@ public sealed class AocGuardEndpointFilterTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task ReturnsProblem_WhenSerializationFails()
{
var httpContext = BuildHttpContext(new TestAocGuard());
var filter = new AocGuardEndpointFilter<GuardPayload>(_ => new object?[] { new SelfReferencingPayload() }, null, null);
var guard = new TestAocGuard();
var httpContext = BuildHttpContext();
var filter = new AocGuardEndpointFilter<GuardPayload>(
guard,
NullLogger<AocGuardEndpointFilter<GuardPayload>>.Instance,
_ => new object?[] { new SelfReferencingPayload() },
null,
null,
null);
var context = new TestEndpointFilterInvocationContext(httpContext, new object?[] { new GuardPayload(new JsonElement()) });
var result = await filter.InvokeAsync(context, _ => new ValueTask<object?>(TypedResults.Ok()));
@@ -56,16 +77,22 @@ public sealed class AocGuardEndpointFilterTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task ValidatesJsonDocumentPayloads()
{
var guard = new TestAocGuard();
var httpContext = BuildHttpContext(guard);
var filter = new AocGuardEndpointFilter<GuardPayload>(_ =>
{
using var doc = JsonDocument.Parse("""{"tenant":"default","source":{},"upstream":{"content_hash":"sha256:abc","signature":{"present":false}},"content":{"raw":{}},"linkset":{}}""");
return new object?[] { JsonDocument.Parse(doc.RootElement.GetRawText()) };
}, null, null);
var httpContext = BuildHttpContext();
var filter = new AocGuardEndpointFilter<GuardPayload>(
guard,
NullLogger<AocGuardEndpointFilter<GuardPayload>>.Instance,
_ =>
{
using var doc = JsonDocument.Parse("""{"tenant":"default","source":{},"upstream":{"content_hash":"sha256:abc","signature":{"present":false}},"content":{"raw":{}},"linkset":{}}""");
return new object?[] { JsonDocument.Parse(doc.RootElement.GetRawText()) };
},
null,
null,
null);
var context = new TestEndpointFilterInvocationContext(httpContext, new object?[] { new GuardPayload(new JsonElement()) });
var result = await filter.InvokeAsync(context, _ => new ValueTask<object?>(TypedResults.Ok()));
@@ -75,18 +102,19 @@ public sealed class AocGuardEndpointFilterTests
Assert.True(guard.WasValidated);
}
private static DefaultHttpContext BuildHttpContext(IAocGuard guard)
private static DefaultHttpContext BuildHttpContext()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton(guard);
var provider = services.BuildServiceProvider();
return new DefaultHttpContext { RequestServices = provider, Response = { Body = new MemoryStream() } };
return new DefaultHttpContext { Response = { Body = new MemoryStream() } };
}
private static async Task<int> ExecuteAsync(object? result, HttpContext context)
{
if (result is IStatusCodeHttpResult statusResult)
{
context.Response.StatusCode = statusResult.StatusCode ?? StatusCodes.Status200OK;
return context.Response.StatusCode;
}
if (result is IResult httpResult)
{
await httpResult.ExecuteAsync(context);