using System.Globalization; using System.Net; namespace ScadaLink.CLI.Commands; /// /// Filter + destination arguments for an audit export invocation. Mirrors the /// Bundle B 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. 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); } /// /// Executes the export: GETs /api/audit/export and copies the response body /// stream directly to . The body is never fully /// buffered — streams in fixed-size chunks. /// 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. public static async Task RunExportAsync( ManagementHttpClient client, AuditExportArgs args, TextWriter output, DateTimeOffset now) { var qs = BuildQueryString(args, now); HttpResponseMessage response; try { response = await client.SendGetStreamAsync("api/audit/export" + qs, CancellationToken.None); } 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(); 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(); OutputFormatter.WriteError( string.IsNullOrWhiteSpace(message) ? $"Export failed (HTTP {(int)response.StatusCode})." : message, "ERROR"); return 1; } await using var source = await response.Content.ReadAsStreamAsync(); await using var destination = new FileStream( args.Output, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: true); await source.CopyToAsync(destination); } output.WriteLine($"Exported audit log to {args.Output}"); return 0; } }