fix tests. new product advisories enhancements

This commit is contained in:
master
2026-01-25 19:11:36 +02:00
parent c70e83719e
commit 6e687b523a
504 changed files with 40610 additions and 3785 deletions

View File

@@ -0,0 +1,35 @@
// -----------------------------------------------------------------------------
// IGuidProvider.cs
// Deterministic GUID generation interface for testing support
// -----------------------------------------------------------------------------
namespace StellaOps.AirGap.Bundle.TrustSnapshot;
/// <summary>
/// Interface for GUID generation, allowing deterministic testing.
/// </summary>
public interface IGuidProvider
{
/// <summary>
/// Creates a new GUID.
/// </summary>
Guid NewGuid();
}
/// <summary>
/// System GUID provider that uses Guid.NewGuid().
/// </summary>
public sealed class SystemGuidProvider : IGuidProvider
{
/// <summary>
/// Singleton instance.
/// </summary>
public static readonly SystemGuidProvider Instance = new();
private SystemGuidProvider()
{
}
/// <inheritdoc />
public Guid NewGuid() => Guid.NewGuid();
}

View File

@@ -0,0 +1,595 @@
// -----------------------------------------------------------------------------
// TrustSnapshotBuilder.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-004 - Add snapshot export command
// Description: Builder for creating trust snapshot bundles
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.AirGap.Bundle.TrustSnapshot;
/// <summary>
/// Builds trust snapshot bundles containing TUF metadata and tiles for offline verification.
/// </summary>
public sealed class TrustSnapshotBuilder
{
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
public TrustSnapshotBuilder() : this(TimeProvider.System, SystemGuidProvider.Instance)
{
}
public TrustSnapshotBuilder(TimeProvider timeProvider, IGuidProvider guidProvider)
{
_timeProvider = timeProvider;
_guidProvider = guidProvider;
}
/// <summary>
/// Builds a trust snapshot bundle.
/// </summary>
public async Task<TrustSnapshotManifest> BuildAsync(
TrustSnapshotBuildRequest request,
string outputPath,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
Directory.CreateDirectory(outputPath);
var bundleId = _guidProvider.NewGuid().ToString();
var createdAt = _timeProvider.GetUtcNow();
// Copy TUF metadata
TufMetadataComponent? tufComponent = null;
DateTimeOffset? expiresAt = null;
if (request.TufMetadata != null)
{
tufComponent = await CopyTufMetadataAsync(
request.TufMetadata,
outputPath,
cancellationToken);
expiresAt = request.TufMetadata.TimestampExpires;
}
// Copy checkpoint
var checkpointComponent = await CopyCheckpointAsync(
request.Checkpoint,
outputPath,
cancellationToken);
// Copy tiles
var tilesComponent = await CopyTilesAsync(
request.Tiles,
outputPath,
cancellationToken);
// Copy entries (optional)
EntriesComponent? entriesComponent = null;
if (request.Entries != null)
{
entriesComponent = await CopyEntriesAsync(
request.Entries,
outputPath,
cancellationToken);
}
// Calculate total size
var totalSize = (tufComponent != null ? GetTufComponentSize(tufComponent) : 0)
+ (checkpointComponent.SignedNote?.Length ?? 0)
+ tilesComponent.SizeBytes
+ (entriesComponent?.SizeBytes ?? 0);
// Build manifest
var manifest = new TrustSnapshotManifest
{
BundleId = bundleId,
CreatedAt = createdAt,
ExpiresAt = expiresAt,
Origin = request.Origin,
TreeSize = request.TreeSize,
RootHash = request.RootHash,
Tuf = tufComponent,
Checkpoint = checkpointComponent,
Tiles = tilesComponent,
Entries = entriesComponent,
TotalSizeBytes = totalSize
};
// Write manifest
var manifestPath = Path.Combine(outputPath, "index.json");
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
var manifestDigest = ComputeDigest(Encoding.UTF8.GetBytes(manifestJson));
await File.WriteAllTextAsync(manifestPath, manifestJson, cancellationToken);
// Return manifest with digest
return manifest with { Digest = manifestDigest };
}
/// <summary>
/// Creates a compressed tar.zst archive from a snapshot directory.
/// </summary>
public async Task<string> PackAsync(
string sourceDirectory,
string outputFilePath,
CancellationToken cancellationToken = default)
{
var tempTarPath = outputFilePath + ".tar";
try
{
// Create tar archive
await CreateTarAsync(sourceDirectory, tempTarPath, cancellationToken);
// Compress with zstd (using GZip as fallback if zstd not available)
await CompressAsync(tempTarPath, outputFilePath, cancellationToken);
return outputFilePath;
}
finally
{
if (File.Exists(tempTarPath))
{
File.Delete(tempTarPath);
}
}
}
private async Task<TufMetadataComponent> CopyTufMetadataAsync(
TufMetadataSource source,
string outputPath,
CancellationToken cancellationToken)
{
var tufDir = Path.Combine(outputPath, "tuf");
var targetsDir = Path.Combine(tufDir, "targets");
Directory.CreateDirectory(targetsDir);
// Copy role metadata
var rootComponent = await CopyFileAsync(source.RootPath, Path.Combine(tufDir, "root.json"), cancellationToken);
var snapshotComponent = await CopyFileAsync(source.SnapshotPath, Path.Combine(tufDir, "snapshot.json"), cancellationToken);
var timestampComponent = await CopyFileAsync(source.TimestampPath, Path.Combine(tufDir, "timestamp.json"), cancellationToken);
var targetsComponent = await CopyFileAsync(source.TargetsPath, Path.Combine(tufDir, "targets.json"), cancellationToken);
// Copy target files
var targetFiles = new List<TufTargetFileComponent>();
foreach (var target in source.TargetFiles)
{
var targetPath = Path.Combine(targetsDir, target.Name);
var component = await CopyFileAsync(target.SourcePath, targetPath, cancellationToken);
targetFiles.Add(new TufTargetFileComponent
{
Name = target.Name,
Path = $"tuf/targets/{target.Name}",
Digest = component.Digest,
SizeBytes = component.SizeBytes
});
}
return new TufMetadataComponent
{
Root = new TufFileComponent
{
Path = "tuf/root.json",
Digest = rootComponent.Digest,
SizeBytes = rootComponent.SizeBytes,
Version = source.RootVersion
},
Snapshot = new TufFileComponent
{
Path = "tuf/snapshot.json",
Digest = snapshotComponent.Digest,
SizeBytes = snapshotComponent.SizeBytes
},
Timestamp = new TufFileComponent
{
Path = "tuf/timestamp.json",
Digest = timestampComponent.Digest,
SizeBytes = timestampComponent.SizeBytes
},
Targets = new TufFileComponent
{
Path = "tuf/targets.json",
Digest = targetsComponent.Digest,
SizeBytes = targetsComponent.SizeBytes
},
TargetFiles = targetFiles.ToImmutableArray(),
RepositoryUrl = source.RepositoryUrl,
RootVersion = source.RootVersion
};
}
private async Task<CheckpointComponent> CopyCheckpointAsync(
CheckpointSource source,
string outputPath,
CancellationToken cancellationToken)
{
var checkpointPath = Path.Combine(outputPath, "checkpoint.sig");
await File.WriteAllTextAsync(checkpointPath, source.SignedNote, cancellationToken);
var digest = ComputeDigest(Encoding.UTF8.GetBytes(source.SignedNote));
return new CheckpointComponent
{
Path = "checkpoint.sig",
Digest = digest,
SignedNote = source.SignedNote
};
}
private async Task<TileSetComponent> CopyTilesAsync(
TileSetSource source,
string outputPath,
CancellationToken cancellationToken)
{
var tilesDir = Path.Combine(outputPath, "tiles");
Directory.CreateDirectory(tilesDir);
var tileFiles = new List<TileFileComponent>();
long totalSize = 0;
foreach (var tile in source.Tiles)
{
var levelDir = Path.Combine(tilesDir, tile.Level.ToString());
Directory.CreateDirectory(levelDir);
var tilePath = Path.Combine(levelDir, $"{tile.Index}.tile");
await File.WriteAllBytesAsync(tilePath, tile.Content, cancellationToken);
var digest = ComputeDigest(tile.Content);
var size = tile.Content.Length;
totalSize += size;
tileFiles.Add(new TileFileComponent
{
Level = tile.Level,
Index = tile.Index,
Path = $"tiles/{tile.Level}/{tile.Index}.tile",
Digest = digest,
SizeBytes = size,
IsPartial = tile.IsPartial
});
}
return new TileSetComponent
{
BasePath = "tiles",
TileCount = tileFiles.Count,
SizeBytes = totalSize,
EntryRange = new EntryRange
{
Start = source.EntryRangeStart,
End = source.EntryRangeEnd
},
Tiles = tileFiles.ToImmutableArray()
};
}
private async Task<EntriesComponent> CopyEntriesAsync(
EntriesSource source,
string outputPath,
CancellationToken cancellationToken)
{
var entriesDir = Path.Combine(outputPath, "entries");
Directory.CreateDirectory(entriesDir);
var entriesPath = Path.Combine(entriesDir, "entries.ndjson.zst");
var component = await CopyFileAsync(source.SourcePath, entriesPath, cancellationToken);
return new EntriesComponent
{
Path = "entries/entries.ndjson.zst",
Digest = component.Digest,
SizeBytes = component.SizeBytes,
EntryCount = source.EntryCount,
Format = "ndjson.zst"
};
}
private static async Task<(string Digest, long SizeBytes)> CopyFileAsync(
string sourcePath,
string destPath,
CancellationToken cancellationToken)
{
await using var sourceStream = File.OpenRead(sourcePath);
await using var destStream = File.Create(destPath);
await sourceStream.CopyToAsync(destStream, cancellationToken);
destStream.Position = 0;
var hash = await SHA256.HashDataAsync(destStream, cancellationToken);
var digest = $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
return (digest, destStream.Length);
}
private static string ComputeDigest(byte[] content)
{
var hash = SHA256.HashData(content);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static long GetTufComponentSize(TufMetadataComponent tuf)
{
return tuf.Root.SizeBytes +
tuf.Snapshot.SizeBytes +
tuf.Timestamp.SizeBytes +
tuf.Targets.SizeBytes +
tuf.TargetFiles.Sum(t => t.SizeBytes);
}
private static async Task CreateTarAsync(
string sourceDirectory,
string tarPath,
CancellationToken cancellationToken)
{
// Simple tar creation (directory structure only)
await using var tarStream = File.Create(tarPath);
foreach (var file in Directory.GetFiles(sourceDirectory, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(sourceDirectory, file);
var content = await File.ReadAllBytesAsync(file, cancellationToken);
// Write TAR header
await WriteTarHeaderAsync(tarStream, relativePath, content.Length, cancellationToken);
// Write content
await tarStream.WriteAsync(content, cancellationToken);
// Pad to 512-byte boundary
var padding = 512 - (content.Length % 512);
if (padding < 512)
{
await tarStream.WriteAsync(new byte[padding], cancellationToken);
}
}
// Write end-of-archive marker (two 512-byte blocks of zeros)
await tarStream.WriteAsync(new byte[1024], cancellationToken);
}
private static async Task WriteTarHeaderAsync(
Stream stream,
string path,
long size,
CancellationToken cancellationToken)
{
var header = new byte[512];
// Name (100 bytes)
var nameBytes = Encoding.ASCII.GetBytes(path.Replace('\\', '/'));
Array.Copy(nameBytes, 0, header, 0, Math.Min(nameBytes.Length, 100));
// Mode (8 bytes) - 0644
Encoding.ASCII.GetBytes("0000644\0").CopyTo(header, 100);
// UID (8 bytes) - 0
Encoding.ASCII.GetBytes("0000000\0").CopyTo(header, 108);
// GID (8 bytes) - 0
Encoding.ASCII.GetBytes("0000000\0").CopyTo(header, 116);
// Size (12 bytes) - octal
var sizeOctal = Convert.ToString(size, 8).PadLeft(11, '0') + "\0";
Encoding.ASCII.GetBytes(sizeOctal).CopyTo(header, 124);
// Mtime (12 bytes) - current time
var mtime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var mtimeOctal = Convert.ToString(mtime, 8).PadLeft(11, '0') + "\0";
Encoding.ASCII.GetBytes(mtimeOctal).CopyTo(header, 136);
// Checksum placeholder (8 bytes of spaces)
Encoding.ASCII.GetBytes(" ").CopyTo(header, 148);
// Type flag - regular file
header[156] = (byte)'0';
// Calculate checksum
var checksum = header.Sum(b => (int)b);
var checksumOctal = Convert.ToString(checksum, 8).PadLeft(6, '0') + "\0 ";
Encoding.ASCII.GetBytes(checksumOctal).CopyTo(header, 148);
await stream.WriteAsync(header, cancellationToken);
}
private static async Task CompressAsync(
string sourcePath,
string destPath,
CancellationToken cancellationToken)
{
// Use GZip compression (zstd would require external library)
await using var sourceStream = File.OpenRead(sourcePath);
await using var destStream = File.Create(destPath);
await using var gzipStream = new GZipStream(destStream, CompressionLevel.Optimal);
await sourceStream.CopyToAsync(gzipStream, cancellationToken);
}
}
/// <summary>
/// Request to build a trust snapshot.
/// </summary>
public sealed record TrustSnapshotBuildRequest
{
/// <summary>
/// Log origin identifier.
/// </summary>
public required string Origin { get; init; }
/// <summary>
/// Tree size at snapshot time.
/// </summary>
public required long TreeSize { get; init; }
/// <summary>
/// Root hash at snapshot time.
/// </summary>
public required string RootHash { get; init; }
/// <summary>
/// Checkpoint source.
/// </summary>
public required CheckpointSource Checkpoint { get; init; }
/// <summary>
/// Tiles to include.
/// </summary>
public required TileSetSource Tiles { get; init; }
/// <summary>
/// TUF metadata (optional).
/// </summary>
public TufMetadataSource? TufMetadata { get; init; }
/// <summary>
/// Entries to include (optional).
/// </summary>
public EntriesSource? Entries { get; init; }
}
/// <summary>
/// Checkpoint source.
/// </summary>
public sealed record CheckpointSource
{
/// <summary>
/// Signed checkpoint note.
/// </summary>
public required string SignedNote { get; init; }
}
/// <summary>
/// Tile set source.
/// </summary>
public sealed record TileSetSource
{
/// <summary>
/// Tiles to include.
/// </summary>
public required IReadOnlyList<TileSource> Tiles { get; init; }
/// <summary>
/// Start of entry range covered.
/// </summary>
public required long EntryRangeStart { get; init; }
/// <summary>
/// End of entry range covered.
/// </summary>
public required long EntryRangeEnd { get; init; }
}
/// <summary>
/// Individual tile source.
/// </summary>
public sealed record TileSource
{
/// <summary>
/// Tile level.
/// </summary>
public required int Level { get; init; }
/// <summary>
/// Tile index.
/// </summary>
public required long Index { get; init; }
/// <summary>
/// Tile content (raw hashes).
/// </summary>
public required byte[] Content { get; init; }
/// <summary>
/// Whether this is a partial tile.
/// </summary>
public bool IsPartial { get; init; }
}
/// <summary>
/// TUF metadata source.
/// </summary>
public sealed record TufMetadataSource
{
/// <summary>
/// Path to root.json.
/// </summary>
public required string RootPath { get; init; }
/// <summary>
/// Path to snapshot.json.
/// </summary>
public required string SnapshotPath { get; init; }
/// <summary>
/// Path to timestamp.json.
/// </summary>
public required string TimestampPath { get; init; }
/// <summary>
/// Path to targets.json.
/// </summary>
public required string TargetsPath { get; init; }
/// <summary>
/// Target files to include.
/// </summary>
public IReadOnlyList<TufTargetSource> TargetFiles { get; init; } = [];
/// <summary>
/// TUF repository URL.
/// </summary>
public string? RepositoryUrl { get; init; }
/// <summary>
/// Root version.
/// </summary>
public int RootVersion { get; init; }
/// <summary>
/// When the timestamp expires.
/// </summary>
public DateTimeOffset? TimestampExpires { get; init; }
}
/// <summary>
/// TUF target file source.
/// </summary>
public sealed record TufTargetSource
{
/// <summary>
/// Target name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Source path.
/// </summary>
public required string SourcePath { get; init; }
}
/// <summary>
/// Entries source.
/// </summary>
public sealed record EntriesSource
{
/// <summary>
/// Path to the entries file.
/// </summary>
public required string SourcePath { get; init; }
/// <summary>
/// Number of entries in the file.
/// </summary>
public required int EntryCount { get; init; }
}

View File

@@ -0,0 +1,686 @@
// -----------------------------------------------------------------------------
// TrustSnapshotImporter.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-005 - Add snapshot import command
// Description: Importer for trust snapshot bundles
// -----------------------------------------------------------------------------
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.AirGap.Bundle.TrustSnapshot;
/// <summary>
/// Imports trust snapshot bundles into the local cache for offline verification.
/// </summary>
public sealed class TrustSnapshotImporter
{
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true
};
public TrustSnapshotImporter() : this(TimeProvider.System)
{
}
public TrustSnapshotImporter(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
/// <summary>
/// Imports a trust snapshot from a compressed archive.
/// </summary>
public async Task<TrustSnapshotImportResult> ImportAsync(
string archivePath,
TrustSnapshotImportOptions options,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(archivePath);
ArgumentNullException.ThrowIfNull(options);
if (!File.Exists(archivePath))
{
return TrustSnapshotImportResult.Failure($"Archive not found: {archivePath}");
}
// Create temp directory for extraction
var tempDir = Path.Combine(Path.GetTempPath(), $"trust-snapshot-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
// Extract archive
await ExtractArchiveAsync(archivePath, tempDir, cancellationToken);
// Read and validate manifest
var manifestPath = Path.Combine(tempDir, "index.json");
if (!File.Exists(manifestPath))
{
return TrustSnapshotImportResult.Failure("Manifest (index.json) not found in archive");
}
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken);
var manifest = JsonSerializer.Deserialize<TrustSnapshotManifest>(manifestJson, JsonOptions);
if (manifest == null)
{
return TrustSnapshotImportResult.Failure("Failed to parse manifest");
}
// Validate manifest integrity
if (options.VerifyManifest)
{
var validationResult = await ValidateManifestAsync(manifest, tempDir, cancellationToken);
if (!validationResult.Success)
{
if (!options.Force)
{
return TrustSnapshotImportResult.Failure($"Manifest validation failed: {validationResult.Error}");
}
// Log warning but continue if force is set
}
}
// Check staleness
if (options.RejectIfStale.HasValue)
{
var age = _timeProvider.GetUtcNow() - manifest.CreatedAt;
if (age > options.RejectIfStale.Value)
{
if (!options.Force)
{
return TrustSnapshotImportResult.Failure(
$"Snapshot is stale (age: {age.TotalDays:F1} days, threshold: {options.RejectIfStale.Value.TotalDays:F1} days)");
}
}
}
// Check expiration
if (manifest.ExpiresAt.HasValue && manifest.ExpiresAt.Value < _timeProvider.GetUtcNow())
{
if (!options.Force)
{
return TrustSnapshotImportResult.Failure(
$"Snapshot has expired (expired at: {manifest.ExpiresAt.Value:u})");
}
}
// Import TUF metadata
TufImportResult? tufResult = null;
if (manifest.Tuf != null && !string.IsNullOrEmpty(options.TufCachePath))
{
tufResult = await ImportTufMetadataAsync(manifest.Tuf, tempDir, options.TufCachePath, cancellationToken);
}
// Import tiles
TileImportResult? tileResult = null;
if (!string.IsNullOrEmpty(options.TileCachePath))
{
tileResult = await ImportTilesAsync(manifest, tempDir, options.TileCachePath, cancellationToken);
}
// Import checkpoint
string? checkpointContent = null;
if (manifest.Checkpoint != null)
{
var checkpointPath = Path.Combine(tempDir, manifest.Checkpoint.Path);
if (File.Exists(checkpointPath))
{
checkpointContent = await File.ReadAllTextAsync(checkpointPath, cancellationToken);
}
}
return TrustSnapshotImportResult.Success(
manifest,
tufResult,
tileResult,
checkpointContent);
}
finally
{
// Cleanup temp directory
try
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
catch
{
// Ignore cleanup errors
}
}
}
/// <summary>
/// Validates a trust snapshot without importing it.
/// </summary>
public async Task<TrustSnapshotValidationResult> ValidateAsync(
string archivePath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(archivePath);
if (!File.Exists(archivePath))
{
return new TrustSnapshotValidationResult
{
IsValid = false,
Error = $"Archive not found: {archivePath}"
};
}
var tempDir = Path.Combine(Path.GetTempPath(), $"trust-snapshot-validate-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
await ExtractArchiveAsync(archivePath, tempDir, cancellationToken);
var manifestPath = Path.Combine(tempDir, "index.json");
if (!File.Exists(manifestPath))
{
return new TrustSnapshotValidationResult
{
IsValid = false,
Error = "Manifest (index.json) not found"
};
}
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken);
var manifest = JsonSerializer.Deserialize<TrustSnapshotManifest>(manifestJson, JsonOptions);
if (manifest == null)
{
return new TrustSnapshotValidationResult
{
IsValid = false,
Error = "Failed to parse manifest"
};
}
var validationResult = await ValidateManifestAsync(manifest, tempDir, cancellationToken);
return new TrustSnapshotValidationResult
{
IsValid = validationResult.Success,
Error = validationResult.Error,
Manifest = manifest,
FileCount = validationResult.FileCount,
TotalBytes = validationResult.TotalBytes
};
}
finally
{
try
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
catch
{
// Ignore cleanup errors
}
}
}
private static async Task ExtractArchiveAsync(
string archivePath,
string destDir,
CancellationToken cancellationToken)
{
// Detect archive type by extension
if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) ||
archivePath.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase) ||
archivePath.EndsWith(".tar.zst", StringComparison.OrdinalIgnoreCase))
{
// Decompress to tar first
var tarPath = Path.Combine(destDir, "archive.tar");
await using (var compressedStream = File.OpenRead(archivePath))
await using (var gzipStream = new GZipStream(compressedStream, CompressionMode.Decompress))
await using (var tarStream = File.Create(tarPath))
{
await gzipStream.CopyToAsync(tarStream, cancellationToken);
}
// Extract tar
await ExtractTarAsync(tarPath, destDir, cancellationToken);
File.Delete(tarPath);
}
else if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
ZipFile.ExtractToDirectory(archivePath, destDir);
}
else
{
// Assume it's a directory
if (Directory.Exists(archivePath))
{
CopyDirectory(archivePath, destDir);
}
else
{
throw new InvalidOperationException($"Unknown archive format: {archivePath}");
}
}
}
private static async Task ExtractTarAsync(
string tarPath,
string destDir,
CancellationToken cancellationToken)
{
await using var tarStream = File.OpenRead(tarPath);
var buffer = new byte[512];
while (true)
{
// Read header
var bytesRead = await tarStream.ReadAsync(buffer.AsMemory(0, 512), cancellationToken);
if (bytesRead < 512 || buffer.All(b => b == 0))
{
break; // End of archive
}
// Parse header
var name = Encoding.ASCII.GetString(buffer, 0, 100).TrimEnd('\0');
if (string.IsNullOrEmpty(name))
{
break;
}
var sizeOctal = Encoding.ASCII.GetString(buffer, 124, 12).TrimEnd('\0', ' ');
var size = Convert.ToInt64(sizeOctal, 8);
var typeFlag = (char)buffer[156];
// Skip directories
if (typeFlag == '5' || name.EndsWith('/'))
{
var dirPath = Path.Combine(destDir, name);
Directory.CreateDirectory(dirPath);
continue;
}
// Extract file
var filePath = Path.Combine(destDir, name);
var fileDir = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(fileDir))
{
Directory.CreateDirectory(fileDir);
}
await using (var fileStream = File.Create(filePath))
{
var remaining = size;
var fileBuffer = new byte[8192];
while (remaining > 0)
{
var toRead = (int)Math.Min(remaining, fileBuffer.Length);
bytesRead = await tarStream.ReadAsync(fileBuffer.AsMemory(0, toRead), cancellationToken);
if (bytesRead == 0) break;
await fileStream.WriteAsync(fileBuffer.AsMemory(0, bytesRead), cancellationToken);
remaining -= bytesRead;
}
}
// Skip padding
var padding = 512 - (size % 512);
if (padding < 512)
{
tarStream.Seek(padding, SeekOrigin.Current);
}
}
}
private static void CopyDirectory(string sourceDir, string destDir)
{
Directory.CreateDirectory(destDir);
foreach (var file in Directory.GetFiles(sourceDir))
{
var destFile = Path.Combine(destDir, Path.GetFileName(file));
File.Copy(file, destFile);
}
foreach (var dir in Directory.GetDirectories(sourceDir))
{
var destSubDir = Path.Combine(destDir, Path.GetFileName(dir));
CopyDirectory(dir, destSubDir);
}
}
private static async Task<ManifestValidationResult> ValidateManifestAsync(
TrustSnapshotManifest manifest,
string extractDir,
CancellationToken cancellationToken)
{
var errors = new List<string>();
var fileCount = 0;
long totalBytes = 0;
// Validate checkpoint
if (manifest.Checkpoint != null)
{
var checkpointPath = Path.Combine(extractDir, manifest.Checkpoint.Path);
if (!File.Exists(checkpointPath))
{
errors.Add($"Checkpoint file missing: {manifest.Checkpoint.Path}");
}
else
{
var content = await File.ReadAllBytesAsync(checkpointPath, cancellationToken);
var digest = ComputeDigest(content);
if (digest != manifest.Checkpoint.Digest)
{
errors.Add($"Checkpoint digest mismatch: expected {manifest.Checkpoint.Digest}, got {digest}");
}
fileCount++;
totalBytes += content.Length;
}
}
// Validate TUF metadata
if (manifest.Tuf != null)
{
var tufFiles = new[]
{
(manifest.Tuf.Root.Path, manifest.Tuf.Root.Digest),
(manifest.Tuf.Snapshot.Path, manifest.Tuf.Snapshot.Digest),
(manifest.Tuf.Timestamp.Path, manifest.Tuf.Timestamp.Digest),
(manifest.Tuf.Targets.Path, manifest.Tuf.Targets.Digest)
};
foreach (var (path, expectedDigest) in tufFiles)
{
var fullPath = Path.Combine(extractDir, path);
if (!File.Exists(fullPath))
{
errors.Add($"TUF file missing: {path}");
continue;
}
var content = await File.ReadAllBytesAsync(fullPath, cancellationToken);
var digest = ComputeDigest(content);
if (digest != expectedDigest)
{
errors.Add($"TUF file digest mismatch ({path}): expected {expectedDigest}, got {digest}");
}
fileCount++;
totalBytes += content.Length;
}
// Validate target files
foreach (var target in manifest.Tuf.TargetFiles)
{
var targetPath = Path.Combine(extractDir, target.Path);
if (!File.Exists(targetPath))
{
errors.Add($"TUF target file missing: {target.Path}");
continue;
}
var content = await File.ReadAllBytesAsync(targetPath, cancellationToken);
var digest = ComputeDigest(content);
if (digest != target.Digest)
{
errors.Add($"TUF target digest mismatch ({target.Name}): expected {target.Digest}, got {digest}");
}
fileCount++;
totalBytes += content.Length;
}
}
// Validate tiles (sample check - not all tiles to avoid performance issues)
if (manifest.Tiles != null && manifest.Tiles.Tiles.Length > 0)
{
var tilesToCheck = manifest.Tiles.Tiles.Length > 10
? manifest.Tiles.Tiles.Take(5).Concat(manifest.Tiles.Tiles.TakeLast(5)).ToArray()
: manifest.Tiles.Tiles.ToArray();
foreach (var tile in tilesToCheck)
{
var tilePath = Path.Combine(extractDir, tile.Path);
if (!File.Exists(tilePath))
{
errors.Add($"Tile file missing: {tile.Path}");
continue;
}
var content = await File.ReadAllBytesAsync(tilePath, cancellationToken);
var digest = ComputeDigest(content);
if (digest != tile.Digest)
{
errors.Add($"Tile digest mismatch ({tile.Level}/{tile.Index}): expected {tile.Digest}, got {digest}");
}
}
fileCount += manifest.Tiles.TileCount;
totalBytes += manifest.Tiles.SizeBytes;
}
return new ManifestValidationResult
{
Success = errors.Count == 0,
Error = errors.Count > 0 ? string.Join("; ", errors) : null,
FileCount = fileCount,
TotalBytes = totalBytes
};
}
private static async Task<TufImportResult> ImportTufMetadataAsync(
TufMetadataComponent tuf,
string sourceDir,
string destDir,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(destDir);
var targetsDir = Path.Combine(destDir, "targets");
Directory.CreateDirectory(targetsDir);
var importedFiles = new List<string>();
// Copy role metadata
var roleFiles = new[]
{
(tuf.Root.Path, "root.json"),
(tuf.Snapshot.Path, "snapshot.json"),
(tuf.Timestamp.Path, "timestamp.json"),
(tuf.Targets.Path, "targets.json")
};
foreach (var (sourcePath, destName) in roleFiles)
{
var src = Path.Combine(sourceDir, sourcePath);
var dest = Path.Combine(destDir, destName);
if (File.Exists(src))
{
await CopyFileAsync(src, dest, cancellationToken);
importedFiles.Add(destName);
}
}
// Copy target files
foreach (var target in tuf.TargetFiles)
{
var src = Path.Combine(sourceDir, target.Path);
var dest = Path.Combine(targetsDir, target.Name);
if (File.Exists(src))
{
await CopyFileAsync(src, dest, cancellationToken);
importedFiles.Add($"targets/{target.Name}");
}
}
return new TufImportResult
{
ImportedFiles = importedFiles,
RootVersion = tuf.RootVersion
};
}
private static async Task<TileImportResult> ImportTilesAsync(
TrustSnapshotManifest manifest,
string sourceDir,
string destDir,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(destDir);
var importedCount = 0;
long importedBytes = 0;
if (manifest.Tiles?.Tiles == null)
{
return new TileImportResult { ImportedCount = 0, ImportedBytes = 0 };
}
foreach (var tile in manifest.Tiles.Tiles)
{
var src = Path.Combine(sourceDir, tile.Path);
if (!File.Exists(src))
{
continue;
}
// Create destination path matching FileSystemRekorTileCache structure
var levelDir = Path.Combine(destDir, manifest.Origin ?? "default", tile.Level.ToString());
Directory.CreateDirectory(levelDir);
var dest = Path.Combine(levelDir, $"{tile.Index}.tile");
await CopyFileAsync(src, dest, cancellationToken);
importedCount++;
importedBytes += tile.SizeBytes;
}
return new TileImportResult
{
ImportedCount = importedCount,
ImportedBytes = importedBytes
};
}
private static async Task CopyFileAsync(string src, string dest, CancellationToken cancellationToken)
{
await using var srcStream = File.OpenRead(src);
await using var destStream = File.Create(dest);
await srcStream.CopyToAsync(destStream, cancellationToken);
}
private static string ComputeDigest(byte[] content)
{
var hash = SHA256.HashData(content);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private sealed record ManifestValidationResult
{
public bool Success { get; init; }
public string? Error { get; init; }
public int FileCount { get; init; }
public long TotalBytes { get; init; }
}
}
/// <summary>
/// Options for importing a trust snapshot.
/// </summary>
public sealed record TrustSnapshotImportOptions
{
/// <summary>
/// Whether to verify manifest checksums.
/// </summary>
public bool VerifyManifest { get; init; } = true;
/// <summary>
/// Reject if snapshot is older than this threshold.
/// </summary>
public TimeSpan? RejectIfStale { get; init; }
/// <summary>
/// Force import even if validation fails.
/// </summary>
public bool Force { get; init; }
/// <summary>
/// Path to TUF cache directory.
/// </summary>
public string? TufCachePath { get; init; }
/// <summary>
/// Path to tile cache directory.
/// </summary>
public string? TileCachePath { get; init; }
}
/// <summary>
/// Result of importing a trust snapshot.
/// </summary>
public sealed record TrustSnapshotImportResult
{
public bool IsSuccess { get; init; }
public string? Error { get; init; }
public TrustSnapshotManifest? Manifest { get; init; }
public TufImportResult? TufResult { get; init; }
public TileImportResult? TileResult { get; init; }
public string? CheckpointContent { get; init; }
public static TrustSnapshotImportResult Success(
TrustSnapshotManifest manifest,
TufImportResult? tufResult,
TileImportResult? tileResult,
string? checkpointContent) => new()
{
IsSuccess = true,
Manifest = manifest,
TufResult = tufResult,
TileResult = tileResult,
CheckpointContent = checkpointContent
};
public static TrustSnapshotImportResult Failure(string error) => new()
{
IsSuccess = false,
Error = error
};
}
/// <summary>
/// Result of importing TUF metadata.
/// </summary>
public sealed record TufImportResult
{
public List<string> ImportedFiles { get; init; } = [];
public int RootVersion { get; init; }
}
/// <summary>
/// Result of importing tiles.
/// </summary>
public sealed record TileImportResult
{
public int ImportedCount { get; init; }
public long ImportedBytes { get; init; }
}
/// <summary>
/// Result of validating a trust snapshot.
/// </summary>
public sealed record TrustSnapshotValidationResult
{
public bool IsValid { get; init; }
public string? Error { get; init; }
public TrustSnapshotManifest? Manifest { get; init; }
public int FileCount { get; init; }
public long TotalBytes { get; init; }
}

