using System.Globalization; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; namespace ScadaLink.CLI.Commands; /// /// Filter arguments for an audit query invocation. Mirrors the Bundle B /// GET /api/audit/query filter parameters; / /// are time-specs (relative like 1h/7d, or absolute ISO-8601). /// public sealed class AuditQueryArgs { public string? Since { get; set; } public string? Until { get; set; } public string? Channel { get; set; } public string? Kind { get; set; } public string? Status { get; set; } public string? Site { get; set; } public string? Target { get; set; } public string? Actor { get; set; } public string? CorrelationId { get; set; } public bool ErrorsOnly { get; set; } public int PageSize { get; set; } = 100; } /// /// Pure helpers for the audit query subcommand: time-spec resolution, query-string /// construction, and the keyset-cursor paging loop. Kept separate from the command wiring /// so each piece is unit-testable without standing up the command tree. /// public static class AuditQueryHelpers { // where unit is s/m/h/d — a relative offset back from "now". private static readonly Regex RelativeSpec = new(@"^(\d+)([smhd])$", RegexOptions.Compiled); /// /// Resolves a time-spec to an absolute . Accepts a /// relative offset (30s, 15m, 1h, 7d) interpreted as /// minus the offset, or an absolute ISO-8601 timestamp. /// /// The spec is neither a known relative form nor a parseable ISO-8601 timestamp. public static DateTimeOffset ResolveTimeSpec(string spec, DateTimeOffset now) { if (string.IsNullOrWhiteSpace(spec)) throw new FormatException("Empty time value."); var match = RelativeSpec.Match(spec.Trim()); if (match.Success) { var amount = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); var offset = match.Groups[2].Value switch { "s" => TimeSpan.FromSeconds(amount), "m" => TimeSpan.FromMinutes(amount), "h" => TimeSpan.FromHours(amount), "d" => TimeSpan.FromDays(amount), _ => throw new FormatException($"Unknown time unit in '{spec}'."), }; return now - offset; } if (DateTimeOffset.TryParse(spec, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var absolute)) { return absolute; } throw new FormatException( $"Invalid time value '{spec}'. Use a relative offset (e.g. 1h, 24h, 7d) or an ISO-8601 timestamp."); } /// /// Builds the ?... query string for GET /api/audit/query from the filter /// args plus an optional keyset cursor. Unset filters are omitted. --errors-only /// maps to status=Failed (the server takes a single status value). /// public static string BuildQueryString( AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId) { var parts = new List(); void Add(string key, string? value) { if (!string.IsNullOrWhiteSpace(value)) parts.Add($"{key}={Uri.EscapeDataString(value)}"); } if (!string.IsNullOrWhiteSpace(args.Since)) Add("fromUtc", ResolveTimeSpec(args.Since!, now).ToString("o", CultureInfo.InvariantCulture)); if (!string.IsNullOrWhiteSpace(args.Until)) Add("toUtc", ResolveTimeSpec(args.Until!, now).ToString("o", CultureInfo.InvariantCulture)); Add("channel", args.Channel); Add("kind", args.Kind); // --errors-only is a convenience shorthand for the single-value Failed status // filter. The server's status filter accepts one value, so --errors-only and an // explicit --status are mutually exclusive in effect; --errors-only wins. Add("status", args.ErrorsOnly ? "Failed" : args.Status); Add("sourceSiteId", args.Site); Add("target", args.Target); Add("actor", args.Actor); Add("correlationId", args.CorrelationId); Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture)); if (afterOccurredAtUtc.HasValue) Add("afterOccurredAtUtc", afterOccurredAtUtc.Value.ToString("o", CultureInfo.InvariantCulture)); Add("afterEventId", afterEventId); return parts.Count == 0 ? string.Empty : "?" + string.Join("&", parts); } /// /// Executes the query: GETs /api/audit/query, renders each page with /// , and — when is set — /// follows nextCursor until the server returns a null cursor. Returns the /// process exit code (0 success, non-zero on HTTP/transport error). /// public static async Task RunQueryAsync( ManagementHttpClient client, AuditQueryArgs args, bool fetchAll, IAuditFormatter formatter, TextWriter output, DateTimeOffset now) { DateTimeOffset? afterOccurredAtUtc = null; string? afterEventId = null; while (true) { var qs = BuildQueryString(args, now, afterOccurredAtUtc, afterEventId); var response = await client.SendGetAsync("api/audit/query" + qs, TimeSpan.FromSeconds(30)); if (response.JsonData == null) { OutputFormatter.WriteError( response.Error ?? "Audit query failed.", response.ErrorCode ?? "ERROR"); return 1; } using var doc = JsonDocument.Parse(response.JsonData); var root = doc.RootElement; var events = root.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array ? evts.EnumerateArray().ToList() : new List(); formatter.WritePage(events, output); output.Flush(); if (!fetchAll) return 0; if (!root.TryGetProperty("nextCursor", out var cursor) || cursor.ValueKind != JsonValueKind.Object) { return 0; } afterOccurredAtUtc = cursor.TryGetProperty("afterOccurredAtUtc", out var c1) && c1.ValueKind == JsonValueKind.String ? DateTimeOffset.Parse(c1.GetString()!, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal) : null; afterEventId = cursor.TryGetProperty("afterEventId", out var c2) && c2.ValueKind == JsonValueKind.String ? c2.GetString() : null; // A malformed cursor (object present but missing both keys) would loop // forever — treat it as the end of results. if (afterOccurredAtUtc == null && afterEventId == null) return 0; } } }