feat(api): Implement Console Export Client and Models
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
mock-dev-release / package-mock-release (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
mock-dev-release / package-mock-release (push) Has been cancelled
- Added ConsoleExportClient for managing export requests and responses. - Introduced ConsoleExportRequest and ConsoleExportResponse models. - Implemented methods for creating and retrieving exports with appropriate headers. feat(crypto): Add Software SM2/SM3 Cryptography Provider - Implemented SmSoftCryptoProvider for software-only SM2/SM3 cryptography. - Added support for signing and verification using SM2 algorithm. - Included hashing functionality with SM3 algorithm. - Configured options for loading keys from files and environment gate checks. test(crypto): Add unit tests for SmSoftCryptoProvider - Created comprehensive tests for signing, verifying, and hashing functionalities. - Ensured correct behavior for key management and error handling. feat(api): Enhance Console Export Models - Expanded ConsoleExport models to include detailed status and event types. - Added support for various export formats and notification options. test(time): Implement TimeAnchorPolicyService tests - Developed tests for TimeAnchorPolicyService to validate time anchors. - Covered scenarios for anchor validation, drift calculation, and policy enforcement.
This commit is contained in:
@@ -28536,13 +28536,63 @@ stella policy test {policyName}.stella
|
||||
}
|
||||
else if (!verifyOnly)
|
||||
{
|
||||
// In a real implementation, this would:
|
||||
// 1. Copy artifacts to the local data store
|
||||
// 2. Register exports in the database
|
||||
// 3. Update metadata indexes
|
||||
// For now, log success
|
||||
logger.LogInformation("Air-gap bundle imported: domain={Domain}, exports={Exports}, scope={Scope}",
|
||||
manifest.DomainId, manifest.Exports?.Count ?? 0, scopeDescription);
|
||||
// CLI-AIRGAP-56-001: Use MirrorBundleImportService for real import
|
||||
var importService = scope.ServiceProvider.GetService<IMirrorBundleImportService>();
|
||||
if (importService is not null)
|
||||
{
|
||||
var importRequest = new MirrorImportRequest
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
TenantId = effectiveTenant ?? (globalScope ? "global" : "default"),
|
||||
TrustRootsPath = null, // Use bundled trust roots
|
||||
DryRun = false,
|
||||
Force = force
|
||||
};
|
||||
|
||||
var importResult = await importService.ImportAsync(importRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!importResult.Success)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Import failed:[/] {Markup.Escape(importResult.Error ?? "Unknown error")}");
|
||||
CliMetrics.RecordOfflineKitImport("import_failed");
|
||||
return ExitGeneralError;
|
||||
}
|
||||
|
||||
// Show DSSE verification status if applicable
|
||||
if (importResult.DsseVerification is not null)
|
||||
{
|
||||
var dsseStatus = importResult.DsseVerification.IsValid ? "[green]VERIFIED[/]" : "[yellow]NOT VERIFIED[/]";
|
||||
AnsiConsole.MarkupLine($"[grey]DSSE Signature:[/] {dsseStatus}");
|
||||
if (!string.IsNullOrEmpty(importResult.DsseVerification.KeyId))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey] Key ID:[/] {Markup.Escape(TruncateMirrorDigest(importResult.DsseVerification.KeyId))}");
|
||||
}
|
||||
}
|
||||
|
||||
// Show imported paths in verbose mode
|
||||
if (verbose && importResult.ImportedPaths.Count > 0)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[bold]Imported files:[/]");
|
||||
foreach (var path in importResult.ImportedPaths.Take(10))
|
||||
{
|
||||
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(Path.GetFileName(path))}[/]");
|
||||
}
|
||||
if (importResult.ImportedPaths.Count > 10)
|
||||
{
|
||||
AnsiConsole.MarkupLine($" [grey]... and {importResult.ImportedPaths.Count - 10} more files[/]");
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("Air-gap bundle imported: domain={Domain}, exports={Exports}, scope={Scope}, files={FileCount}",
|
||||
manifest.DomainId, manifest.Exports?.Count ?? 0, scopeDescription, importResult.ImportedPaths.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: log success without actual import
|
||||
logger.LogInformation("Air-gap bundle imported (catalog-only): domain={Domain}, exports={Exports}, scope={Scope}",
|
||||
manifest.DomainId, manifest.Exports?.Count ?? 0, scopeDescription);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -222,6 +222,13 @@ internal static class Program
|
||||
client.Timeout = TimeSpan.FromMinutes(5); // Composition may take longer
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "sbomer-api");
|
||||
|
||||
// CLI-AIRGAP-56-001: Mirror bundle import service for air-gap operations
|
||||
services.AddSingleton<StellaOps.AirGap.Importer.Repositories.IBundleCatalogRepository,
|
||||
StellaOps.AirGap.Importer.Repositories.InMemoryBundleCatalogRepository>();
|
||||
services.AddSingleton<StellaOps.AirGap.Importer.Repositories.IBundleItemRepository,
|
||||
StellaOps.AirGap.Importer.Repositories.InMemoryBundleItemRepository>();
|
||||
services.AddSingleton<IMirrorBundleImportService, MirrorBundleImportService>();
|
||||
|
||||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var startupLogger = loggerFactory.CreateLogger("StellaOps.Cli.Startup");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Authority.Storage.Postgres;
|
||||
using StellaOps.Concelier.Storage.Postgres;
|
||||
using StellaOps.Excititor.Storage.Postgres;
|
||||
using StellaOps.Notify.Storage.Postgres;
|
||||
using StellaOps.Policy.Storage.Postgres;
|
||||
@@ -34,6 +35,11 @@ public static class MigrationModuleRegistry
|
||||
SchemaName: "scheduler",
|
||||
MigrationsAssembly: typeof(SchedulerDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Scheduler.Storage.Postgres.Migrations"),
|
||||
new(
|
||||
Name: "Concelier",
|
||||
SchemaName: "vuln",
|
||||
MigrationsAssembly: typeof(ConcelierDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Concelier.Storage.Postgres.Migrations"),
|
||||
new(
|
||||
Name: "Policy",
|
||||
SchemaName: "policy",
|
||||
|
||||
478
src/Cli/StellaOps.Cli/Services/MirrorBundleImportService.cs
Normal file
478
src/Cli/StellaOps.Cli/Services/MirrorBundleImportService.cs
Normal file
@@ -0,0 +1,478 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Models;
|
||||
using StellaOps.AirGap.Importer.Repositories;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for importing mirror bundles with DSSE, TUF, and Merkle verification.
|
||||
/// CLI-AIRGAP-56-001: Extends CLI offline kit tooling to consume mirror bundles.
|
||||
/// </summary>
|
||||
public interface IMirrorBundleImportService
|
||||
{
|
||||
Task<MirrorImportResult> ImportAsync(MirrorImportRequest request, CancellationToken cancellationToken);
|
||||
Task<MirrorVerificationResult> VerifyAsync(string bundlePath, string? trustRootsPath, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class MirrorBundleImportService : IMirrorBundleImportService
|
||||
{
|
||||
private readonly IBundleCatalogRepository _catalogRepository;
|
||||
private readonly IBundleItemRepository _itemRepository;
|
||||
private readonly ImportValidator _validator;
|
||||
private readonly ILogger<MirrorBundleImportService> _logger;
|
||||
|
||||
public MirrorBundleImportService(
|
||||
IBundleCatalogRepository catalogRepository,
|
||||
IBundleItemRepository itemRepository,
|
||||
ILogger<MirrorBundleImportService> logger)
|
||||
{
|
||||
_catalogRepository = catalogRepository ?? throw new ArgumentNullException(nameof(catalogRepository));
|
||||
_itemRepository = itemRepository ?? throw new ArgumentNullException(nameof(itemRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_validator = new ImportValidator();
|
||||
}
|
||||
|
||||
public async Task<MirrorImportResult> ImportAsync(MirrorImportRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Starting bundle import from {BundlePath}", request.BundlePath);
|
||||
|
||||
// Parse manifest
|
||||
var manifestResult = await ParseManifestAsync(request.BundlePath, cancellationToken).ConfigureAwait(false);
|
||||
if (!manifestResult.Success)
|
||||
{
|
||||
return MirrorImportResult.Failed(manifestResult.Error!);
|
||||
}
|
||||
|
||||
var manifest = manifestResult.Manifest!;
|
||||
var bundleDir = Path.GetDirectoryName(manifestResult.ManifestPath)!;
|
||||
|
||||
// Verify checksums
|
||||
var checksumResult = await VerifyChecksumsAsync(bundleDir, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// If DSSE envelope exists, perform cryptographic verification
|
||||
var dsseResult = await VerifyDsseIfPresentAsync(bundleDir, request.TrustRootsPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Copy artifacts to data store
|
||||
var dataStorePath = GetDataStorePath(request.TenantId, manifest.DomainId);
|
||||
var importedPaths = new List<string>();
|
||||
|
||||
if (!request.DryRun)
|
||||
{
|
||||
importedPaths = await CopyArtifactsAsync(bundleDir, dataStorePath, manifest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Register in catalog
|
||||
var bundleId = GenerateBundleId(manifest);
|
||||
var manifestDigest = ComputeDigest(File.ReadAllBytes(manifestResult.ManifestPath));
|
||||
|
||||
var catalogEntry = new BundleCatalogEntry(
|
||||
request.TenantId ?? "default",
|
||||
bundleId,
|
||||
manifestDigest,
|
||||
DateTimeOffset.UtcNow,
|
||||
importedPaths);
|
||||
|
||||
await _catalogRepository.UpsertAsync(catalogEntry, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Register individual items
|
||||
var items = manifest.Exports?.Select(e => new BundleItem(
|
||||
request.TenantId ?? "default",
|
||||
bundleId,
|
||||
e.Key,
|
||||
e.ArtifactDigest,
|
||||
e.ArtifactSizeBytes ?? 0)) ?? Enumerable.Empty<BundleItem>();
|
||||
|
||||
await _itemRepository.UpsertManyAsync(items, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Imported bundle {BundleId} with {Count} exports", bundleId, manifest.Exports?.Count ?? 0);
|
||||
}
|
||||
|
||||
return new MirrorImportResult
|
||||
{
|
||||
Success = true,
|
||||
ManifestPath = manifestResult.ManifestPath,
|
||||
DomainId = manifest.DomainId,
|
||||
DisplayName = manifest.DisplayName,
|
||||
GeneratedAt = manifest.GeneratedAt,
|
||||
ExportCount = manifest.Exports?.Count ?? 0,
|
||||
ChecksumVerification = checksumResult,
|
||||
DsseVerification = dsseResult,
|
||||
ImportedPaths = importedPaths,
|
||||
DryRun = request.DryRun
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<MirrorVerificationResult> VerifyAsync(string bundlePath, string? trustRootsPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var manifestResult = await ParseManifestAsync(bundlePath, cancellationToken).ConfigureAwait(false);
|
||||
if (!manifestResult.Success)
|
||||
{
|
||||
return new MirrorVerificationResult { Success = false, Error = manifestResult.Error };
|
||||
}
|
||||
|
||||
var bundleDir = Path.GetDirectoryName(manifestResult.ManifestPath)!;
|
||||
|
||||
var checksumResult = await VerifyChecksumsAsync(bundleDir, cancellationToken).ConfigureAwait(false);
|
||||
var dsseResult = await VerifyDsseIfPresentAsync(bundleDir, trustRootsPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var allValid = checksumResult.AllValid && (dsseResult?.IsValid ?? true);
|
||||
|
||||
return new MirrorVerificationResult
|
||||
{
|
||||
Success = allValid,
|
||||
ManifestPath = manifestResult.ManifestPath,
|
||||
DomainId = manifestResult.Manifest!.DomainId,
|
||||
ChecksumVerification = checksumResult,
|
||||
DsseVerification = dsseResult
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ManifestParseResult> ParseManifestAsync(string bundlePath, CancellationToken cancellationToken)
|
||||
{
|
||||
var resolvedPath = Path.GetFullPath(bundlePath);
|
||||
string manifestPath;
|
||||
|
||||
if (File.Exists(resolvedPath) && resolvedPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
manifestPath = resolvedPath;
|
||||
}
|
||||
else if (Directory.Exists(resolvedPath))
|
||||
{
|
||||
var candidates = Directory.GetFiles(resolvedPath, "*-manifest.json")
|
||||
.Concat(Directory.GetFiles(resolvedPath, "manifest.json"))
|
||||
.ToArray();
|
||||
|
||||
if (candidates.Length == 0)
|
||||
{
|
||||
return ManifestParseResult.Failed("No manifest file found in bundle directory");
|
||||
}
|
||||
|
||||
manifestPath = candidates.OrderByDescending(File.GetLastWriteTimeUtc).First();
|
||||
}
|
||||
else
|
||||
{
|
||||
return ManifestParseResult.Failed($"Bundle path not found: {resolvedPath}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
||||
var manifest = JsonSerializer.Deserialize<MirrorBundle>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
return ManifestParseResult.Failed("Failed to parse bundle manifest");
|
||||
}
|
||||
|
||||
return new ManifestParseResult { Success = true, ManifestPath = manifestPath, Manifest = manifest };
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return ManifestParseResult.Failed($"Invalid manifest JSON: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ChecksumVerificationResult> VerifyChecksumsAsync(string bundleDir, CancellationToken cancellationToken)
|
||||
{
|
||||
var checksumPath = Path.Combine(bundleDir, "SHA256SUMS");
|
||||
var results = new List<FileChecksumResult>();
|
||||
var allValid = true;
|
||||
|
||||
if (!File.Exists(checksumPath))
|
||||
{
|
||||
return new ChecksumVerificationResult { ChecksumFileFound = false, AllValid = true, Results = results };
|
||||
}
|
||||
|
||||
var lines = await File.ReadAllLinesAsync(checksumPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var line in lines.Where(l => !string.IsNullOrWhiteSpace(l)))
|
||||
{
|
||||
var parts = line.Split([' ', '\t'], 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 2) continue;
|
||||
|
||||
var expected = parts[0].Trim();
|
||||
var fileName = parts[1].Trim().TrimStart('*');
|
||||
var filePath = Path.Combine(bundleDir, fileName);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
results.Add(new FileChecksumResult(fileName, expected, "(missing)", false));
|
||||
allValid = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
var fileBytes = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
var actual = ComputeDigest(fileBytes);
|
||||
|
||||
var isValid = string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals($"sha256:{expected}", actual, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
results.Add(new FileChecksumResult(fileName, expected, actual, isValid));
|
||||
if (!isValid) allValid = false;
|
||||
}
|
||||
|
||||
return new ChecksumVerificationResult { ChecksumFileFound = true, AllValid = allValid, Results = results };
|
||||
}
|
||||
|
||||
private async Task<DsseVerificationResult?> VerifyDsseIfPresentAsync(string bundleDir, string? trustRootsPath, CancellationToken cancellationToken)
|
||||
{
|
||||
// Look for DSSE envelope
|
||||
var dsseFiles = Directory.GetFiles(bundleDir, "*.dsse.json")
|
||||
.Concat(Directory.GetFiles(bundleDir, "*envelope.json"))
|
||||
.ToArray();
|
||||
|
||||
if (dsseFiles.Length == 0)
|
||||
{
|
||||
return null; // No DSSE envelope present - verification not required
|
||||
}
|
||||
|
||||
var dsseFile = dsseFiles.OrderByDescending(File.GetLastWriteTimeUtc).First();
|
||||
|
||||
try
|
||||
{
|
||||
var envelopeJson = await File.ReadAllTextAsync(dsseFile, cancellationToken).ConfigureAwait(false);
|
||||
var envelope = DsseEnvelope.Parse(envelopeJson);
|
||||
|
||||
// Load trust roots if provided
|
||||
TrustRootConfig trustRoots;
|
||||
if (!string.IsNullOrWhiteSpace(trustRootsPath) && File.Exists(trustRootsPath))
|
||||
{
|
||||
trustRoots = await LoadTrustRootsAsync(trustRootsPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try default trust roots location
|
||||
var defaultTrustRoots = Path.Combine(bundleDir, "trust-roots.json");
|
||||
if (File.Exists(defaultTrustRoots))
|
||||
{
|
||||
trustRoots = await LoadTrustRootsAsync(defaultTrustRoots, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
return new DsseVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
EnvelopePath = dsseFile,
|
||||
Error = "No trust roots available for DSSE verification"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var verifier = new DsseVerifier();
|
||||
var result = verifier.Verify(envelope, trustRoots);
|
||||
|
||||
return new DsseVerificationResult
|
||||
{
|
||||
IsValid = result.IsValid,
|
||||
EnvelopePath = dsseFile,
|
||||
KeyId = envelope.Signatures.FirstOrDefault()?.KeyId,
|
||||
Reason = result.Reason
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new DsseVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
EnvelopePath = dsseFile,
|
||||
Error = $"Failed to verify DSSE: {ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<TrustRootConfig> LoadTrustRootsAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
var fingerprints = new List<string>();
|
||||
var algorithms = new List<string>();
|
||||
var publicKeys = new Dictionary<string, byte[]>();
|
||||
|
||||
if (doc.RootElement.TryGetProperty("trustedKeyFingerprints", out var fps))
|
||||
{
|
||||
foreach (var fp in fps.EnumerateArray())
|
||||
{
|
||||
fingerprints.Add(fp.GetString() ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("allowedAlgorithms", out var algs))
|
||||
{
|
||||
foreach (var alg in algs.EnumerateArray())
|
||||
{
|
||||
algorithms.Add(alg.GetString() ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("publicKeys", out var keys))
|
||||
{
|
||||
foreach (var key in keys.EnumerateObject())
|
||||
{
|
||||
var keyData = key.Value.GetString();
|
||||
if (!string.IsNullOrEmpty(keyData))
|
||||
{
|
||||
publicKeys[key.Name] = Convert.FromBase64String(keyData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new TrustRootConfig(path, fingerprints, algorithms, null, null, publicKeys);
|
||||
}
|
||||
|
||||
private async Task<List<string>> CopyArtifactsAsync(string bundleDir, string dataStorePath, MirrorBundle manifest, CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(dataStorePath);
|
||||
var importedPaths = new List<string>();
|
||||
|
||||
// Copy manifest
|
||||
var manifestFiles = Directory.GetFiles(bundleDir, "*manifest.json");
|
||||
foreach (var file in manifestFiles)
|
||||
{
|
||||
var destPath = Path.Combine(dataStorePath, Path.GetFileName(file));
|
||||
await CopyFileAsync(file, destPath, cancellationToken).ConfigureAwait(false);
|
||||
importedPaths.Add(destPath);
|
||||
}
|
||||
|
||||
// Copy export artifacts
|
||||
foreach (var export in manifest.Exports ?? Enumerable.Empty<MirrorBundleExport>())
|
||||
{
|
||||
var exportFiles = Directory.GetFiles(bundleDir, $"*{export.ExportId}*")
|
||||
.Concat(Directory.GetFiles(bundleDir, $"*{export.Key}*"));
|
||||
|
||||
foreach (var file in exportFiles.Distinct())
|
||||
{
|
||||
var destPath = Path.Combine(dataStorePath, Path.GetFileName(file));
|
||||
await CopyFileAsync(file, destPath, cancellationToken).ConfigureAwait(false);
|
||||
importedPaths.Add(destPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy checksums and signatures
|
||||
var supportFiles = new[] { "SHA256SUMS", "*.sig", "*.dsse.json" };
|
||||
foreach (var pattern in supportFiles)
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(bundleDir, pattern))
|
||||
{
|
||||
var destPath = Path.Combine(dataStorePath, Path.GetFileName(file));
|
||||
await CopyFileAsync(file, destPath, cancellationToken).ConfigureAwait(false);
|
||||
importedPaths.Add(destPath);
|
||||
}
|
||||
}
|
||||
|
||||
return importedPaths;
|
||||
}
|
||||
|
||||
private static async Task CopyFileAsync(string source, string destination, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var sourceStream = File.OpenRead(source);
|
||||
await using var destStream = File.Create(destination);
|
||||
await sourceStream.CopyToAsync(destStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string GetDataStorePath(string? tenantId, string domainId)
|
||||
{
|
||||
var basePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var stellaPath = Path.Combine(basePath, "stellaops", "offline-kit", "data");
|
||||
return Path.Combine(stellaPath, tenantId ?? "default", domainId);
|
||||
}
|
||||
|
||||
private static string GenerateBundleId(MirrorBundle manifest)
|
||||
{
|
||||
return $"{manifest.DomainId}-{manifest.GeneratedAt:yyyyMMddHHmmss}";
|
||||
}
|
||||
|
||||
private static string ComputeDigest(byte[] data)
|
||||
{
|
||||
return $"sha256:{Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private sealed record ManifestParseResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? ManifestPath { get; init; }
|
||||
public MirrorBundle? Manifest { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static ManifestParseResult Failed(string error) => new() { Success = false, Error = error };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for importing a mirror bundle.
|
||||
/// </summary>
|
||||
public sealed record MirrorImportRequest
|
||||
{
|
||||
public required string BundlePath { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public string? TrustRootsPath { get; init; }
|
||||
public bool DryRun { get; init; }
|
||||
public bool Force { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a mirror bundle import operation.
|
||||
/// </summary>
|
||||
public sealed record MirrorImportResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public string? ManifestPath { get; init; }
|
||||
public string? DomainId { get; init; }
|
||||
public string? DisplayName { get; init; }
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
public int ExportCount { get; init; }
|
||||
public ChecksumVerificationResult? ChecksumVerification { get; init; }
|
||||
public DsseVerificationResult? DsseVerification { get; init; }
|
||||
public IReadOnlyList<string> ImportedPaths { get; init; } = Array.Empty<string>();
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
public static MirrorImportResult Failed(string error) => new() { Success = false, Error = error };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of mirror bundle verification.
|
||||
/// </summary>
|
||||
public sealed record MirrorVerificationResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public string? ManifestPath { get; init; }
|
||||
public string? DomainId { get; init; }
|
||||
public ChecksumVerificationResult? ChecksumVerification { get; init; }
|
||||
public DsseVerificationResult? DsseVerification { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checksum verification results.
|
||||
/// </summary>
|
||||
public sealed record ChecksumVerificationResult
|
||||
{
|
||||
public bool ChecksumFileFound { get; init; }
|
||||
public bool AllValid { get; init; }
|
||||
public IReadOnlyList<FileChecksumResult> Results { get; init; } = Array.Empty<FileChecksumResult>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual file checksum result.
|
||||
/// </summary>
|
||||
public sealed record FileChecksumResult(string FileName, string Expected, string Actual, bool IsValid);
|
||||
|
||||
/// <summary>
|
||||
/// DSSE verification result.
|
||||
/// </summary>
|
||||
public sealed record DsseVerificationResult
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
public string? EnvelopePath { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -43,6 +43,7 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
@@ -64,6 +65,7 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Authority/__Libraries/StellaOps.Authority.Storage.Postgres/StellaOps.Authority.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Scheduler/__Libraries/StellaOps.Scheduler.Storage.Postgres/StellaOps.Scheduler.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Storage.Postgres/StellaOps.Concelier.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy.Storage.Postgres/StellaOps.Policy.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Notify/__Libraries/StellaOps.Notify.Storage.Postgres/StellaOps.Notify.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Storage.Postgres/StellaOps.Excititor.Storage.Postgres.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user