View File

@@ -0,0 +1,359 @@
// -----------------------------------------------------------------------------
// TrustSnapshotManifest.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-004 - Add snapshot export command
// Description: Manifest model for trust snapshots
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.AirGap.Bundle.TrustSnapshot;
/// <summary>
/// Manifest for a trust snapshot bundle containing TUF metadata and tiles.
/// </summary>
public sealed record TrustSnapshotManifest
{
/// <summary>
/// Schema version for the manifest format.
/// </summary>
[JsonPropertyName("schema_version")]
public string SchemaVersion { get; init; } = "1.0.0";
/// <summary>
/// Unique bundle identifier.
/// </summary>
[JsonPropertyName("bundle_id")]
public required string BundleId { get; init; }
/// <summary>
/// When the snapshot was created.
/// </summary>
[JsonPropertyName("created_at")]
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When the snapshot expires (based on TUF metadata expiration).
/// </summary>
[JsonPropertyName("expires_at")]
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Log origin identifier.
/// </summary>
[JsonPropertyName("origin")]
public required string Origin { get; init; }
/// <summary>
/// Tree size at snapshot time.
/// </summary>
[JsonPropertyName("tree_size")]
public required long TreeSize { get; init; }
/// <summary>
/// Root hash at snapshot time.
/// </summary>
[JsonPropertyName("root_hash")]
public required string RootHash { get; init; }
/// <summary>
/// TUF metadata included in the bundle.
/// </summary>
[JsonPropertyName("tuf")]
public TufMetadataComponent? Tuf { get; init; }
/// <summary>
/// Checkpoint component.
/// </summary>
[JsonPropertyName("checkpoint")]
public required CheckpointComponent Checkpoint { get; init; }
/// <summary>
/// Tiles included in the snapshot.
/// </summary>
[JsonPropertyName("tiles")]
public required TileSetComponent Tiles { get; init; }
/// <summary>
/// Optional entries component.
/// </summary>
[JsonPropertyName("entries")]
public EntriesComponent? Entries { get; init; }
/// <summary>
/// Total size of the bundle in bytes.
/// </summary>
[JsonPropertyName("total_size_bytes")]
public long TotalSizeBytes { get; init; }
/// <summary>
/// SHA-256 digest of the manifest (computed after serialization).
/// </summary>
[JsonPropertyName("digest")]
public string? Digest { get; init; }
}
/// <summary>
/// TUF metadata component.
/// </summary>
public sealed record TufMetadataComponent
{
/// <summary>
/// Path to root.json.
/// </summary>
[JsonPropertyName("root")]
public required TufFileComponent Root { get; init; }
/// <summary>
/// Path to snapshot.json.
/// </summary>
[JsonPropertyName("snapshot")]
public required TufFileComponent Snapshot { get; init; }
/// <summary>
/// Path to timestamp.json.
/// </summary>
[JsonPropertyName("timestamp")]
public required TufFileComponent Timestamp { get; init; }
/// <summary>
/// Path to targets.json.
/// </summary>
[JsonPropertyName("targets")]
public required TufFileComponent Targets { get; init; }
/// <summary>
/// Target files (Rekor keys, service map, etc.).
/// </summary>
[JsonPropertyName("target_files")]
public ImmutableArray<TufTargetFileComponent> TargetFiles { get; init; } = [];
/// <summary>
/// TUF repository URL.
/// </summary>
[JsonPropertyName("repository_url")]
public string? RepositoryUrl { get; init; }
/// <summary>
/// TUF root version.
/// </summary>
[JsonPropertyName("root_version")]
public int RootVersion { get; init; }
}
/// <summary>
/// Individual TUF metadata file.
/// </summary>
public sealed record TufFileComponent
{
/// <summary>
/// Relative path within the bundle.
/// </summary>
[JsonPropertyName("path")]
public required string Path { get; init; }
/// <summary>
/// SHA-256 digest.
/// </summary>
[JsonPropertyName("digest")]
public required string Digest { get; init; }
/// <summary>
/// File size in bytes.
/// </summary>
[JsonPropertyName("size_bytes")]
public required long SizeBytes { get; init; }
/// <summary>
/// Version number (if applicable).
/// </summary>
[JsonPropertyName("version")]
public int? Version { get; init; }
}
/// <summary>
/// TUF target file component.
/// </summary>
public sealed record TufTargetFileComponent
{
/// <summary>
/// Target name.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Relative path within the bundle.
/// </summary>
[JsonPropertyName("path")]
public required string Path { get; init; }
/// <summary>
/// SHA-256 digest.
/// </summary>
[JsonPropertyName("digest")]
public required string Digest { get; init; }
/// <summary>
/// File size in bytes.
/// </summary>
[JsonPropertyName("size_bytes")]
public required long SizeBytes { get; init; }
}
/// <summary>
/// Checkpoint component.
/// </summary>
public sealed record CheckpointComponent
{
/// <summary>
/// Relative path to the checkpoint file.
/// </summary>
[JsonPropertyName("path")]
public required string Path { get; init; }
/// <summary>
/// SHA-256 digest.
/// </summary>
[JsonPropertyName("digest")]
public required string Digest { get; init; }
/// <summary>
/// Signed checkpoint note (raw).
/// </summary>
[JsonPropertyName("signed_note")]
public string? SignedNote { get; init; }
}
/// <summary>
/// Tile set component.
/// </summary>
public sealed record TileSetComponent
{
/// <summary>
/// Base path for tiles within the bundle.
/// </summary>
[JsonPropertyName("base_path")]
public required string BasePath { get; init; }
/// <summary>
/// Number of tiles included.
/// </summary>
[JsonPropertyName("tile_count")]
public required int TileCount { get; init; }
/// <summary>
/// Total size of tiles in bytes.
/// </summary>
[JsonPropertyName("size_bytes")]
public required long SizeBytes { get; init; }
/// <summary>
/// Range of entries covered by tiles.
/// </summary>
[JsonPropertyName("entry_range")]
public required EntryRange EntryRange { get; init; }
/// <summary>
/// Individual tile files (for verification).
/// </summary>
[JsonPropertyName("tiles")]
public ImmutableArray<TileFileComponent> Tiles { get; init; } = [];
}
/// <summary>
/// Entry range specification.
/// </summary>
public sealed record EntryRange
{
/// <summary>
/// Start index (inclusive).
/// </summary>
[JsonPropertyName("start")]
public required long Start { get; init; }
/// <summary>
/// End index (exclusive).
/// </summary>
[JsonPropertyName("end")]
public required long End { get; init; }
}
/// <summary>
/// Individual tile file.
/// </summary>
public sealed record TileFileComponent
{
/// <summary>
/// Tile level.
/// </summary>
[JsonPropertyName("level")]
public required int Level { get; init; }
/// <summary>
/// Tile index.
/// </summary>
[JsonPropertyName("index")]
public required long Index { get; init; }
/// <summary>
/// Relative path within the bundle.
/// </summary>
[JsonPropertyName("path")]
public required string Path { get; init; }
/// <summary>
/// SHA-256 digest.
/// </summary>
[JsonPropertyName("digest")]
public required string Digest { get; init; }
/// <summary>
/// File size in bytes.
/// </summary>
[JsonPropertyName("size_bytes")]
public required long SizeBytes { get; init; }
/// <summary>
/// Whether this is a partial tile.
/// </summary>
[JsonPropertyName("is_partial")]
public bool IsPartial { get; init; }
}
/// <summary>
/// Optional entries component (for offline verification).
/// </summary>
public sealed record EntriesComponent
{
/// <summary>
/// Relative path to the entries file.
/// </summary>
[JsonPropertyName("path")]
public required string Path { get; init; }
/// <summary>
/// SHA-256 digest.
/// </summary>
[JsonPropertyName("digest")]
public required string Digest { get; init; }
/// <summary>
/// File size in bytes.
/// </summary>
[JsonPropertyName("size_bytes")]
public required long SizeBytes { get; init; }
/// <summary>
/// Number of entries included.
/// </summary>
[JsonPropertyName("entry_count")]
public required int EntryCount { get; init; }
/// <summary>
/// Format of the entries file.
/// </summary>
[JsonPropertyName("format")]
public string Format { get; init; } = "ndjson.zst";
}