test fixes and new product advisories work

This commit is contained in:
master
2026-01-28 02:30:48 +02:00
parent 82caceba56
commit 644887997c
288 changed files with 69101 additions and 375 deletions

View File

@@ -5,6 +5,11 @@ namespace StellaOps.AirGap.Importer.Validation;
/// </summary>
public sealed record BundleValidationResult(bool IsValid, string Reason)
{
/// <summary>
/// Summary of referrer validation results (if referrer validation was performed).
/// </summary>
public ReferrerValidationSummary? ReferrerSummary { get; init; }
public static BundleValidationResult Success(string reason = "ok") => new(true, reason);
public static BundleValidationResult Failure(string reason) => new(false, reason);
}

View File

@@ -9,7 +9,7 @@ using StellaOps.AirGap.Importer.Versioning;
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// Coordinates DSSE, TUF, Merkle, monotonicity, and quarantine behaviors for an offline import.
/// Coordinates DSSE, TUF, Merkle, monotonicity, referrer validation, and quarantine behaviors for an offline import.
/// </summary>
public sealed class ImportValidator
{
@@ -19,6 +19,7 @@ public sealed class ImportValidator
private readonly RootRotationPolicy _rotation;
private readonly IVersionMonotonicityChecker _monotonicityChecker;
private readonly IQuarantineService _quarantineService;
private readonly ReferrerValidator? _referrerValidator;
private readonly ILogger<ImportValidator> _logger;
public ImportValidator(
@@ -28,7 +29,8 @@ public sealed class ImportValidator
RootRotationPolicy rotation,
IVersionMonotonicityChecker monotonicityChecker,
IQuarantineService quarantineService,
ILogger<ImportValidator> logger)
ILogger<ImportValidator> logger,
ReferrerValidator? referrerValidator = null)
{
_dsse = dsse ?? throw new ArgumentNullException(nameof(dsse));
_tuf = tuf ?? throw new ArgumentNullException(nameof(tuf));
@@ -36,6 +38,7 @@ public sealed class ImportValidator
_rotation = rotation ?? throw new ArgumentNullException(nameof(rotation));
_monotonicityChecker = monotonicityChecker ?? throw new ArgumentNullException(nameof(monotonicityChecker));
_quarantineService = quarantineService ?? throw new ArgumentNullException(nameof(quarantineService));
_referrerValidator = referrerValidator;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -152,6 +155,45 @@ public sealed class ImportValidator
}
verificationLog.Add($"rotation:{rotationResult.Reason}");
// Referrer validation (if validator is provided and bundle type supports it)
ReferrerValidationSummary? referrerSummary = null;
if (_referrerValidator is not null && IsBundleTypeWithReferrers(request.BundleType))
{
referrerSummary = _referrerValidator.Validate(
request.ManifestJson,
request.PayloadEntries,
cancellationToken);
if (!referrerSummary.IsValid)
{
var errorDetails = FormatReferrerErrors(referrerSummary);
var failed = BundleValidationResult.Failure($"referrer-validation-failed:{errorDetails}");
verificationLog.Add(failed.Reason);
_logger.LogWarning(
"offlinekit.import.validation failed tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest} reason_code={reason_code} referrer_missing={missing} checksum_mismatch={checksum} size_mismatch={size}",
request.TenantId,
request.BundleType,
request.BundleDigest,
"REFERRER_VALIDATION_FAILED",
referrerSummary.MissingReferrers,
referrerSummary.ChecksumMismatches,
referrerSummary.SizeMismatches);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed with { ReferrerSummary = referrerSummary };
}
if (referrerSummary.OrphanedReferrers > 0)
{
_logger.LogWarning(
"offlinekit.import.referrer_orphans tenant_id={tenant_id} bundle_type={bundle_type} orphaned_count={orphaned_count}",
request.TenantId,
request.BundleType,
referrerSummary.OrphanedReferrers);
}
verificationLog.Add($"referrers:valid={referrerSummary.ValidReferrers}:total={referrerSummary.TotalReferrers}");
}
BundleVersion incomingVersion;
try
{
@@ -254,7 +296,7 @@ public sealed class ImportValidator
request.BundleDigest,
request.ManifestVersion,
request.ForceActivate);
return BundleValidationResult.Success("import-validated");
return BundleValidationResult.Success("import-validated") with { ReferrerSummary = referrerSummary };
}
private async Task TryQuarantineAsync(
@@ -355,6 +397,35 @@ public sealed class ImportValidator
value = null;
return false;
}
private static bool IsBundleTypeWithReferrers(string bundleType)
{
// Only mirror bundles and offline kits containing mirror bundles support referrers
return bundleType.Equals("mirror-bundle", StringComparison.OrdinalIgnoreCase) ||
bundleType.Equals("offline-kit", StringComparison.OrdinalIgnoreCase);
}
private static string FormatReferrerErrors(ReferrerValidationSummary summary)
{
var parts = new List<string>(3);
if (summary.MissingReferrers > 0)
{
parts.Add($"missing={summary.MissingReferrers}");
}
if (summary.ChecksumMismatches > 0)
{
parts.Add($"checksum_mismatch={summary.ChecksumMismatches}");
}
if (summary.SizeMismatches > 0)
{
parts.Add($"size_mismatch={summary.SizeMismatches}");
}
return parts.Count > 0 ? string.Join(",", parts) : "unknown";
}
}
public sealed record ImportValidationRequest(

View File

@@ -0,0 +1,480 @@
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// Validates OCI referrer artifacts declared in a mirror bundle manifest.
/// </summary>
public sealed class ReferrerValidator
{
private readonly ILogger<ReferrerValidator> _logger;
public ReferrerValidator(ILogger<ReferrerValidator> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Validates referrer artifacts in a bundle against manifest declarations.
/// </summary>
/// <param name="manifestJson">The bundle manifest JSON containing referrers section.</param>
/// <param name="bundleEntries">Named streams of bundle entries for content validation.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Referrer validation summary with any issues found.</returns>
public ReferrerValidationSummary Validate(
string? manifestJson,
IReadOnlyList<NamedStream> bundleEntries,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(manifestJson))
{
return ReferrerValidationSummary.Empty();
}
var referrers = TryParseReferrersSection(manifestJson);
if (referrers is null || referrers.Count == 0)
{
// No referrers declared; check for orphans
var orphans = FindOrphanedReferrers(bundleEntries, new HashSet<string>(StringComparer.OrdinalIgnoreCase));
return new ReferrerValidationSummary
{
TotalSubjects = 0,
TotalReferrers = 0,
ValidReferrers = 0,
MissingReferrers = 0,
ChecksumMismatches = 0,
SizeMismatches = 0,
OrphanedReferrers = orphans.Count,
Issues = orphans
};
}
var issues = new List<ReferrerValidationIssue>();
var validCount = 0;
var missingCount = 0;
var checksumMismatchCount = 0;
var sizeMismatchCount = 0;
// Build lookup of bundle entries by path
var entryLookup = bundleEntries
.ToDictionary(e => NormalizePath(e.Path), e => e, StringComparer.OrdinalIgnoreCase);
// Track which paths we've validated (for orphan detection)
var validatedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var referrer in referrers)
{
cancellationToken.ThrowIfCancellationRequested();
var normalizedPath = NormalizePath(referrer.Path);
validatedPaths.Add(normalizedPath);
if (!entryLookup.TryGetValue(normalizedPath, out var entry))
{
missingCount++;
issues.Add(new ReferrerValidationIssue
{
IssueType = ReferrerValidationIssueType.ReferrerMissing,
Severity = ReferrerValidationSeverity.Error,
SubjectDigest = referrer.SubjectDigest,
ReferrerDigest = referrer.Digest,
ExpectedPath = referrer.Path,
Message = $"Declared referrer artifact not found in bundle: {referrer.Path}"
});
continue;
}
// Validate checksum
var actualChecksum = ComputeStreamChecksum(entry.Stream);
if (!string.Equals(actualChecksum, referrer.Sha256, StringComparison.OrdinalIgnoreCase))
{
checksumMismatchCount++;
issues.Add(new ReferrerValidationIssue
{
IssueType = ReferrerValidationIssueType.ReferrerChecksumMismatch,
Severity = ReferrerValidationSeverity.Error,
SubjectDigest = referrer.SubjectDigest,
ReferrerDigest = referrer.Digest,
ExpectedPath = referrer.Path,
ExpectedValue = referrer.Sha256,
ActualValue = actualChecksum,
Message = $"Referrer artifact checksum mismatch: expected {referrer.Sha256}, got {actualChecksum}"
});
continue;
}
// Validate size
var actualSize = GetStreamLength(entry.Stream);
if (referrer.Size > 0 && actualSize != referrer.Size)
{
sizeMismatchCount++;
issues.Add(new ReferrerValidationIssue
{
IssueType = ReferrerValidationIssueType.ReferrerSizeMismatch,
Severity = ReferrerValidationSeverity.Error,
SubjectDigest = referrer.SubjectDigest,
ReferrerDigest = referrer.Digest,
ExpectedPath = referrer.Path,
ExpectedValue = referrer.Size.ToString(),
ActualValue = actualSize.ToString(),
Message = $"Referrer artifact size mismatch: expected {referrer.Size} bytes, got {actualSize} bytes"
});
continue;
}
validCount++;
}
// Find orphaned referrer artifacts (files in referrers/ not declared in manifest)
var orphanedIssues = FindOrphanedReferrers(bundleEntries, validatedPaths);
issues.AddRange(orphanedIssues);
// Count unique subjects
var subjectCount = referrers.Select(r => r.SubjectDigest).Distinct(StringComparer.OrdinalIgnoreCase).Count();
_logger.LogInformation(
"Referrer validation completed: subjects={subjects} total={total} valid={valid} missing={missing} checksum_mismatch={checksum_mismatch} size_mismatch={size_mismatch} orphaned={orphaned}",
subjectCount,
referrers.Count,
validCount,
missingCount,
checksumMismatchCount,
sizeMismatchCount,
orphanedIssues.Count);
return new ReferrerValidationSummary
{
TotalSubjects = subjectCount,
TotalReferrers = referrers.Count,
ValidReferrers = validCount,
MissingReferrers = missingCount,
ChecksumMismatches = checksumMismatchCount,
SizeMismatches = sizeMismatchCount,
OrphanedReferrers = orphanedIssues.Count,
Issues = issues
};
}
/// <summary>
/// Checks if the validation summary represents a passing state.
/// Missing referrers and checksum/size mismatches are failures.
/// Orphaned referrers are warnings only.
/// </summary>
public static bool IsValid(ReferrerValidationSummary summary)
{
return summary.MissingReferrers == 0 &&
summary.ChecksumMismatches == 0 &&
summary.SizeMismatches == 0;
}
private static IReadOnlyList<ParsedReferrer>? TryParseReferrersSection(string manifestJson)
{
try
{
using var doc = JsonDocument.Parse(manifestJson);
// Look for referrers section (can be top-level or nested)
if (!doc.RootElement.TryGetProperty("referrers", out var referrersElement))
{
return null;
}
// Parse subjects array
if (!referrersElement.TryGetProperty("subjects", out var subjectsElement) ||
subjectsElement.ValueKind != JsonValueKind.Array)
{
return null;
}
var referrers = new List<ParsedReferrer>();
foreach (var subject in subjectsElement.EnumerateArray())
{
var subjectDigest = GetStringProperty(subject, "subject");
if (string.IsNullOrEmpty(subjectDigest))
{
continue;
}
if (!subject.TryGetProperty("artifacts", out var artifactsElement) ||
artifactsElement.ValueKind != JsonValueKind.Array)
{
continue;
}
foreach (var artifact in artifactsElement.EnumerateArray())
{
var digest = GetStringProperty(artifact, "digest");
var path = GetStringProperty(artifact, "path");
var sha256 = GetStringProperty(artifact, "sha256");
var size = GetLongProperty(artifact, "size");
var category = GetStringProperty(artifact, "category");
var artifactType = GetStringProperty(artifact, "artifactType");
if (string.IsNullOrEmpty(path))
{
continue;
}
referrers.Add(new ParsedReferrer(
SubjectDigest: subjectDigest,
Digest: digest ?? string.Empty,
Path: path,
Sha256: sha256 ?? string.Empty,
Size: size,
Category: category ?? string.Empty,
ArtifactType: artifactType));
}
}
return referrers;
}
catch (JsonException)
{
return null;
}
}
private static List<ReferrerValidationIssue> FindOrphanedReferrers(
IReadOnlyList<NamedStream> bundleEntries,
HashSet<string> validatedPaths)
{
var orphans = new List<ReferrerValidationIssue>();
foreach (var entry in bundleEntries)
{
var normalizedPath = NormalizePath(entry.Path);
// Check if this is a referrer artifact (under referrers/ directory)
if (!normalizedPath.StartsWith("referrers/", StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Skip if already validated
if (validatedPaths.Contains(normalizedPath))
{
continue;
}
orphans.Add(new ReferrerValidationIssue
{
IssueType = ReferrerValidationIssueType.OrphanedReferrer,
Severity = ReferrerValidationSeverity.Warning,
ExpectedPath = entry.Path,
Message = $"Referrer artifact exists but is not declared in manifest: {entry.Path}"
});
}
return orphans;
}
private static string NormalizePath(string path)
{
return path.Replace('\\', '/').TrimStart('/');
}
private static string ComputeStreamChecksum(Stream stream)
{
var canSeek = stream.CanSeek;
var originalPosition = canSeek ? stream.Position : 0;
if (canSeek)
{
stream.Seek(0, SeekOrigin.Begin);
}
var hash = SHA256.HashData(stream);
if (canSeek)
{
stream.Seek(originalPosition, SeekOrigin.Begin);
}
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static long GetStreamLength(Stream stream)
{
if (stream.CanSeek)
{
return stream.Length;
}
// For non-seekable streams, we already computed the hash so position is at end
return stream.Position;
}
private static string? GetStringProperty(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String)
{
return prop.GetString();
}
return null;
}
private static long GetLongProperty(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.Number)
{
return prop.GetInt64();
}
return 0;
}
private sealed record ParsedReferrer(
string SubjectDigest,
string Digest,
string Path,
string Sha256,
long Size,
string Category,
string? ArtifactType);
}
/// <summary>
/// Summary of referrer validation results.
/// </summary>
public sealed record ReferrerValidationSummary
{
/// <summary>
/// Number of unique subject images with declared referrers.
/// </summary>
public int TotalSubjects { get; init; }
/// <summary>
/// Total number of declared referrer artifacts.
/// </summary>
public int TotalReferrers { get; init; }
/// <summary>
/// Number of referrers that passed validation.
/// </summary>
public int ValidReferrers { get; init; }
/// <summary>
/// Number of declared referrers not found in bundle.
/// </summary>
public int MissingReferrers { get; init; }
/// <summary>
/// Number of referrers with checksum mismatches.
/// </summary>
public int ChecksumMismatches { get; init; }
/// <summary>
/// Number of referrers with size mismatches.
/// </summary>
public int SizeMismatches { get; init; }
/// <summary>
/// Number of undeclared referrer artifacts found in bundle.
/// </summary>
public int OrphanedReferrers { get; init; }
/// <summary>
/// Detailed list of validation issues.
/// </summary>
public IReadOnlyList<ReferrerValidationIssue> Issues { get; init; } = [];
/// <summary>
/// Creates an empty summary when no referrers are present.
/// </summary>
public static ReferrerValidationSummary Empty() => new();
/// <summary>
/// Whether the validation passed (no errors, warnings are allowed).
/// </summary>
public bool IsValid => MissingReferrers == 0 && ChecksumMismatches == 0 && SizeMismatches == 0;
}
/// <summary>
/// A specific validation issue found during referrer validation.
/// </summary>
public sealed record ReferrerValidationIssue
{
/// <summary>
/// Type of validation issue.
/// </summary>
public required ReferrerValidationIssueType IssueType { get; init; }
/// <summary>
/// Severity of the issue.
/// </summary>
public required ReferrerValidationSeverity Severity { get; init; }
/// <summary>
/// Subject image digest (if applicable).
/// </summary>
public string? SubjectDigest { get; init; }
/// <summary>
/// Referrer artifact digest (if applicable).
/// </summary>
public string? ReferrerDigest { get; init; }
/// <summary>
/// Expected path in the bundle.
/// </summary>
public string? ExpectedPath { get; init; }
/// <summary>
/// Expected value (for mismatch issues).
/// </summary>
public string? ExpectedValue { get; init; }
/// <summary>
/// Actual value found (for mismatch issues).
/// </summary>
public string? ActualValue { get; init; }
/// <summary>
/// Human-readable description of the issue.
/// </summary>
public required string Message { get; init; }
}
/// <summary>
/// Types of referrer validation issues.
/// </summary>
public enum ReferrerValidationIssueType
{
/// <summary>
/// Declared referrer artifact not found in bundle.
/// </summary>
ReferrerMissing = 1,
/// <summary>
/// Referrer artifact checksum doesn't match declared value.
/// </summary>
ReferrerChecksumMismatch = 2,
/// <summary>
/// Referrer artifact size doesn't match declared value.
/// </summary>
ReferrerSizeMismatch = 3,
/// <summary>
/// Artifact found in referrers/ directory but not declared in manifest.
/// </summary>
OrphanedReferrer = 4
}
/// <summary>
/// Severity levels for referrer validation issues.
/// </summary>
public enum ReferrerValidationSeverity
{
/// <summary>
/// Warning - does not fail validation.
/// </summary>
Warning = 1,
/// <summary>
/// Error - fails validation.
/// </summary>
Error = 2
}

View File

@@ -231,4 +231,257 @@ public sealed class ImportValidatorTests
public Task<int> CleanupExpiredAsync(TimeSpan retentionPeriod, CancellationToken cancellationToken = default) =>
Task.FromResult(0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateAsync_WithReferrerValidator_MissingReferrer_ShouldFailAndQuarantine()
{
// Arrange
var root = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\"}";
var snapshot = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\",\"meta\":{\"snapshot\":{\"hashes\":{\"sha256\":\"abc\"}}}}";
var timestamp = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\",\"snapshot\":{\"meta\":{\"hashes\":{\"sha256\":\"abc\"}}}}";
using var rsa = RSA.Create(2048);
var pub = rsa.ExportSubjectPublicKeyInfo();
var payload = "bundle-body";
var payloadType = "application/vnd.stella.bundle";
var pae = BuildPae(payloadType, payload);
var sig = rsa.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
var envelope = new DsseEnvelope(payloadType, Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload)), new[]
{
new DsseSignature("k1", Convert.ToBase64String(sig))
});
var trustStore = new TrustStore();
trustStore.LoadActive(new Dictionary<string, byte[]> { ["k1"] = pub });
trustStore.StagePending(new Dictionary<string, byte[]> { ["k2"] = pub });
var quarantine = new CapturingQuarantineService();
var monotonicity = new CapturingMonotonicityChecker();
var referrerValidator = new ReferrerValidator(NullLogger<ReferrerValidator>.Instance);
var validator = new ImportValidator(
new DsseVerifier(),
new TufMetadataValidator(),
new MerkleRootCalculator(),
new RootRotationPolicy(),
monotonicity,
quarantine,
NullLogger<ImportValidator>.Instance,
referrerValidator);
// Manifest with referrer that doesn't exist in entries
var manifestJson = """
{
"version": "1.0.0",
"merkleRoot": "dummy",
"referrers": {
"subjects": [
{
"subject": "sha256:abc123",
"artifacts": [
{
"digest": "sha256:ref001",
"path": "referrers/sha256-abc123/sha256-ref001.json",
"sha256": "abcd1234",
"size": 100,
"category": "sbom"
}
]
}
]
}
}
""";
var payloadEntries = new List<NamedStream> { new("a.txt", new MemoryStream("data"u8.ToArray())) };
var merkleRoot = new MerkleRootCalculator().ComputeRoot(payloadEntries);
manifestJson = manifestJson.Replace("\"merkleRoot\": \"dummy\"", $"\"merkleRoot\": \"{merkleRoot}\"");
var tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-airgap-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
var bundlePath = Path.Combine(tempRoot, "bundle.tar.zst");
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
try
{
var request = new ImportValidationRequest(
TenantId: "tenant-a",
BundleType: "mirror-bundle",
BundleDigest: "sha256:bundle",
BundlePath: bundlePath,
ManifestJson: manifestJson,
ManifestVersion: "1.0.0",
ManifestCreatedAt: DateTimeOffset.Parse("2025-12-15T00:00:00Z"),
ForceActivate: false,
ForceActivateReason: null,
Envelope: envelope,
TrustRoots: new TrustRootConfig("/tmp/root.json", new[] { Fingerprint(pub) }, new[] { "rsassa-pss-sha256" }, null, null, new Dictionary<string, byte[]> { ["k1"] = pub }),
RootJson: root,
SnapshotJson: snapshot,
TimestampJson: timestamp,
PayloadEntries: payloadEntries,
TrustStore: trustStore,
ApproverIds: new[] { "approver-1", "approver-2" });
// Act
var result = await validator.ValidateAsync(request);
// Assert
result.IsValid.Should().BeFalse();
result.Reason.Should().Contain("referrer-validation-failed");
result.ReferrerSummary.Should().NotBeNull();
result.ReferrerSummary!.MissingReferrers.Should().Be(1);
quarantine.Requests.Should().HaveCount(1);
}
finally
{
try
{
Directory.Delete(tempRoot, recursive: true);
}
catch
{
// best-effort cleanup
}
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateAsync_WithReferrerValidator_AllReferrersPresent_ShouldSucceed()
{
// Arrange
var root = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\"}";
var snapshot = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\",\"meta\":{\"snapshot\":{\"hashes\":{\"sha256\":\"abc\"}}}}";
var timestamp = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\",\"snapshot\":{\"meta\":{\"hashes\":{\"sha256\":\"abc\"}}}}";
using var rsa = RSA.Create(2048);
var pub = rsa.ExportSubjectPublicKeyInfo();
var payload = "bundle-body";
var payloadType = "application/vnd.stella.bundle";
var pae = BuildPae(payloadType, payload);
var sig = rsa.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
var envelope = new DsseEnvelope(payloadType, Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload)), new[]
{
new DsseSignature("k1", Convert.ToBase64String(sig))
});
var trustStore = new TrustStore();
trustStore.LoadActive(new Dictionary<string, byte[]> { ["k1"] = pub });
trustStore.StagePending(new Dictionary<string, byte[]> { ["k2"] = pub });
var quarantine = new CapturingQuarantineService();
var monotonicity = new CapturingMonotonicityChecker();
var referrerValidator = new ReferrerValidator(NullLogger<ReferrerValidator>.Instance);
var validator = new ImportValidator(
new DsseVerifier(),
new TufMetadataValidator(),
new MerkleRootCalculator(),
new RootRotationPolicy(),
monotonicity,
quarantine,
NullLogger<ImportValidator>.Instance,
referrerValidator);
// Create referrer content and compute its hash
var referrerContent = "{\"sbom\":\"content\"}"u8.ToArray();
var referrerSha256 = Convert.ToHexString(SHA256.HashData(referrerContent)).ToLowerInvariant();
// Manifest with referrer that exists in entries
var manifestJsonTemplate = """
{
"version": "1.0.0",
"merkleRoot": "MERKLE_PLACEHOLDER",
"referrers": {
"subjects": [
{
"subject": "sha256:abc123",
"artifacts": [
{
"digest": "sha256:ref001",
"path": "referrers/sha256-abc123/sha256-ref001.json",
"sha256": "CHECKSUM_PLACEHOLDER",
"size": SIZE_PLACEHOLDER,
"category": "sbom"
}
]
}
]
}
}
""";
var payloadEntries = new List<NamedStream>
{
new("a.txt", new MemoryStream("data"u8.ToArray())),
new("referrers/sha256-abc123/sha256-ref001.json", new MemoryStream(referrerContent))
};
var merkleRoot = new MerkleRootCalculator().ComputeRoot(payloadEntries);
var manifestJson = manifestJsonTemplate
.Replace("MERKLE_PLACEHOLDER", merkleRoot)
.Replace("CHECKSUM_PLACEHOLDER", referrerSha256)
.Replace("SIZE_PLACEHOLDER", referrerContent.Length.ToString());
var tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-airgap-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
var bundlePath = Path.Combine(tempRoot, "bundle.tar.zst");
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
try
{
// Reset streams for re-reading
foreach (var entry in payloadEntries)
{
entry.Stream.Seek(0, SeekOrigin.Begin);
}
var request = new ImportValidationRequest(
TenantId: "tenant-a",
BundleType: "mirror-bundle",
BundleDigest: "sha256:bundle",
BundlePath: bundlePath,
ManifestJson: manifestJson,
ManifestVersion: "1.0.0",
ManifestCreatedAt: DateTimeOffset.Parse("2025-12-15T00:00:00Z"),
ForceActivate: false,
ForceActivateReason: null,
Envelope: envelope,
TrustRoots: new TrustRootConfig("/tmp/root.json", new[] { Fingerprint(pub) }, new[] { "rsassa-pss-sha256" }, null, null, new Dictionary<string, byte[]> { ["k1"] = pub }),
RootJson: root,
SnapshotJson: snapshot,
TimestampJson: timestamp,
PayloadEntries: payloadEntries,
TrustStore: trustStore,
ApproverIds: new[] { "approver-1", "approver-2" });
// Act
var result = await validator.ValidateAsync(request);
// Assert
result.IsValid.Should().BeTrue();
result.ReferrerSummary.Should().NotBeNull();
result.ReferrerSummary!.TotalReferrers.Should().Be(1);
result.ReferrerSummary.ValidReferrers.Should().Be(1);
result.ReferrerSummary.MissingReferrers.Should().Be(0);
quarantine.Requests.Should().BeEmpty();
}
finally
{
try
{
Directory.Delete(tempRoot, recursive: true);
}
catch
{
// best-effort cleanup
}
}
}
}

