using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.AirGap.Importer.Telemetry; using StellaOps.Determinism; using System.Globalization; using System.Text.Json; using System.Text.RegularExpressions; namespace StellaOps.AirGap.Importer.Quarantine; public sealed class FileSystemQuarantineService : IQuarantineService { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; private readonly QuarantineOptions _options; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private readonly IGuidProvider _guidProvider; public FileSystemQuarantineService( IOptions options, ILogger logger, TimeProvider timeProvider, IGuidProvider? guidProvider = null) { _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _guidProvider = guidProvider ?? SystemGuidProvider.Instance; } public async Task QuarantineAsync( QuarantineRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); ArgumentException.ThrowIfNullOrWhiteSpace(request.TenantId); ArgumentException.ThrowIfNullOrWhiteSpace(request.BundlePath); ArgumentException.ThrowIfNullOrWhiteSpace(request.ReasonCode); using var tenantScope = _logger.BeginTenantScope(request.TenantId); if (!File.Exists(request.BundlePath)) { return new QuarantineResult( Success: false, QuarantineId: "", QuarantinePath: "", QuarantinedAt: _timeProvider.GetUtcNow(), ErrorMessage: "bundle-path-not-found"); } var tenantRoot = Path.Combine(_options.QuarantineRoot, SanitizeForPathSegment(request.TenantId)); if (_options.EnableAutomaticCleanup && _options.RetentionPeriod > TimeSpan.Zero) { _ = await CleanupExpiredAsync(_options.RetentionPeriod, cancellationToken).ConfigureAwait(false); } if (_options.MaxQuarantineSizeBytes > 0) { var bundleSize = new FileInfo(request.BundlePath).Length; var currentSize = GetDirectorySizeBytes(tenantRoot); if (currentSize + bundleSize > _options.MaxQuarantineSizeBytes) { return new QuarantineResult( Success: false, QuarantineId: "", QuarantinePath: "", QuarantinedAt: _timeProvider.GetUtcNow(), ErrorMessage: "quarantine-quota-exceeded"); } } var now = _timeProvider.GetUtcNow(); var timestamp = now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); var sanitizedReason = SanitizeForPathSegment(request.ReasonCode); var quarantineId = $"{timestamp}-{sanitizedReason}-{_guidProvider.NewGuid():N}"; var quarantinePath = Path.Combine(tenantRoot, quarantineId); try { Directory.CreateDirectory(quarantinePath); var bundleDestination = Path.Combine(quarantinePath, "bundle.tar.zst"); File.Copy(request.BundlePath, bundleDestination, overwrite: false); if (request.ManifestJson is not null) { await File.WriteAllTextAsync( Path.Combine(quarantinePath, "manifest.json"), request.ManifestJson, cancellationToken).ConfigureAwait(false); } var verificationLogPath = Path.Combine(quarantinePath, "verification.log"); await File.WriteAllLinesAsync(verificationLogPath, request.VerificationLog, cancellationToken).ConfigureAwait(false); var failureReasonPath = Path.Combine(quarantinePath, "failure-reason.txt"); await File.WriteAllTextAsync( failureReasonPath, BuildFailureReasonText(request, now), cancellationToken).ConfigureAwait(false); var bundleSize = new FileInfo(bundleDestination).Length; var entry = new QuarantineEntry( QuarantineId: quarantineId, TenantId: request.TenantId, OriginalBundleName: Path.GetFileName(request.BundlePath), ReasonCode: request.ReasonCode, ReasonMessage: request.ReasonMessage, QuarantinedAt: now, BundleSizeBytes: bundleSize, QuarantinePath: quarantinePath); await File.WriteAllTextAsync( Path.Combine(quarantinePath, "quarantine.json"), JsonSerializer.Serialize(entry, JsonOptions), cancellationToken).ConfigureAwait(false); _logger.LogWarning( "offlinekit.quarantine created tenant_id={tenant_id} quarantine_id={quarantine_id} reason_code={reason_code} quarantine_path={quarantine_path} original_bundle={original_bundle}", request.TenantId, quarantineId, request.ReasonCode, quarantinePath, Path.GetFileName(request.BundlePath)); return new QuarantineResult( Success: true, QuarantineId: quarantineId, QuarantinePath: quarantinePath, QuarantinedAt: now); } catch (Exception ex) { _logger.LogError( ex, "offlinekit.quarantine failed tenant_id={tenant_id} quarantine_id={quarantine_id} quarantine_path={quarantine_path}", request.TenantId, quarantineId, quarantinePath); return new QuarantineResult( Success: false, QuarantineId: quarantineId, QuarantinePath: quarantinePath, QuarantinedAt: now, ErrorMessage: ex.Message); } } public async Task> ListAsync( string tenantId, QuarantineListOptions? options = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); options ??= new QuarantineListOptions(); var tenantRoot = Path.Combine(_options.QuarantineRoot, SanitizeForPathSegment(tenantId)); if (!Directory.Exists(tenantRoot)) { return Array.Empty(); } var entries = new List(); foreach (var dir in Directory.EnumerateDirectories(tenantRoot)) { cancellationToken.ThrowIfCancellationRequested(); if (Path.GetFileName(dir).Equals(".removed", StringComparison.OrdinalIgnoreCase)) { continue; } var jsonPath = Path.Combine(dir, "quarantine.json"); if (!File.Exists(jsonPath)) { continue; } try { var json = await File.ReadAllTextAsync(jsonPath, cancellationToken).ConfigureAwait(false); var entry = JsonSerializer.Deserialize(json, JsonOptions); if (entry is null) { continue; } if (!string.IsNullOrWhiteSpace(options.ReasonCodeFilter) && !entry.ReasonCode.Equals(options.ReasonCodeFilter, StringComparison.OrdinalIgnoreCase)) { continue; } if (options.Since is { } since && entry.QuarantinedAt < since) { continue; } if (options.Until is { } until && entry.QuarantinedAt > until) { continue; } entries.Add(entry); } catch { continue; } } return entries .OrderBy(e => e.QuarantinedAt) .ThenBy(e => e.QuarantineId, StringComparer.Ordinal) .Take(Math.Max(0, options.Limit)) .ToArray(); } public async Task RemoveAsync( string tenantId, string quarantineId, string removalReason, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentException.ThrowIfNullOrWhiteSpace(quarantineId); ArgumentException.ThrowIfNullOrWhiteSpace(removalReason); using var tenantScope = _logger.BeginTenantScope(tenantId); var tenantRoot = Path.Combine(_options.QuarantineRoot, SanitizeForPathSegment(tenantId)); var entryPath = Path.Combine(tenantRoot, quarantineId); if (!Directory.Exists(entryPath)) { return false; } var removalPath = Path.Combine(entryPath, "removal-reason.txt"); await File.WriteAllTextAsync( removalPath, $"RemovedAt: {_timeProvider.GetUtcNow():O}{Environment.NewLine}Reason: {removalReason}{Environment.NewLine}", cancellationToken).ConfigureAwait(false); var removedRoot = Path.Combine(tenantRoot, ".removed"); Directory.CreateDirectory(removedRoot); var removedPath = Path.Combine(removedRoot, quarantineId); if (Directory.Exists(removedPath)) { removedPath = Path.Combine(removedRoot, $"{quarantineId}-{_guidProvider.NewGuid():N}"); } Directory.Move(entryPath, removedPath); _logger.LogInformation( "offlinekit.quarantine removed tenant_id={tenant_id} quarantine_id={quarantine_id} removed_path={removed_path}", tenantId, quarantineId, removedPath); return true; } public Task CleanupExpiredAsync(TimeSpan retentionPeriod, CancellationToken cancellationToken = default) { if (retentionPeriod <= TimeSpan.Zero) { return Task.FromResult(0); } var now = _timeProvider.GetUtcNow(); var threshold = now - retentionPeriod; if (!Directory.Exists(_options.QuarantineRoot)) { return Task.FromResult(0); } var removedCount = 0; foreach (var tenantRoot in Directory.EnumerateDirectories(_options.QuarantineRoot)) { cancellationToken.ThrowIfCancellationRequested(); removedCount += CleanupExpiredInTenant(tenantRoot, threshold, cancellationToken); var removedRoot = Path.Combine(tenantRoot, ".removed"); if (Directory.Exists(removedRoot)) { removedCount += CleanupExpiredInTenant(removedRoot, threshold, cancellationToken); } } return Task.FromResult(removedCount); } private static int CleanupExpiredInTenant(string tenantRoot, DateTimeOffset threshold, CancellationToken cancellationToken) { var removedCount = 0; foreach (var dir in Directory.EnumerateDirectories(tenantRoot)) { cancellationToken.ThrowIfCancellationRequested(); var jsonPath = Path.Combine(dir, "quarantine.json"); if (!File.Exists(jsonPath)) { continue; } try { var json = File.ReadAllText(jsonPath); var entry = JsonSerializer.Deserialize(json, JsonOptions); if (entry is null) { continue; } if (entry.QuarantinedAt >= threshold) { continue; } Directory.Delete(dir, recursive: true); removedCount++; } catch { continue; } } return removedCount; } private static string BuildFailureReasonText(QuarantineRequest request, DateTimeOffset now) { var metadataLines = request.Metadata is null ? Array.Empty() : request.Metadata .OrderBy(kv => kv.Key, StringComparer.Ordinal) .Select(kv => $" {kv.Key}: {kv.Value}") .ToArray(); return $""" Quarantine Reason: {request.ReasonCode} Message: {request.ReasonMessage} Timestamp: {now:O} Tenant: {request.TenantId} Original Bundle: {Path.GetFileName(request.BundlePath)} Metadata: {string.Join(Environment.NewLine, metadataLines)} """; } private static string SanitizeForPathSegment(string input) { input = input.Trim(); if (input.Length == 0) { return "_"; } return Regex.Replace(input, @"[^a-zA-Z0-9_-]", "_"); } private static long GetDirectorySizeBytes(string directory) { if (!Directory.Exists(directory)) { return 0; } long total = 0; foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)) { try { total += new FileInfo(file).Length; } catch { continue; } } return total; } }