Initial commit (history squashed)
This commit is contained in:
		
							
								
								
									
										329
									
								
								src/StellaOps.Cli/Services/ScannerExecutor.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										329
									
								
								src/StellaOps.Cli/Services/ScannerExecutor.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,329 @@
 | 
			
		||||
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<ScannerExecutor> _logger;
 | 
			
		||||
 | 
			
		||||
    public ScannerExecutor(ILogger<ScannerExecutor> logger)
 | 
			
		||||
    {
 | 
			
		||||
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<ScannerExecutionResult> RunAsync(
 | 
			
		||||
        string runner,
 | 
			
		||||
        string entry,
 | 
			
		||||
        string targetDirectory,
 | 
			
		||||
        string resultsDirectory,
 | 
			
		||||
        IReadOnlyList<string> 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<string>(baselineFiles, StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
 | 
			
		||||
        var startInfo = BuildProcessStartInfo(runner, entry, normalizedTarget, resultsDirectory, arguments);
 | 
			
		||||
        using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
 | 
			
		||||
 | 
			
		||||
        var stdout = new List<string>();
 | 
			
		||||
        var stderr = new List<string>();
 | 
			
		||||
 | 
			
		||||
        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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> arguments,
 | 
			
		||||
        int exitCode,
 | 
			
		||||
        IReadOnlyList<string> stdout,
 | 
			
		||||
        IReadOnlyList<string> 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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user