View File

@@ -0,0 +1,599 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.AirGap.Importer.Validation;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Importer.Tests.Validation;
public sealed class ReferrerValidatorTests
{
private readonly ReferrerValidator _validator;
public ReferrerValidatorTests()
{
_validator = new ReferrerValidator(NullLogger<ReferrerValidator>.Instance);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_NullManifest_ReturnsEmptySummary()
{
// Act
var result = _validator.Validate(null, []);
// Assert
result.Should().NotBeNull();
result.TotalSubjects.Should().Be(0);
result.TotalReferrers.Should().Be(0);
result.IsValid.Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_EmptyManifest_ReturnsEmptySummary()
{
// Act
var result = _validator.Validate("", []);
// Assert
result.Should().NotBeNull();
result.TotalSubjects.Should().Be(0);
result.TotalReferrers.Should().Be(0);
result.IsValid.Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_ManifestWithoutReferrers_ReturnsEmptySummary()
{
// Arrange
var manifest = """{"version":"1.0.0","counts":{"advisories":5}}""";
// Act
var result = _validator.Validate(manifest, []);
// Assert
result.Should().NotBeNull();
result.TotalSubjects.Should().Be(0);
result.TotalReferrers.Should().Be(0);
result.IsValid.Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_AllReferrersPresent_ReturnsValid()
{
// Arrange
var content = "test content for referrer"u8.ToArray();
var sha256 = ComputeSha256(content);
var manifest = $$"""
{
"referrers": {
"subjects": [
{
"subject": "sha256:abc123",
"artifacts": [
{
"digest": "sha256:ref001",
"path": "referrers/sha256-abc123/sha256-ref001.json",
"sha256": "{{sha256}}",
"size": {{content.Length}},
"category": "sbom"
}
]
}
]
}
}
""";
var entries = new List<NamedStream>
{
new("referrers/sha256-abc123/sha256-ref001.json", new MemoryStream(content))
};
// Act
var result = _validator.Validate(manifest, entries);
// Assert
result.IsValid.Should().BeTrue();
result.TotalSubjects.Should().Be(1);
result.TotalReferrers.Should().Be(1);
result.ValidReferrers.Should().Be(1);
result.MissingReferrers.Should().Be(0);
result.ChecksumMismatches.Should().Be(0);
result.SizeMismatches.Should().Be(0);
result.Issues.Should().BeEmpty();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_MissingReferrer_ReturnsInvalidWithIssue()
{
// Arrange
var manifest = """
{
"referrers": {
"subjects": [
{
"subject": "sha256:abc123",
"artifacts": [
{
"digest": "sha256:ref001",
"path": "referrers/sha256-abc123/sha256-ref001.json",
"sha256": "abcd1234",
"size": 100,
"category": "sbom"
}
]
}
]
}
}
""";
// Act - no entries provided, so referrer is missing
var result = _validator.Validate(manifest, []);
// Assert
result.IsValid.Should().BeFalse();
result.MissingReferrers.Should().Be(1);
result.Issues.Should().HaveCount(1);
result.Issues[0].IssueType.Should().Be(ReferrerValidationIssueType.ReferrerMissing);
result.Issues[0].Severity.Should().Be(ReferrerValidationSeverity.Error);
result.Issues[0].SubjectDigest.Should().Be("sha256:abc123");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_ChecksumMismatch_ReturnsInvalidWithIssue()
{
// Arrange
var content = "test content"u8.ToArray();
var wrongChecksum = "0000000000000000000000000000000000000000000000000000000000000000";
var manifest = $$"""
{
"referrers": {
"subjects": [
{
"subject": "sha256:abc123",
"artifacts": [
{
"digest": "sha256:ref001",
"path": "referrers/sha256-abc123/sha256-ref001.json",
"sha256": "{{wrongChecksum}}",
"size": {{content.Length}},
"category": "sbom"
}
]
}
]
}
}
""";
var entries = new List<NamedStream>
{
new("referrers/sha256-abc123/sha256-ref001.json", new MemoryStream(content))
};
// Act
var result = _validator.Validate(manifest, entries);
// Assert
result.IsValid.Should().BeFalse();
result.ChecksumMismatches.Should().Be(1);
result.Issues.Should().HaveCount(1);
result.Issues[0].IssueType.Should().Be(ReferrerValidationIssueType.ReferrerChecksumMismatch);
result.Issues[0].Severity.Should().Be(ReferrerValidationSeverity.Error);
result.Issues[0].ExpectedValue.Should().Be(wrongChecksum);
result.Issues[0].ActualValue.Should().NotBe(wrongChecksum);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_SizeMismatch_ReturnsInvalidWithIssue()
{
// Arrange
var content = "test content"u8.ToArray();
var sha256 = ComputeSha256(content);
var wrongSize = content.Length + 100;
var manifest = $$"""
{
"referrers": {
"subjects": [
{
"subject": "sha256:abc123",
"artifacts": [
{
"digest": "sha256:ref001",
"path": "referrers/sha256-abc123/sha256-ref001.json",
"sha256": "{{sha256}}",
"size": {{wrongSize}},
"category": "sbom"
}
]
}
]
}
}
""";
var entries = new List<NamedStream>
{
new("referrers/sha256-abc123/sha256-ref001.json", new MemoryStream(content))
};
// Act
var result = _validator.Validate(manifest, entries);
// Assert
result.IsValid.Should().BeFalse();
result.SizeMismatches.Should().Be(1);
result.Issues.Should().HaveCount(1);
result.Issues[0].IssueType.Should().Be(ReferrerValidationIssueType.ReferrerSizeMismatch);
result.Issues[0].Severity.Should().Be(ReferrerValidationSeverity.Error);
result.Issues[0].ExpectedValue.Should().Be(wrongSize.ToString());
result.Issues[0].ActualValue.Should().Be(content.Length.ToString());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_OrphanedReferrer_ReturnsValidWithWarning()
{
// Arrange - manifest has no referrers but bundle has referrer files
var manifest = """{"version":"1.0.0"}""";
var content = "orphaned content"u8.ToArray();
var entries = new List<NamedStream>
{
new("referrers/sha256-abc123/sha256-orphan.json", new MemoryStream(content))
};
// Act
var result = _validator.Validate(manifest, entries);
// Assert
result.IsValid.Should().BeTrue(); // Orphans are warnings, not errors
result.OrphanedReferrers.Should().Be(1);
result.Issues.Should().HaveCount(1);
result.Issues[0].IssueType.Should().Be(ReferrerValidationIssueType.OrphanedReferrer);
result.Issues[0].Severity.Should().Be(ReferrerValidationSeverity.Warning);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_MultipleSubjectsAndArtifacts_ValidatesAll()
{
// Arrange
var content1 = "content for subject 1 artifact 1"u8.ToArray();
var content2 = "content for subject 1 artifact 2"u8.ToArray();
var content3 = "content for subject 2 artifact 1"u8.ToArray();
var sha256_1 = ComputeSha256(content1);
var sha256_2 = ComputeSha256(content2);
var sha256_3 = ComputeSha256(content3);
var manifest = $$"""
{
"referrers": {
"subjects": [
{
"subject": "sha256:subject1",
"artifacts": [
{
"digest": "sha256:ref001",
"path": "referrers/sha256-subject1/sha256-ref001.json",
"sha256": "{{sha256_1}}",
"size": {{content1.Length}},
"category": "sbom"
},
{
"digest": "sha256:ref002",
"path": "referrers/sha256-subject1/sha256-ref002.json",
"sha256": "{{sha256_2}}",
"size": {{content2.Length}},
"category": "attestation"
}
]
},
{
"subject": "sha256:subject2",
"artifacts": [
{
"digest": "sha256:ref003",
"path": "referrers/sha256-subject2/sha256-ref003.json",
"sha256": "{{sha256_3}}",
"size": {{content3.Length}},
"category": "vex"
}
]
}
]
}
}
""";
var entries = new List<NamedStream>
{
new("referrers/sha256-subject1/sha256-ref001.json", new MemoryStream(content1)),
new("referrers/sha256-subject1/sha256-ref002.json", new MemoryStream(content2)),
new("referrers/sha256-subject2/sha256-ref003.json", new MemoryStream(content3))
};
// Act
var result = _validator.Validate(manifest, entries);
// Assert
result.IsValid.Should().BeTrue();
result.TotalSubjects.Should().Be(2);
result.TotalReferrers.Should().Be(3);
result.ValidReferrers.Should().Be(3);
result.Issues.Should().BeEmpty();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_MixedErrors_ReportsAllIssues()
{
// Arrange
var validContent = "valid content"u8.ToArray();
var validSha256 = ComputeSha256(validContent);
var wrongChecksum = "0000000000000000000000000000000000000000000000000000000000000000";
var manifest = $$"""
{
"referrers": {
"subjects": [
{
"subject": "sha256:subject1",
"artifacts": [
{
"digest": "sha256:valid",
"path": "referrers/sha256-subject1/sha256-valid.json",
"sha256": "{{validSha256}}",
"size": {{validContent.Length}},
"category": "sbom"
},
{
"digest": "sha256:missing",
"path": "referrers/sha256-subject1/sha256-missing.json",
"sha256": "abcd1234",
"size": 100,
"category": "attestation"
},
{
"digest": "sha256:badchecksum",
"path": "referrers/sha256-subject1/sha256-badchecksum.json",
"sha256": "{{wrongChecksum}}",
"size": {{validContent.Length}},
"category": "vex"
}
]
}
]
}
}
""";
var entries = new List<NamedStream>
{
new("referrers/sha256-subject1/sha256-valid.json", new MemoryStream(validContent)),
new("referrers/sha256-subject1/sha256-badchecksum.json", new MemoryStream(validContent)),
new("referrers/sha256-subject1/sha256-orphan.json", new MemoryStream(validContent))
};
// Act
var result = _validator.Validate(manifest, entries);
// Assert
result.IsValid.Should().BeFalse();
result.ValidReferrers.Should().Be(1);
result.MissingReferrers.Should().Be(1);
result.ChecksumMismatches.Should().Be(1);
result.OrphanedReferrers.Should().Be(1);
result.Issues.Should().HaveCount(3); // missing, checksum mismatch, orphan
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_PathNormalization_HandlesBackslashes()
{
// Arrange
var content = "test content"u8.ToArray();
var sha256 = ComputeSha256(content);
var manifest = $$"""
{
"referrers": {
"subjects": [
{
"subject": "sha256:abc123",
"artifacts": [
{
"digest": "sha256:ref001",
"path": "referrers\\sha256-abc123\\sha256-ref001.json",
"sha256": "{{sha256}}",
"size": {{content.Length}},
"category": "sbom"
}
]
}
]
}
}
""";
var entries = new List<NamedStream>
{
new("referrers/sha256-abc123/sha256-ref001.json", new MemoryStream(content))
};
// Act
var result = _validator.Validate(manifest, entries);
// Assert
result.IsValid.Should().BeTrue();
result.ValidReferrers.Should().Be(1);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_CaseInsensitivePaths_MatchesCorrectly()
{
// Arrange
var content = "test content"u8.ToArray();
var sha256 = ComputeSha256(content);
var manifest = $$"""
{
"referrers": {
"subjects": [
{
"subject": "sha256:abc123",
"artifacts": [
{
"digest": "sha256:ref001",
"path": "REFERRERS/SHA256-ABC123/SHA256-REF001.JSON",
"sha256": "{{sha256}}",
"size": {{content.Length}},
"category": "sbom"
}
]
}
]
}
}
""";
var entries = new List<NamedStream>
{
new("referrers/sha256-abc123/sha256-ref001.json", new MemoryStream(content))
};
// Act
var result = _validator.Validate(manifest, entries);
// Assert
result.IsValid.Should().BeTrue();
result.ValidReferrers.Should().Be(1);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_ZeroSizeInManifest_SkipsSizeValidation()
{
// Arrange - when size is 0 or not specified, size validation is skipped
var content = "test content"u8.ToArray();
var sha256 = ComputeSha256(content);
var manifest = $$"""
{
"referrers": {
"subjects": [
{
"subject": "sha256:abc123",
"artifacts": [
{
"digest": "sha256:ref001",
"path": "referrers/sha256-abc123/sha256-ref001.json",
"sha256": "{{sha256}}",
"size": 0,
"category": "sbom"
}
]
}
]
}
}
""";
var entries = new List<NamedStream>
{
new("referrers/sha256-abc123/sha256-ref001.json", new MemoryStream(content))
};
// Act
var result = _validator.Validate(manifest, entries);
// Assert
result.IsValid.Should().BeTrue();
result.SizeMismatches.Should().Be(0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_InvalidJson_ReturnsEmptySummary()
{
// Arrange
var manifest = "this is not valid json {{{";
// Act
var result = _validator.Validate(manifest, []);
// Assert
result.IsValid.Should().BeTrue();
result.TotalReferrers.Should().Be(0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_NonReferrerFiles_NotReportedAsOrphans()
{
// Arrange
var manifest = """{"version":"1.0.0"}""";
var content = "some content"u8.ToArray();
var entries = new List<NamedStream>
{
new("advisories/adv-001.json", new MemoryStream(content)),
new("sboms/sbom-001.json", new MemoryStream(content)),
new("manifest.yaml", new MemoryStream(content))
};
// Act
var result = _validator.Validate(manifest, entries);
// Assert
result.IsValid.Should().BeTrue();
result.OrphanedReferrers.Should().Be(0);
result.Issues.Should().BeEmpty();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void IsValid_StaticMethod_ChecksCorrectly()
{
// Valid summary
var valid = new ReferrerValidationSummary
{
TotalReferrers = 5,
ValidReferrers = 5,
MissingReferrers = 0,
ChecksumMismatches = 0,
SizeMismatches = 0,
OrphanedReferrers = 2 // Warnings are OK
};
ReferrerValidator.IsValid(valid).Should().BeTrue();
// Invalid - missing
var missing = valid with { MissingReferrers = 1 };
ReferrerValidator.IsValid(missing).Should().BeFalse();
// Invalid - checksum
var checksum = valid with { ChecksumMismatches = 1 };
ReferrerValidator.IsValid(checksum).Should().BeFalse();
// Invalid - size
var size = valid with { SizeMismatches = 1 };
ReferrerValidator.IsValid(size).Should().BeFalse();
}
private static string ComputeSha256(byte[] data)
{
var hash = System.Security.Cryptography.SHA256.HashData(data);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}