Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Manifest for an offline bundle, inventorying all components with content digests.
|
||||
/// Used for integrity verification and completeness checking in air-gapped environments.
|
||||
/// </summary>
|
||||
public sealed record BundleManifest
|
||||
{
|
||||
public required string BundleId { get; init; }
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public required ImmutableArray<FeedComponent> Feeds { get; init; }
|
||||
public required ImmutableArray<PolicyComponent> Policies { get; init; }
|
||||
public required ImmutableArray<CryptoComponent> CryptoMaterials { get; init; }
|
||||
public ImmutableArray<CatalogComponent> Catalogs { get; init; } = [];
|
||||
public RekorSnapshot? RekorSnapshot { get; init; }
|
||||
public ImmutableArray<CryptoProviderComponent> CryptoProviders { get; init; } = [];
|
||||
public long TotalSizeBytes { get; init; }
|
||||
public string? BundleDigest { get; init; }
|
||||
}
|
||||
|
||||
public sealed record FeedComponent(
|
||||
string FeedId,
|
||||
string Name,
|
||||
string Version,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
DateTimeOffset SnapshotAt,
|
||||
FeedFormat Format);
|
||||
|
||||
public enum FeedFormat
|
||||
{
|
||||
StellaOpsNative,
|
||||
TrivyDb,
|
||||
GrypeDb,
|
||||
OsvJson
|
||||
}
|
||||
|
||||
public sealed record PolicyComponent(
|
||||
string PolicyId,
|
||||
string Name,
|
||||
string Version,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
PolicyType Type);
|
||||
|
||||
public enum PolicyType
|
||||
{
|
||||
OpaRego,
|
||||
LatticeRules,
|
||||
UnknownBudgets,
|
||||
ScoringWeights
|
||||
}
|
||||
|
||||
public sealed record CryptoComponent(
|
||||
string ComponentId,
|
||||
string Name,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
CryptoComponentType Type,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
|
||||
public enum CryptoComponentType
|
||||
{
|
||||
TrustRoot,
|
||||
IntermediateCa,
|
||||
TimestampRoot,
|
||||
SigningKey,
|
||||
FulcioRoot
|
||||
}
|
||||
|
||||
public sealed record CatalogComponent(
|
||||
string CatalogId,
|
||||
string Ecosystem,
|
||||
string Version,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
DateTimeOffset SnapshotAt);
|
||||
|
||||
public sealed record RekorSnapshot(
|
||||
string TreeId,
|
||||
long TreeSize,
|
||||
string RootHash,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
DateTimeOffset SnapshotAt);
|
||||
|
||||
public sealed record CryptoProviderComponent(
|
||||
string ProviderId,
|
||||
string Name,
|
||||
string Version,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
ImmutableArray<string> SupportedAlgorithms);
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://stellaops.io/schemas/offline-bundle/v1",
|
||||
"title": "StellaOps Offline Bundle Manifest",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"bundleId",
|
||||
"schemaVersion",
|
||||
"name",
|
||||
"version",
|
||||
"createdAt",
|
||||
"feeds",
|
||||
"policies",
|
||||
"cryptoMaterials",
|
||||
"totalSizeBytes"
|
||||
],
|
||||
"properties": {
|
||||
"bundleId": { "type": "string" },
|
||||
"schemaVersion": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"version": { "type": "string" },
|
||||
"createdAt": { "type": "string", "format": "date-time" },
|
||||
"expiresAt": { "type": ["string", "null"], "format": "date-time" },
|
||||
"feeds": { "type": "array", "items": { "$ref": "#/$defs/feed" } },
|
||||
"policies": { "type": "array", "items": { "$ref": "#/$defs/policy" } },
|
||||
"cryptoMaterials": { "type": "array", "items": { "$ref": "#/$defs/crypto" } },
|
||||
"catalogs": { "type": "array", "items": { "$ref": "#/$defs/catalog" } },
|
||||
"rekorSnapshot": { "$ref": "#/$defs/rekorSnapshot" },
|
||||
"cryptoProviders": { "type": "array", "items": { "$ref": "#/$defs/cryptoProvider" } },
|
||||
"totalSizeBytes": { "type": "integer" },
|
||||
"bundleDigest": { "type": ["string", "null"] }
|
||||
},
|
||||
"$defs": {
|
||||
"feed": {
|
||||
"type": "object",
|
||||
"required": ["feedId", "name", "version", "relativePath", "digest", "sizeBytes", "snapshotAt", "format"],
|
||||
"properties": {
|
||||
"feedId": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"version": { "type": "string" },
|
||||
"relativePath": { "type": "string" },
|
||||
"digest": { "type": "string" },
|
||||
"sizeBytes": { "type": "integer" },
|
||||
"snapshotAt": { "type": "string", "format": "date-time" },
|
||||
"format": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"policy": {
|
||||
"type": "object",
|
||||
"required": ["policyId", "name", "version", "relativePath", "digest", "sizeBytes", "type"],
|
||||
"properties": {
|
||||
"policyId": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"version": { "type": "string" },
|
||||
"relativePath": { "type": "string" },
|
||||
"digest": { "type": "string" },
|
||||
"sizeBytes": { "type": "integer" },
|
||||
"type": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"crypto": {
|
||||
"type": "object",
|
||||
"required": ["componentId", "name", "relativePath", "digest", "sizeBytes", "type"],
|
||||
"properties": {
|
||||
"componentId": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"relativePath": { "type": "string" },
|
||||
"digest": { "type": "string" },
|
||||
"sizeBytes": { "type": "integer" },
|
||||
"type": { "type": "string" },
|
||||
"expiresAt": { "type": ["string", "null"], "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"catalog": {
|
||||
"type": "object",
|
||||
"required": ["catalogId", "ecosystem", "version", "relativePath", "digest", "sizeBytes", "snapshotAt"],
|
||||
"properties": {
|
||||
"catalogId": { "type": "string" },
|
||||
"ecosystem": { "type": "string" },
|
||||
"version": { "type": "string" },
|
||||
"relativePath": { "type": "string" },
|
||||
"digest": { "type": "string" },
|
||||
"sizeBytes": { "type": "integer" },
|
||||
"snapshotAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"rekorSnapshot": {
|
||||
"type": ["object", "null"],
|
||||
"properties": {
|
||||
"treeId": { "type": "string" },
|
||||
"treeSize": { "type": "integer" },
|
||||
"rootHash": { "type": "string" },
|
||||
"relativePath": { "type": "string" },
|
||||
"digest": { "type": "string" },
|
||||
"snapshotAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"cryptoProvider": {
|
||||
"type": "object",
|
||||
"required": ["providerId", "name", "version", "relativePath", "digest", "sizeBytes", "supportedAlgorithms"],
|
||||
"properties": {
|
||||
"providerId": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"version": { "type": "string" },
|
||||
"relativePath": { "type": "string" },
|
||||
"digest": { "type": "string" },
|
||||
"sizeBytes": { "type": "integer" },
|
||||
"supportedAlgorithms": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.Canonical.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical serialization for bundle manifests.
|
||||
/// </summary>
|
||||
public static class BundleManifestSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
public static string Serialize(BundleManifest manifest)
|
||||
{
|
||||
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, JsonOptions);
|
||||
var canonicalBytes = CanonJson.CanonicalizeParsedJson(jsonBytes);
|
||||
return Encoding.UTF8.GetString(canonicalBytes);
|
||||
}
|
||||
|
||||
public static BundleManifest Deserialize(string json)
|
||||
{
|
||||
return JsonSerializer.Deserialize<BundleManifest>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to deserialize bundle manifest");
|
||||
}
|
||||
|
||||
public static string ComputeDigest(BundleManifest manifest)
|
||||
{
|
||||
var withoutDigest = manifest with { BundleDigest = null };
|
||||
var json = Serialize(withoutDigest);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static BundleManifest WithDigest(BundleManifest manifest)
|
||||
=> manifest with { BundleDigest = ComputeDigest(manifest) };
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed class BundleBuilder : IBundleBuilder
|
||||
{
|
||||
public async Task<BundleManifest> BuildAsync(
|
||||
BundleBuildRequest request,
|
||||
string outputPath,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Directory.CreateDirectory(outputPath);
|
||||
|
||||
var feeds = new List<FeedComponent>();
|
||||
var policies = new List<PolicyComponent>();
|
||||
var cryptoMaterials = new List<CryptoComponent>();
|
||||
|
||||
foreach (var feedConfig in request.Feeds)
|
||||
{
|
||||
var component = await CopyComponentAsync(feedConfig, outputPath, ct).ConfigureAwait(false);
|
||||
feeds.Add(new FeedComponent(
|
||||
feedConfig.FeedId,
|
||||
feedConfig.Name,
|
||||
feedConfig.Version,
|
||||
component.RelativePath,
|
||||
component.Digest,
|
||||
component.SizeBytes,
|
||||
feedConfig.SnapshotAt,
|
||||
feedConfig.Format));
|
||||
}
|
||||
|
||||
foreach (var policyConfig in request.Policies)
|
||||
{
|
||||
var component = await CopyComponentAsync(policyConfig, outputPath, ct).ConfigureAwait(false);
|
||||
policies.Add(new PolicyComponent(
|
||||
policyConfig.PolicyId,
|
||||
policyConfig.Name,
|
||||
policyConfig.Version,
|
||||
component.RelativePath,
|
||||
component.Digest,
|
||||
component.SizeBytes,
|
||||
policyConfig.Type));
|
||||
}
|
||||
|
||||
foreach (var cryptoConfig in request.CryptoMaterials)
|
||||
{
|
||||
var component = await CopyComponentAsync(cryptoConfig, outputPath, ct).ConfigureAwait(false);
|
||||
cryptoMaterials.Add(new CryptoComponent(
|
||||
cryptoConfig.ComponentId,
|
||||
cryptoConfig.Name,
|
||||
component.RelativePath,
|
||||
component.Digest,
|
||||
component.SizeBytes,
|
||||
cryptoConfig.Type,
|
||||
cryptoConfig.ExpiresAt));
|
||||
}
|
||||
|
||||
var totalSize = feeds.Sum(f => f.SizeBytes) +
|
||||
policies.Sum(p => p.SizeBytes) +
|
||||
cryptoMaterials.Sum(c => c.SizeBytes);
|
||||
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
BundleId = Guid.NewGuid().ToString(),
|
||||
SchemaVersion = "1.0.0",
|
||||
Name = request.Name,
|
||||
Version = request.Version,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
Feeds = feeds.ToImmutableArray(),
|
||||
Policies = policies.ToImmutableArray(),
|
||||
CryptoMaterials = cryptoMaterials.ToImmutableArray(),
|
||||
TotalSizeBytes = totalSize
|
||||
};
|
||||
|
||||
return BundleManifestSerializer.WithDigest(manifest);
|
||||
}
|
||||
|
||||
private static async Task<CopiedComponent> CopyComponentAsync(
|
||||
BundleComponentSource source,
|
||||
string outputPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var targetPath = Path.Combine(outputPath, source.RelativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
|
||||
|
||||
await using var input = File.OpenRead(source.SourcePath);
|
||||
await using var output = File.Create(targetPath);
|
||||
await input.CopyToAsync(output, ct).ConfigureAwait(false);
|
||||
|
||||
await using var digestStream = File.OpenRead(targetPath);
|
||||
var hash = await SHA256.HashDataAsync(digestStream, ct).ConfigureAwait(false);
|
||||
var digest = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
var info = new FileInfo(targetPath);
|
||||
return new CopiedComponent(source.RelativePath, digest, info.Length);
|
||||
}
|
||||
|
||||
private sealed record CopiedComponent(string RelativePath, string Digest, long SizeBytes);
|
||||
}
|
||||
|
||||
public interface IBundleBuilder
|
||||
{
|
||||
Task<BundleManifest> BuildAsync(BundleBuildRequest request, string outputPath, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record BundleBuildRequest(
|
||||
string Name,
|
||||
string Version,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
IReadOnlyList<FeedBuildConfig> Feeds,
|
||||
IReadOnlyList<PolicyBuildConfig> Policies,
|
||||
IReadOnlyList<CryptoBuildConfig> CryptoMaterials);
|
||||
|
||||
public abstract record BundleComponentSource(string SourcePath, string RelativePath);
|
||||
|
||||
public sealed record FeedBuildConfig(
|
||||
string FeedId,
|
||||
string Name,
|
||||
string Version,
|
||||
string SourcePath,
|
||||
string RelativePath,
|
||||
DateTimeOffset SnapshotAt,
|
||||
FeedFormat Format)
|
||||
: BundleComponentSource(SourcePath, RelativePath);
|
||||
|
||||
public sealed record PolicyBuildConfig(
|
||||
string PolicyId,
|
||||
string Name,
|
||||
string Version,
|
||||
string SourcePath,
|
||||
string RelativePath,
|
||||
PolicyType Type)
|
||||
: BundleComponentSource(SourcePath, RelativePath);
|
||||
|
||||
public sealed record CryptoBuildConfig(
|
||||
string ComponentId,
|
||||
string Name,
|
||||
string SourcePath,
|
||||
string RelativePath,
|
||||
CryptoComponentType Type,
|
||||
DateTimeOffset? ExpiresAt)
|
||||
: BundleComponentSource(SourcePath, RelativePath);
|
||||
@@ -0,0 +1,79 @@
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Serialization;
|
||||
using StellaOps.AirGap.Bundle.Validation;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed class BundleLoader : IBundleLoader
|
||||
{
|
||||
private readonly IBundleValidator _validator;
|
||||
private readonly IFeedRegistry _feedRegistry;
|
||||
private readonly IPolicyRegistry _policyRegistry;
|
||||
private readonly ICryptoProviderRegistry _cryptoRegistry;
|
||||
|
||||
public BundleLoader(
|
||||
IBundleValidator validator,
|
||||
IFeedRegistry feedRegistry,
|
||||
IPolicyRegistry policyRegistry,
|
||||
ICryptoProviderRegistry cryptoRegistry)
|
||||
{
|
||||
_validator = validator;
|
||||
_feedRegistry = feedRegistry;
|
||||
_policyRegistry = policyRegistry;
|
||||
_cryptoRegistry = cryptoRegistry;
|
||||
}
|
||||
|
||||
public async Task LoadAsync(string bundlePath, CancellationToken ct = default)
|
||||
{
|
||||
var manifestPath = Path.Combine(bundlePath, "manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
throw new FileNotFoundException("Bundle manifest not found", manifestPath);
|
||||
}
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct).ConfigureAwait(false);
|
||||
var manifest = BundleManifestSerializer.Deserialize(manifestJson);
|
||||
|
||||
var validationResult = await _validator.ValidateAsync(manifest, bundlePath, ct).ConfigureAwait(false);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
var details = string.Join("; ", validationResult.Errors.Select(e => e.Message));
|
||||
throw new InvalidOperationException($"Bundle validation failed: {details}");
|
||||
}
|
||||
|
||||
foreach (var feed in manifest.Feeds)
|
||||
{
|
||||
_feedRegistry.Register(feed, Path.Combine(bundlePath, feed.RelativePath));
|
||||
}
|
||||
|
||||
foreach (var policy in manifest.Policies)
|
||||
{
|
||||
_policyRegistry.Register(policy, Path.Combine(bundlePath, policy.RelativePath));
|
||||
}
|
||||
|
||||
foreach (var crypto in manifest.CryptoMaterials)
|
||||
{
|
||||
_cryptoRegistry.Register(crypto, Path.Combine(bundlePath, crypto.RelativePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface IBundleLoader
|
||||
{
|
||||
Task LoadAsync(string bundlePath, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public interface IFeedRegistry
|
||||
{
|
||||
void Register(FeedComponent component, string absolutePath);
|
||||
}
|
||||
|
||||
public interface IPolicyRegistry
|
||||
{
|
||||
void Register(PolicyComponent component, string absolutePath);
|
||||
}
|
||||
|
||||
public interface ICryptoProviderRegistry
|
||||
{
|
||||
void Register(CryptoComponent component, string absolutePath);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Collections.Immutable" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Schemas\*.json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Validation;
|
||||
|
||||
public sealed class BundleValidator : IBundleValidator
|
||||
{
|
||||
public async Task<BundleValidationResult> ValidateAsync(
|
||||
BundleManifest manifest,
|
||||
string bundlePath,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var errors = new List<BundleValidationError>();
|
||||
var warnings = new List<BundleValidationWarning>();
|
||||
|
||||
if (manifest.Feeds.Length == 0)
|
||||
{
|
||||
errors.Add(new BundleValidationError("Feeds", "At least one feed required"));
|
||||
}
|
||||
|
||||
if (manifest.CryptoMaterials.Length == 0)
|
||||
{
|
||||
errors.Add(new BundleValidationError("CryptoMaterials", "Trust roots required"));
|
||||
}
|
||||
|
||||
foreach (var feed in manifest.Feeds)
|
||||
{
|
||||
var filePath = Path.Combine(bundlePath, feed.RelativePath);
|
||||
var result = await VerifyFileDigestAsync(filePath, feed.Digest, ct).ConfigureAwait(false);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
errors.Add(new BundleValidationError("Feeds",
|
||||
$"Feed {feed.FeedId} digest mismatch: expected {feed.Digest}, got {result.ActualDigest}"));
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.ExpiresAt.HasValue && manifest.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
||||
{
|
||||
warnings.Add(new BundleValidationWarning("ExpiresAt", "Bundle has expired"));
|
||||
}
|
||||
|
||||
foreach (var feed in manifest.Feeds)
|
||||
{
|
||||
var age = DateTimeOffset.UtcNow - feed.SnapshotAt;
|
||||
if (age.TotalDays > 7)
|
||||
{
|
||||
warnings.Add(new BundleValidationWarning("Feeds",
|
||||
$"Feed {feed.FeedId} is {age.TotalDays:F0} days old"));
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.BundleDigest is not null)
|
||||
{
|
||||
var computed = ComputeBundleDigest(manifest);
|
||||
if (!string.Equals(computed, manifest.BundleDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add(new BundleValidationError("BundleDigest", "Bundle digest mismatch"));
|
||||
}
|
||||
}
|
||||
|
||||
return new BundleValidationResult(
|
||||
errors.Count == 0,
|
||||
errors,
|
||||
warnings,
|
||||
manifest.TotalSizeBytes);
|
||||
}
|
||||
|
||||
private static async Task<(bool IsValid, string ActualDigest)> VerifyFileDigestAsync(
|
||||
string filePath, string expectedDigest, CancellationToken ct)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return (false, "FILE_NOT_FOUND");
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await SHA256.HashDataAsync(stream, ct).ConfigureAwait(false);
|
||||
var actualDigest = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
return (string.Equals(actualDigest, expectedDigest, StringComparison.OrdinalIgnoreCase), actualDigest);
|
||||
}
|
||||
|
||||
private static string ComputeBundleDigest(BundleManifest manifest)
|
||||
{
|
||||
var withoutDigest = manifest with { BundleDigest = null };
|
||||
var json = BundleManifestSerializer.Serialize(withoutDigest);
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json))).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public interface IBundleValidator
|
||||
{
|
||||
Task<BundleValidationResult> ValidateAsync(BundleManifest manifest, string bundlePath, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record BundleValidationResult(
|
||||
bool IsValid,
|
||||
IReadOnlyList<BundleValidationError> Errors,
|
||||
IReadOnlyList<BundleValidationWarning> Warnings,
|
||||
long TotalSizeBytes);
|
||||
|
||||
public sealed record BundleValidationError(string Component, string Message);
|
||||
public sealed record BundleValidationWarning(string Component, string Message);
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Serialization;
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using StellaOps.AirGap.Bundle.Validation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Tests;
|
||||
|
||||
public class BundleManifestTests
|
||||
{
|
||||
[Fact]
|
||||
public void Serializer_RoundTrip_PreservesFields()
|
||||
{
|
||||
var manifest = CreateManifest();
|
||||
var json = BundleManifestSerializer.Serialize(manifest);
|
||||
var deserialized = BundleManifestSerializer.Deserialize(json);
|
||||
deserialized.Should().BeEquivalentTo(manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validator_FlagsMissingFeedFile()
|
||||
{
|
||||
var manifest = CreateManifest();
|
||||
var validator = new BundleValidator();
|
||||
var result = await validator.ValidateAsync(manifest, Path.GetTempPath());
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Builder_CopiesComponentsAndComputesDigest()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
var sourceFile = Path.Combine(tempRoot, "feed.json");
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
await File.WriteAllTextAsync(sourceFile, "feed");
|
||||
|
||||
var builder = new BundleBuilder();
|
||||
var request = new BundleBuildRequest(
|
||||
"offline-test",
|
||||
"1.0.0",
|
||||
null,
|
||||
new[] { new FeedBuildConfig("feed-1", "nvd", "v1", sourceFile, "feeds/nvd.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative) },
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>());
|
||||
|
||||
var outputPath = Path.Combine(tempRoot, "bundle");
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
manifest.BundleDigest.Should().NotBeNullOrEmpty();
|
||||
File.Exists(Path.Combine(outputPath, "feeds", "nvd.json")).Should().BeTrue();
|
||||
}
|
||||
|
||||
private static BundleManifest CreateManifest()
|
||||
{
|
||||
return new BundleManifest
|
||||
{
|
||||
BundleId = Guid.NewGuid().ToString(),
|
||||
SchemaVersion = "1.0.0",
|
||||
Name = "offline-test",
|
||||
Version = "1.0.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Feeds = ImmutableArray.Create(new FeedComponent(
|
||||
"feed-1",
|
||||
"nvd",
|
||||
"v1",
|
||||
"feeds/nvd.json",
|
||||
new string('a', 64),
|
||||
10,
|
||||
DateTimeOffset.UtcNow,
|
||||
FeedFormat.StellaOpsNative)),
|
||||
Policies = ImmutableArray.Create(new PolicyComponent(
|
||||
"policy-1",
|
||||
"default",
|
||||
"1.0",
|
||||
"policies/default.rego",
|
||||
new string('b', 64),
|
||||
10,
|
||||
PolicyType.OpaRego)),
|
||||
CryptoMaterials = ImmutableArray.Create(new CryptoComponent(
|
||||
"crypto-1",
|
||||
"trust-root",
|
||||
"certs/root.pem",
|
||||
new string('c', 64),
|
||||
10,
|
||||
CryptoComponentType.TrustRoot,
|
||||
null)),
|
||||
TotalSizeBytes = 30
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.AirGap.Bundle\StellaOps.AirGap.Bundle.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user