using System.Globalization; using System.Net; using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; /// /// Filter + destination arguments for an audit export invocation. Mirrors the /// GET /api/audit/export parameters. /// /// /// are multi-valued — each supplied value becomes a repeated query-string param so /// the server's multi-value IN (…) filter sees the full set, exactly like /// the audit query subcommand. /// public sealed class AuditExportArgs { /// /// Start timestamp for the export time window. /// public string Since { get; set; } = string.Empty; /// /// End timestamp for the export time window. /// public string Until { get; set; } = string.Empty; /// /// Export format (e.g., 'json', 'csv', 'parquet'). /// public string Format { get; set; } = string.Empty; /// /// Output file path for the exported audit log. /// public string Output { get; set; } = string.Empty; /// /// Channel filter values (repeated query parameter). /// public string[] Channel { get; set; } = Array.Empty(); /// /// Kind filter values (repeated query parameter). /// public string[] Kind { get; set; } = Array.Empty(); /// /// Status filter values (repeated query parameter). /// public string[] Status { get; set; } = Array.Empty(); /// /// Site identifier filter values (repeated query parameter). /// public string[] Site { get; set; } = Array.Empty(); /// /// Optional target system filter. /// public string? Target { get; set; } /// /// Optional actor/user filter. /// public string? Actor { get; set; } } /// /// Helpers for the audit export subcommand: builds the export query string and /// streams the HTTP response body straight to the destination file without buffering /// the (potentially multi-megabyte) export in memory. /// public static class AuditExportHelpers { /// /// Builds the ?... query string for GET /api/audit/export: the required /// time window + format, plus optional filters. Time-specs are resolved via /// . The multi-valued /// --channel/--kind/--status/--site filters each emit ONE /// repeated query-string key per value (e.g. channel=A&channel=B) so the /// server's multi-value IN (…) filter receives the full set — mirroring /// . /// /// The export arguments containing filters and format. /// The current time for resolving relative time specifications. /// The full query string (including the leading ?) for the export endpoint. public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now) { var parts = new List(); void Add(string key, string? value) { if (!string.IsNullOrWhiteSpace(value)) parts.Add($"{key}={Uri.EscapeDataString(value)}"); } void AddEach(string key, IReadOnlyList values) { foreach (var value in values) { Add(key, value); } } Add("fromUtc", AuditQueryHelpers.ResolveTimeSpec(args.Since, now).ToString("o", CultureInfo.InvariantCulture)); Add("toUtc", AuditQueryHelpers.ResolveTimeSpec(args.Until, now).ToString("o", CultureInfo.InvariantCulture)); Add("format", args.Format); AddEach("channel", args.Channel); AddEach("kind", args.Kind); AddEach("status", args.Status); AddEach("sourceSiteId", args.Site); Add("target", args.Target); Add("actor", args.Actor); return "?" + string.Join("&", parts); } /// /// Deadline for receiving the export response's headers (i.e. the server has /// started responding), measured from the start of the request. This is the /// one bound does NOT /// carry on its own — 's HttpClient.Timeout /// is deliberately (see its doc comment) /// and bounds only the TCP/TLS /// connect phase, not a connected-but-silent server. Reuses /// as the standard CLI /// per-call default. /// public static readonly TimeSpan DefaultHeadersTimeout = ManagementHttpClient.DefaultConnectTimeout; /// /// Inactivity deadline for the streaming body copy: reset on every chunk /// successfully read, so a large export keeps streaming indefinitely as long /// as bytes keep arriving, but a stream that goes silent mid-transfer (a /// hung/crashed server holding the connection open) is bounded rather than /// hanging the CLI forever — the residual gap the timeout-honesty fix /// (ManagementHttpClient.DefaultConnectTimeout doc comment) left in this /// one path. /// public static readonly TimeSpan DefaultInactivityTimeout = ManagementHttpClient.DefaultConnectTimeout; /// /// Executes the export: GETs /api/audit/export and copies the response body /// stream directly to . The body is never fully /// buffered — the copy streams in fixed-size chunks via . /// A 501 Not Implemented (parquet not yet supported server-side) prints the /// server message and returns a non-zero exit code. /// /// The management HTTP client for API communication. /// The export arguments containing filters and output file path. /// Text writer for command output messages. /// The current time for resolving relative time specifications. /// /// External cancellation (e.g. the CLI's Ctrl-C handler) — cancelling this /// stops the export promptly at either the headers wait or the next stream /// read, with a "cancelled" (not timed-out) message. /// /// /// Override for — test seam. /// /// /// Override for — test seam. /// /// 0 on success, 1 on general error, or 2 on authorization failure. public static async Task RunExportAsync( ManagementHttpClient client, AuditExportArgs args, TextWriter output, DateTimeOffset now, CancellationToken cancellationToken = default, TimeSpan? headersTimeout = null, TimeSpan? inactivityTimeout = null) { var qs = BuildQueryString(args, now); using var headersCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); headersCts.CancelAfter(headersTimeout ?? DefaultHeadersTimeout); HttpResponseMessage response; try { response = await client.SendGetStreamAsync("api/audit/export" + qs, headersCts.Token); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { OutputFormatter.WriteError("Export cancelled.", "CANCELLED"); return 1; } catch (OperationCanceledException) { OutputFormatter.WriteError("Request timed out waiting for the server to respond.", "TIMEOUT"); return 1; } catch (HttpRequestException ex) { OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED"); return 1; } using (response) { if (response.StatusCode == HttpStatusCode.NotImplemented) { var message = await response.Content.ReadAsStringAsync(cancellationToken); OutputFormatter.WriteError( string.IsNullOrWhiteSpace(message) ? "Export format not implemented by the server." : message, "NOT_IMPLEMENTED"); return 1; } if (!response.IsSuccessStatusCode) { var message = await response.Content.ReadAsStringAsync(cancellationToken); // Honour the documented "authorization failure → exit 2" // contract on the REST audit surface as well. HTTP 403 is the // primary signal; the server may also surface UNAUTHORIZED / // FORBIDDEN via the JSON error envelope on a status other than 403. var errorCode = TryExtractErrorCode(message); var isAuthFailure = (int)response.StatusCode == 403 || string.Equals(errorCode, "FORBIDDEN", StringComparison.OrdinalIgnoreCase) || string.Equals(errorCode, "UNAUTHORIZED", StringComparison.OrdinalIgnoreCase); OutputFormatter.WriteError( string.IsNullOrWhiteSpace(message) ? $"Export failed (HTTP {(int)response.StatusCode})." : message, errorCode ?? "ERROR"); return isAuthFailure ? 2 : 1; } await using var source = await response.Content.ReadAsStreamAsync(cancellationToken); await using var destination = new FileStream( args.Output, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: true); try { await CopyWithInactivityTimeoutAsync( source, destination, inactivityTimeout ?? DefaultInactivityTimeout, cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { OutputFormatter.WriteError($"Export cancelled; partial file written to {args.Output}.", "CANCELLED"); return 1; } catch (OperationCanceledException) { OutputFormatter.WriteError( "Export stalled — no data received from the server for " + $"{(inactivityTimeout ?? DefaultInactivityTimeout).TotalSeconds:0}s; " + $"partial file written to {args.Output}.", "TIMEOUT"); return 1; } catch (Exception ex) when (ex is IOException or HttpRequestException) { OutputFormatter.WriteError( $"Export failed while streaming the response: {ex.Message}; partial file written to {args.Output}.", "STREAM_FAILED"); return 1; } } output.WriteLine($"Exported audit log to {args.Output}"); return 0; } /// /// Copies to in fixed-size /// chunks, rearming an inactivity deadline before every read so the copy is bounded /// only by silence, not by total size — a multi-hundred-MB export keeps streaming as /// long as bytes keep arriving. (external, e.g. /// Ctrl-C) and the inactivity deadline share one linked token; callers distinguish the /// two causes via cancellationToken.IsCancellationRequested after catching /// . /// private static async Task CopyWithInactivityTimeoutAsync( Stream source, Stream destination, TimeSpan inactivityTimeout, CancellationToken cancellationToken) { using var readCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var buffer = new byte[81920]; while (true) { readCts.CancelAfter(inactivityTimeout); var bytesRead = await source.ReadAsync(buffer.AsMemory(), readCts.Token); if (bytesRead == 0) { break; } await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); } } /// /// Best-effort parse of the server's JSON error envelope ({ "error": ..., "code": ... }) /// to extract the code field. Returns null if the body is empty, not valid JSON, or /// has no code property — callers fall back to "ERROR" in that case. /// /// The HTTP response body string to parse for an error code. /// The code string from the JSON error envelope, or null if absent or unparseable. internal static string? TryExtractErrorCode(string body) { if (string.IsNullOrWhiteSpace(body)) return null; try { using var doc = JsonDocument.Parse(body); if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("code", out var codeProp) && codeProp.ValueKind == JsonValueKind.String) { return codeProp.GetString(); } } catch (JsonException) { // Body is not a JSON envelope (e.g. an HTML proxy error page); no code to extract. } return null; } }