tests fixes and sprints work
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
@@ -144,53 +144,53 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Time", "St
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography", "StellaOps.Cryptography", "{66557252-B5C4-664B-D807-07018C627474}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.OfflineVerification", "StellaOps.Cryptography.Plugin.OfflineVerification", "{9FB0DDD7-7A77-8DA4-F9E2-A94E60ED8FC7}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.DependencyInjection", "StellaOps.DependencyInjection", "{589A43FD-8213-E9E3-6CFF-9CBA72D53E98}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.EfCore", "StellaOps.Infrastructure.EfCore", "{FCD529E0-DD17-6587-B29C-12D425C0AD0C}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres", "StellaOps.Infrastructure.Postgres", "{61B23570-4F2D-B060-BE1F-37995682E494}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Ingestion.Telemetry", "StellaOps.Ingestion.Telemetry", "{1182764D-2143-EEF0-9270-3DCE392F5D06}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Plugin", "StellaOps.Plugin", "{772B02B5-6280-E1D4-3E2E-248D0455C2FB}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Provenance", "StellaOps.Provenance", "{E69FA1A0-6D1B-A6E4-2DC0-8F4C5F21BF04}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.TestKit", "StellaOps.TestKit", "{8380A20C-A5B8-EE91-1A58-270323688CB9}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{90659617-4DF7-809A-4E5B-29BB5A98E8E1}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{AB8B269C-5A2A-A4B8-0488-B5F81E55B4D9}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres.Testing", "StellaOps.Infrastructure.Postgres.Testing", "{CEDC2447-F717-3C95-7E08-F214D575A7B7}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{A5C98087-E847-D2C4-2143-20869479839D}"
|
||||
|
||||
EndProject
|
||||
@@ -448,3 +448,4 @@ Global
|
||||
{68C75AAB-0E77-F9CF-9924-6C2BF6488ACD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{68C75AAB-0E77-F9CF-9924-6C2BF6488ACD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using StellaOps.AirGap.Bundle.Validation;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Tests;
|
||||
|
||||
public sealed class BundleInlineArtifactSizeTests : IAsyncLifetime
|
||||
{
|
||||
private string _tempRoot = null!;
|
||||
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"bundle-inline-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task BuildAsync_InlineArtifactUnderLimit_StaysInline()
|
||||
{
|
||||
var builder = new BundleBuilder();
|
||||
var outputPath = Path.Combine(_tempRoot, "inline-ok");
|
||||
var content = new byte[BundleSizeValidator.MaxInlineBlobSize - 8];
|
||||
var request = new BundleBuildRequest(
|
||||
"inline-ok",
|
||||
"1.0.0",
|
||||
null,
|
||||
Array.Empty<FeedBuildConfig>(),
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>(),
|
||||
Array.Empty<RuleBundleBuildConfig>(),
|
||||
Artifacts: new[]
|
||||
{
|
||||
new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = "sbom",
|
||||
ContentType = "application/json",
|
||||
Content = content
|
||||
}
|
||||
});
|
||||
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
manifest.Artifacts.Should().HaveCount(1);
|
||||
manifest.Artifacts[0].Path.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task BuildAsync_InlineArtifactOverLimit_ExternalizesToArtifactsDir()
|
||||
{
|
||||
var builder = new BundleBuilder();
|
||||
var outputPath = Path.Combine(_tempRoot, "inline-over");
|
||||
var warnings = new List<string>();
|
||||
var content = new byte[BundleSizeValidator.MaxInlineBlobSize + 1];
|
||||
var request = new BundleBuildRequest(
|
||||
"inline-over",
|
||||
"1.0.0",
|
||||
null,
|
||||
Array.Empty<FeedBuildConfig>(),
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>(),
|
||||
Array.Empty<RuleBundleBuildConfig>(),
|
||||
Artifacts: new[]
|
||||
{
|
||||
new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = "sbom",
|
||||
ContentType = "application/json",
|
||||
Content = content,
|
||||
FileName = "sbom.json"
|
||||
}
|
||||
},
|
||||
WarningSink: warnings);
|
||||
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
manifest.Artifacts.Should().HaveCount(1);
|
||||
var artifact = manifest.Artifacts[0];
|
||||
artifact.Path.Should().NotBeNullOrEmpty();
|
||||
artifact.Path.Should().StartWith("artifacts/");
|
||||
warnings.Should().ContainSingle();
|
||||
|
||||
var artifactPath = Path.Combine(outputPath, artifact.Path!.Replace('/', Path.DirectorySeparatorChar));
|
||||
File.Exists(artifactPath).Should().BeTrue();
|
||||
new FileInfo(artifactPath).Length.Should().Be(content.Length);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task BuildAsync_InlineArtifactOverLimit_StrictModeThrows()
|
||||
{
|
||||
var builder = new BundleBuilder();
|
||||
var outputPath = Path.Combine(_tempRoot, "inline-strict");
|
||||
var content = new byte[BundleSizeValidator.MaxInlineBlobSize + 1];
|
||||
var request = new BundleBuildRequest(
|
||||
"inline-strict",
|
||||
"1.0.0",
|
||||
null,
|
||||
Array.Empty<FeedBuildConfig>(),
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>(),
|
||||
Array.Empty<RuleBundleBuildConfig>(),
|
||||
Artifacts: new[]
|
||||
{
|
||||
new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = "sbom",
|
||||
ContentType = "application/json",
|
||||
Content = content
|
||||
}
|
||||
},
|
||||
StrictInlineArtifacts: true);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => builder.BuildAsync(request, outputPath));
|
||||
}
|
||||
}
|
||||
@@ -159,11 +159,22 @@ public sealed class BundleTimestampOfflineVerificationTests : IAsyncLifetime
|
||||
{
|
||||
var writer = new AsnWriter(AsnEncodingRules.DER);
|
||||
writer.PushSequence();
|
||||
writer.WriteEnumeratedValue(0);
|
||||
// OCSP response status: 0 = successful
|
||||
writer.WriteEnumeratedValue(OcspResponseStatus.Successful);
|
||||
writer.PopSequence();
|
||||
return writer.Encode();
|
||||
}
|
||||
|
||||
private enum OcspResponseStatus
|
||||
{
|
||||
Successful = 0,
|
||||
MalformedRequest = 1,
|
||||
InternalError = 2,
|
||||
TryLater = 3,
|
||||
SigRequired = 5,
|
||||
Unauthorized = 6
|
||||
}
|
||||
|
||||
private static byte[] CreateCrlPlaceholder()
|
||||
{
|
||||
var writer = new AsnWriter(AsnEncodingRules.DER);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.AirGap.Bundle/StellaOps.AirGap.Bundle.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
|
||||
<ProjectReference Include="../../../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
|
||||
<ProjectReference Include="../../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Tests;
|
||||
|
||||
public sealed class TrustProfileLoaderTests : IAsyncLifetime
|
||||
{
|
||||
private string _tempRoot = null!;
|
||||
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"trust-profiles-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Loader_ResolvesProfileIdAndEntryPaths()
|
||||
{
|
||||
var assetsDir = Path.Combine(_tempRoot, "assets");
|
||||
Directory.CreateDirectory(assetsDir);
|
||||
var rootPath = Path.Combine(assetsDir, "root.pem");
|
||||
File.WriteAllText(rootPath, "-----BEGIN PUBLIC KEY-----\nTEST\n-----END PUBLIC KEY-----");
|
||||
|
||||
var profilePath = Path.Combine(_tempRoot, "global.trustprofile.json");
|
||||
var profileJson = """
|
||||
{
|
||||
"name": "Global",
|
||||
"trustRoots": [
|
||||
{
|
||||
"id": "root-1",
|
||||
"path": "assets/root.pem",
|
||||
"algorithm": "x509"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
File.WriteAllText(profilePath, profileJson);
|
||||
|
||||
var loader = new TrustProfileLoader();
|
||||
var profile = loader.LoadProfile(profilePath);
|
||||
|
||||
profile.ProfileId.Should().Be("global");
|
||||
profile.Name.Should().Be("Global");
|
||||
profile.SourcePath.Should().Be(profilePath);
|
||||
profile.TrustRoots.Should().HaveCount(1);
|
||||
|
||||
var resolvedPath = loader.ResolveEntryPath(profile, profile.TrustRoots[0]);
|
||||
resolvedPath.Should().Be(rootPath);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Loader_LoadsProfilesFromDirectory()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_tempRoot, "one.trustprofile.json"), "{}");
|
||||
File.WriteAllText(Path.Combine(_tempRoot, "two.trustprofile.json"), "{}");
|
||||
|
||||
var loader = new TrustProfileLoader();
|
||||
var profiles = loader.LoadProfiles(_tempRoot);
|
||||
|
||||
profiles.Should().HaveCount(2);
|
||||
profiles.Select(p => p.ProfileId).Should().Contain(new[] { "one", "two" });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user