using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using System.Text.Json; namespace StellaOps.Cli.Services; internal sealed class ScannerExecutor : IScannerExecutor { private readonly ILogger _logger; public ScannerExecutor(ILogger logger) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task RunAsync( string runner, string entry, string targetDirectory, string resultsDirectory, IReadOnlyList arguments, bool verbose, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(targetDirectory)) { throw new ArgumentException("Target directory must be provided.", nameof(targetDirectory)); } runner = string.IsNullOrWhiteSpace(runner) ? "docker" : runner.Trim().ToLowerInvariant(); entry = entry?.Trim() ?? string.Empty; var normalizedTarget = Path.GetFullPath(targetDirectory); if (!Directory.Exists(normalizedTarget)) { throw new DirectoryNotFoundException($"Scan target directory '{normalizedTarget}' does not exist."); } resultsDirectory = string.IsNullOrWhiteSpace(resultsDirectory) ? Path.Combine(Directory.GetCurrentDirectory(), "scan-results") : Path.GetFullPath(resultsDirectory); Directory.CreateDirectory(resultsDirectory); var executionTimestamp = DateTimeOffset.UtcNow; var baselineFiles = Directory.GetFiles(resultsDirectory, "*", SearchOption.AllDirectories); var baseline = new HashSet(baselineFiles, StringComparer.OrdinalIgnoreCase); var startInfo = BuildProcessStartInfo(runner, entry, normalizedTarget, resultsDirectory, arguments); using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; var stdout = new List(); var stderr = new List(); process.OutputDataReceived += (_, args) => { if (args.Data is null) { return; } stdout.Add(args.Data); if (verbose) { _logger.LogInformation("[scan] {Line}", args.Data); } }; process.ErrorDataReceived += (_, args) => { if (args.Data is null) { return; } stderr.Add(args.Data); _logger.LogError("[scan] {Line}", args.Data); }; _logger.LogInformation("Launching scanner via {Runner} (entry: {Entry})...", runner, entry); if (!process.Start()) { throw new InvalidOperationException("Failed to start scanner process."); } process.BeginOutputReadLine(); process.BeginErrorReadLine(); await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); var completionTimestamp = DateTimeOffset.UtcNow; if (process.ExitCode == 0) { _logger.LogInformation("Scanner completed successfully."); } else { _logger.LogWarning("Scanner exited with code {Code}.", process.ExitCode); } var resultsPath = ResolveResultsPath(resultsDirectory, executionTimestamp, baseline); if (string.IsNullOrWhiteSpace(resultsPath)) { resultsPath = CreatePlaceholderResult(resultsDirectory); } var metadataPath = WriteRunMetadata( resultsDirectory, executionTimestamp, completionTimestamp, runner, entry, normalizedTarget, resultsPath, arguments, process.ExitCode, stdout, stderr); return new ScannerExecutionResult(process.ExitCode, resultsPath, metadataPath); } private ProcessStartInfo BuildProcessStartInfo( string runner, string entry, string targetDirectory, string resultsDirectory, IReadOnlyList args) { return runner switch { "self" or "native" => BuildNativeStartInfo(entry, args), "dotnet" => BuildDotNetStartInfo(entry, args), "docker" => BuildDockerStartInfo(entry, targetDirectory, resultsDirectory, args), _ => BuildCustomRunnerStartInfo(runner, entry, args) }; } private static ProcessStartInfo BuildNativeStartInfo(string binaryPath, IReadOnlyList args) { if (string.IsNullOrWhiteSpace(binaryPath) || !File.Exists(binaryPath)) { throw new FileNotFoundException("Scanner entrypoint not found.", binaryPath); } var startInfo = new ProcessStartInfo { FileName = binaryPath, WorkingDirectory = Directory.GetCurrentDirectory() }; foreach (var argument in args) { startInfo.ArgumentList.Add(argument); } startInfo.RedirectStandardError = true; startInfo.RedirectStandardOutput = true; startInfo.UseShellExecute = false; return startInfo; } private static ProcessStartInfo BuildDotNetStartInfo(string binaryPath, IReadOnlyList args) { var startInfo = new ProcessStartInfo { FileName = "dotnet", WorkingDirectory = Directory.GetCurrentDirectory() }; startInfo.ArgumentList.Add(binaryPath); foreach (var argument in args) { startInfo.ArgumentList.Add(argument); } startInfo.RedirectStandardError = true; startInfo.RedirectStandardOutput = true; startInfo.UseShellExecute = false; return startInfo; } private static ProcessStartInfo BuildDockerStartInfo(string image, string targetDirectory, string resultsDirectory, IReadOnlyList args) { if (string.IsNullOrWhiteSpace(image)) { throw new ArgumentException("Docker image must be provided when runner is 'docker'.", nameof(image)); } var cwd = Directory.GetCurrentDirectory(); var startInfo = new ProcessStartInfo { FileName = "docker", WorkingDirectory = cwd }; startInfo.ArgumentList.Add("run"); startInfo.ArgumentList.Add("--rm"); startInfo.ArgumentList.Add("-v"); startInfo.ArgumentList.Add($"{cwd}:{cwd}"); startInfo.ArgumentList.Add("-v"); startInfo.ArgumentList.Add($"{targetDirectory}:/scan-target:ro"); startInfo.ArgumentList.Add("-v"); startInfo.ArgumentList.Add($"{resultsDirectory}:/scan-results"); startInfo.ArgumentList.Add("-w"); startInfo.ArgumentList.Add(cwd); startInfo.ArgumentList.Add(image); startInfo.ArgumentList.Add("--target"); startInfo.ArgumentList.Add("/scan-target"); startInfo.ArgumentList.Add("--output"); startInfo.ArgumentList.Add("/scan-results/scan.json"); foreach (var argument in args) { startInfo.ArgumentList.Add(argument); } startInfo.RedirectStandardError = true; startInfo.RedirectStandardOutput = true; startInfo.UseShellExecute = false; return startInfo; } private static ProcessStartInfo BuildCustomRunnerStartInfo(string runner, string entry, IReadOnlyList args) { var startInfo = new ProcessStartInfo { FileName = runner, WorkingDirectory = Directory.GetCurrentDirectory() }; if (!string.IsNullOrWhiteSpace(entry)) { startInfo.ArgumentList.Add(entry); } foreach (var argument in args) { startInfo.ArgumentList.Add(argument); } startInfo.RedirectStandardError = true; startInfo.RedirectStandardOutput = true; startInfo.UseShellExecute = false; return startInfo; } private static string ResolveResultsPath(string resultsDirectory, DateTimeOffset startTimestamp, HashSet baseline) { var candidates = Directory.GetFiles(resultsDirectory, "*", SearchOption.AllDirectories); string? newest = null; DateTimeOffset newestTimestamp = startTimestamp; foreach (var candidate in candidates) { if (baseline.Contains(candidate)) { continue; } var info = new FileInfo(candidate); if (info.LastWriteTimeUtc >= newestTimestamp) { newestTimestamp = info.LastWriteTimeUtc; newest = candidate; } } return newest ?? string.Empty; } private static string CreatePlaceholderResult(string resultsDirectory) { var fileName = $"scan-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json"; var path = Path.Combine(resultsDirectory, fileName); File.WriteAllText(path, "{\"status\":\"placeholder\"}"); return path; } private static string WriteRunMetadata( string resultsDirectory, DateTimeOffset startedAt, DateTimeOffset completedAt, string runner, string entry, string targetDirectory, string resultsPath, IReadOnlyList arguments, int exitCode, IReadOnlyList stdout, IReadOnlyList stderr) { var duration = completedAt - startedAt; var payload = new { runner, entry, targetDirectory, resultsPath, arguments, exitCode, startedAt = startedAt, completedAt = completedAt, durationSeconds = Math.Round(duration.TotalSeconds, 3, MidpointRounding.AwayFromZero), stdout, stderr }; var fileName = $"scan-run-{startedAt:yyyyMMddHHmmssfff}.json"; var path = Path.Combine(resultsDirectory, fileName); var options = new JsonSerializerOptions { WriteIndented = true }; var json = JsonSerializer.Serialize(payload, options); File.WriteAllText(path, json); return path; } }