tests fixes and sprints work
This commit is contained in:
@@ -72,7 +72,7 @@ public sealed record BundleManifest
|
||||
/// </summary>
|
||||
public sealed record BundleArtifact(
|
||||
/// <summary>Relative path within the bundle.</summary>
|
||||
string Path,
|
||||
string? Path,
|
||||
/// <summary>Artifact type: sbom, vex, dsse, rekor-proof, oci-referrers, etc.</summary>
|
||||
string Type,
|
||||
/// <summary>Content type (MIME).</summary>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
public sealed record TrustProfile
|
||||
{
|
||||
[JsonPropertyName("profileId")]
|
||||
public string ProfileId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("trustRoots")]
|
||||
public ImmutableArray<TrustProfileEntry> TrustRoots { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("rekorKeys")]
|
||||
public ImmutableArray<TrustProfileEntry> RekorKeys { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("tsaRoots")]
|
||||
public ImmutableArray<TrustProfileEntry> TsaRoots { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
[JsonIgnore]
|
||||
public string? SourcePath { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TrustProfileEntry
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string? Algorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("purpose")]
|
||||
public string? Purpose { get; init; }
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public string? Sha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("validFrom")]
|
||||
public DateTimeOffset? ValidFrom { get; init; }
|
||||
|
||||
[JsonPropertyName("validUntil")]
|
||||
public DateTimeOffset? ValidUntil { get; init; }
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Serialization;
|
||||
using StellaOps.AirGap.Bundle.Validation;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
@@ -184,11 +185,27 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
}
|
||||
}
|
||||
|
||||
var artifacts = new List<BundleArtifact>();
|
||||
long artifactsSizeBytes = 0;
|
||||
var artifactConfigs = request.Artifacts ?? Array.Empty<BundleArtifactBuildConfig>();
|
||||
foreach (var artifactConfig in artifactConfigs)
|
||||
{
|
||||
var (artifact, sizeBytes) = await AddArtifactAsync(
|
||||
artifactConfig,
|
||||
outputPath,
|
||||
request.StrictInlineArtifacts,
|
||||
request.WarningSink,
|
||||
ct).ConfigureAwait(false);
|
||||
artifacts.Add(artifact);
|
||||
artifactsSizeBytes += sizeBytes;
|
||||
}
|
||||
|
||||
var totalSize = feeds.Sum(f => f.SizeBytes) +
|
||||
policies.Sum(p => p.SizeBytes) +
|
||||
cryptoMaterials.Sum(c => c.SizeBytes) +
|
||||
ruleBundles.Sum(r => r.SizeBytes) +
|
||||
timestampSizeBytes;
|
||||
timestampSizeBytes +
|
||||
artifactsSizeBytes;
|
||||
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
@@ -203,12 +220,200 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
CryptoMaterials = cryptoMaterials.ToImmutableArray(),
|
||||
RuleBundles = ruleBundles.ToImmutableArray(),
|
||||
Timestamps = timestamps.ToImmutableArray(),
|
||||
Artifacts = artifacts.ToImmutableArray(),
|
||||
TotalSizeBytes = totalSize
|
||||
};
|
||||
|
||||
return BundleManifestSerializer.WithDigest(manifest);
|
||||
}
|
||||
|
||||
private static async Task<(BundleArtifact Artifact, long SizeBytes)> AddArtifactAsync(
|
||||
BundleArtifactBuildConfig config,
|
||||
string outputPath,
|
||||
bool strictInlineArtifacts,
|
||||
ICollection<string>? warningSink,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.Type))
|
||||
{
|
||||
throw new ArgumentException("Artifact type is required.", nameof(config));
|
||||
}
|
||||
|
||||
var hasSourcePath = !string.IsNullOrWhiteSpace(config.SourcePath);
|
||||
var hasContent = config.Content is { Length: > 0 };
|
||||
if (!hasSourcePath && !hasContent)
|
||||
{
|
||||
throw new ArgumentException("Artifact content or source path is required.", nameof(config));
|
||||
}
|
||||
|
||||
string? relativePath = string.IsNullOrWhiteSpace(config.RelativePath) ? null : config.RelativePath;
|
||||
if (!string.IsNullOrWhiteSpace(relativePath) && !PathValidation.IsSafeRelativePath(relativePath))
|
||||
{
|
||||
throw new ArgumentException($"Invalid relative path: {relativePath}", nameof(config));
|
||||
}
|
||||
|
||||
string digest;
|
||||
long sizeBytes;
|
||||
|
||||
if (hasSourcePath)
|
||||
{
|
||||
var sourcePath = Path.GetFullPath(config.SourcePath!);
|
||||
if (!File.Exists(sourcePath))
|
||||
{
|
||||
throw new FileNotFoundException("Artifact source file not found.", sourcePath);
|
||||
}
|
||||
|
||||
var info = new FileInfo(sourcePath);
|
||||
sizeBytes = info.Length;
|
||||
digest = await ComputeSha256DigestAsync(sourcePath, ct).ConfigureAwait(false);
|
||||
relativePath = ApplyInlineSizeGuard(
|
||||
relativePath,
|
||||
config,
|
||||
digest,
|
||||
sizeBytes,
|
||||
strictInlineArtifacts,
|
||||
warningSink);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
|
||||
File.Copy(sourcePath, targetPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var content = config.Content ?? Array.Empty<byte>();
|
||||
sizeBytes = content.Length;
|
||||
digest = ComputeSha256Digest(content);
|
||||
relativePath = ApplyInlineSizeGuard(
|
||||
relativePath,
|
||||
config,
|
||||
digest,
|
||||
sizeBytes,
|
||||
strictInlineArtifacts,
|
||||
warningSink);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
|
||||
await File.WriteAllBytesAsync(targetPath, content, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var artifact = new BundleArtifact(relativePath, config.Type, config.ContentType, digest, sizeBytes);
|
||||
return (artifact, sizeBytes);
|
||||
}
|
||||
|
||||
private static string? ApplyInlineSizeGuard(
|
||||
string? relativePath,
|
||||
BundleArtifactBuildConfig config,
|
||||
string digest,
|
||||
long sizeBytes,
|
||||
bool strictInlineArtifacts,
|
||||
ICollection<string>? warningSink)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
if (!BundleSizeValidator.RequiresExternalization(sizeBytes))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var warning = BundleSizeValidator.GetInlineSizeWarning(sizeBytes)
|
||||
?? "Inline artifact size exceeds the maximum allowed size.";
|
||||
|
||||
if (strictInlineArtifacts)
|
||||
{
|
||||
throw new InvalidOperationException(warning);
|
||||
}
|
||||
|
||||
warningSink?.Add(warning);
|
||||
|
||||
var fileName = string.IsNullOrWhiteSpace(config.FileName)
|
||||
? BuildInlineFallbackName(config.Type, digest)
|
||||
: EnsureSafeFileName(config.FileName);
|
||||
|
||||
var fallbackPath = $"artifacts/{fileName}";
|
||||
if (!PathValidation.IsSafeRelativePath(fallbackPath))
|
||||
{
|
||||
throw new ArgumentException($"Invalid artifact fallback path: {fallbackPath}", nameof(config));
|
||||
}
|
||||
|
||||
return fallbackPath;
|
||||
}
|
||||
|
||||
private static string BuildInlineFallbackName(string type, string digest)
|
||||
{
|
||||
var normalizedType = SanitizeFileSegment(type);
|
||||
var digestValue = digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
|
||||
? digest[7..]
|
||||
: digest;
|
||||
var shortDigest = digestValue.Length > 12 ? digestValue[..12] : digestValue;
|
||||
return $"{normalizedType}-{shortDigest}.blob";
|
||||
}
|
||||
|
||||
private static string SanitizeFileSegment(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "artifact";
|
||||
}
|
||||
|
||||
var buffer = new char[value.Length];
|
||||
var index = 0;
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch) || ch == '-' || ch == '_')
|
||||
{
|
||||
buffer[index++] = ch;
|
||||
}
|
||||
else
|
||||
{
|
||||
buffer[index++] = '-';
|
||||
}
|
||||
}
|
||||
|
||||
var cleaned = new string(buffer, 0, index).Trim('-');
|
||||
return string.IsNullOrWhiteSpace(cleaned) ? "artifact" : cleaned;
|
||||
}
|
||||
|
||||
private static string EnsureSafeFileName(string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
throw new ArgumentException("Artifact file name is required.");
|
||||
}
|
||||
|
||||
if (fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 ||
|
||||
fileName.Contains('/') ||
|
||||
fileName.Contains('\\'))
|
||||
{
|
||||
throw new ArgumentException($"Invalid artifact file name: {fileName}");
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeSha256DigestAsync(string filePath, CancellationToken ct)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await SHA256.HashDataAsync(stream, ct).ConfigureAwait(false);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string ComputeSha256Digest(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static async Task<CopiedComponent> CopyComponentAsync(
|
||||
BundleComponentSource source,
|
||||
string outputPath,
|
||||
@@ -297,7 +502,7 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
|
||||
foreach (var blob in blobs
|
||||
.OrderBy(b => b.CertificateIndex)
|
||||
.ThenBy(b => ComputeShortHash(blob.Data), StringComparer.Ordinal))
|
||||
.ThenBy(b => ComputeShortHash(b.Data), StringComparer.Ordinal))
|
||||
{
|
||||
var hash = ComputeShortHash(blob.Data);
|
||||
var fileName = $"{prefix}-{blob.CertificateIndex:D2}-{hash}.{extension}";
|
||||
@@ -356,7 +561,10 @@ public sealed record BundleBuildRequest(
|
||||
IReadOnlyList<PolicyBuildConfig> Policies,
|
||||
IReadOnlyList<CryptoBuildConfig> CryptoMaterials,
|
||||
IReadOnlyList<RuleBundleBuildConfig> RuleBundles,
|
||||
IReadOnlyList<TimestampBuildConfig>? Timestamps = null);
|
||||
IReadOnlyList<TimestampBuildConfig>? Timestamps = null,
|
||||
IReadOnlyList<BundleArtifactBuildConfig>? Artifacts = null,
|
||||
bool StrictInlineArtifacts = false,
|
||||
ICollection<string>? WarningSink = null);
|
||||
|
||||
public abstract record BundleComponentSource(string SourcePath, string RelativePath);
|
||||
|
||||
@@ -396,6 +604,16 @@ public sealed record Rfc3161TimestampBuildConfig(byte[] TimeStampToken)
|
||||
public sealed record EidasQtsTimestampBuildConfig(string SourcePath, string RelativePath)
|
||||
: TimestampBuildConfig;
|
||||
|
||||
public sealed record BundleArtifactBuildConfig
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public string? ContentType { get; init; }
|
||||
public string? SourcePath { get; init; }
|
||||
public byte[]? Content { get; init; }
|
||||
public string? RelativePath { get; init; }
|
||||
public string? FileName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for building a rule bundle component.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed class TrustProfileLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public IReadOnlyList<TrustProfile> LoadProfiles(string directory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
throw new ArgumentException("Profiles directory is required.", nameof(directory));
|
||||
}
|
||||
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return Array.Empty<TrustProfile>();
|
||||
}
|
||||
|
||||
var profiles = Directory.GetFiles(directory, "*.trustprofile.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(LoadProfile)
|
||||
.ToList();
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
public TrustProfile LoadProfile(string profilePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(profilePath))
|
||||
{
|
||||
throw new ArgumentException("Profile path is required.", nameof(profilePath));
|
||||
}
|
||||
|
||||
if (!File.Exists(profilePath))
|
||||
{
|
||||
throw new FileNotFoundException("Trust profile not found.", profilePath);
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(profilePath);
|
||||
var profile = JsonSerializer.Deserialize<TrustProfile>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to deserialize trust profile.");
|
||||
|
||||
return NormalizeProfile(profile, profilePath);
|
||||
}
|
||||
|
||||
public string ResolveEntryPath(TrustProfile profile, TrustProfileEntry entry)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.Path))
|
||||
{
|
||||
throw new ArgumentException("Entry path is required.", nameof(entry));
|
||||
}
|
||||
|
||||
if (Path.IsPathRooted(entry.Path))
|
||||
{
|
||||
return entry.Path;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(profile.SourcePath))
|
||||
{
|
||||
throw new InvalidOperationException("Profile source path is missing.");
|
||||
}
|
||||
|
||||
var baseDir = Path.GetDirectoryName(profile.SourcePath);
|
||||
if (string.IsNullOrWhiteSpace(baseDir))
|
||||
{
|
||||
throw new InvalidOperationException("Profile base directory is missing.");
|
||||
}
|
||||
|
||||
return PathValidation.SafeCombine(baseDir, entry.Path);
|
||||
}
|
||||
|
||||
private static TrustProfile NormalizeProfile(TrustProfile profile, string sourcePath)
|
||||
{
|
||||
var profileId = string.IsNullOrWhiteSpace(profile.ProfileId)
|
||||
? InferProfileId(sourcePath)
|
||||
: profile.ProfileId;
|
||||
|
||||
var name = string.IsNullOrWhiteSpace(profile.Name) ? profileId : profile.Name;
|
||||
|
||||
return profile with
|
||||
{
|
||||
ProfileId = profileId,
|
||||
Name = name,
|
||||
TrustRoots = profile.TrustRoots.IsDefault ? [] : profile.TrustRoots,
|
||||
RekorKeys = profile.RekorKeys.IsDefault ? [] : profile.RekorKeys,
|
||||
TsaRoots = profile.TsaRoots.IsDefault ? [] : profile.TsaRoots,
|
||||
SourcePath = sourcePath
|
||||
};
|
||||
}
|
||||
|
||||
private static string InferProfileId(string profilePath)
|
||||
{
|
||||
var fileName = Path.GetFileName(profilePath);
|
||||
const string suffix = ".trustprofile.json";
|
||||
if (fileName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return fileName[..^suffix.Length];
|
||||
}
|
||||
|
||||
return Path.GetFileNameWithoutExtension(fileName);
|
||||
}
|
||||
}
|
||||
@@ -191,7 +191,13 @@ public sealed class TsaChainBundler : ITsaChainBundler
|
||||
try
|
||||
{
|
||||
var ski = new X509SubjectKeyIdentifierExtension(ext, ext.Critical);
|
||||
return Convert.FromHexString(ski.SubjectKeyIdentifier);
|
||||
var keyId = ski.SubjectKeyIdentifier;
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Convert.FromHexString(keyId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.AirGap.Bundle.Validation;
|
||||
|
||||
public static class BundleSizeValidator
|
||||
{
|
||||
public const int MaxInlineBlobSize = 4 * 1024 * 1024;
|
||||
|
||||
public static bool RequiresExternalization(long sizeBytes) =>
|
||||
sizeBytes > MaxInlineBlobSize;
|
||||
|
||||
public static string? GetInlineSizeWarning(long sizeBytes)
|
||||
{
|
||||
if (sizeBytes <= MaxInlineBlobSize)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $"Inline artifact size {sizeBytes} exceeds {MaxInlineBlobSize} bytes.";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user