tests fixes and sprints work

This commit is contained in:
master
2026-01-22 19:08:46 +02:00
parent c32fff8f86
commit 726d70dc7f
881 changed files with 134434 additions and 6228 deletions

View File

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