Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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