fix tests. new product advisories enhancements
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user