test fixes and new product advisories work
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user