// Copyright (c) StellaOps. All rights reserved. // Licensed under AGPL-3.0-or-later. See LICENSE in the project root. using System.Diagnostics; using System.Globalization; using System.Runtime.InteropServices; using System.Text; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace StellaOps.BinaryIndex.Ghidra; /// /// Manages Ghidra Headless process lifecycle. /// Provides methods to run analysis with proper process isolation and cleanup. /// public sealed class GhidraHeadlessManager : IAsyncDisposable { private readonly GhidraOptions _options; private readonly ILogger _logger; private readonly SemaphoreSlim _semaphore; private bool _disposed; /// /// Creates a new GhidraHeadlessManager. /// /// Ghidra configuration options. /// Logger instance. public GhidraHeadlessManager( IOptions options, ILogger logger) { _options = options.Value; _logger = logger; _semaphore = new SemaphoreSlim(_options.MaxConcurrentInstances, _options.MaxConcurrentInstances); EnsureWorkDirectoryExists(); } /// /// Runs Ghidra analysis on a binary. /// /// Absolute path to the binary file. /// Name of the post-analysis script to run. /// Arguments to pass to the script. /// Whether to run full auto-analysis. /// Timeout in seconds (0 = use default). /// Cancellation token. /// Standard output from Ghidra. public async Task RunAnalysisAsync( string binaryPath, string? scriptName = null, string[]? scriptArgs = null, bool runAnalysis = true, int timeoutSeconds = 0, CancellationToken ct = default) { ObjectDisposedException.ThrowIf(_disposed, this); if (!File.Exists(binaryPath)) { throw new FileNotFoundException("Binary file not found", binaryPath); } var effectiveTimeout = timeoutSeconds > 0 ? timeoutSeconds : _options.DefaultTimeoutSeconds; await _semaphore.WaitAsync(ct); try { var projectDir = CreateTempProjectDirectory(); try { var args = BuildAnalyzeArgs(projectDir, binaryPath, scriptName, scriptArgs, runAnalysis); return await RunGhidraAsync(args, effectiveTimeout, ct); } finally { if (_options.CleanupTempProjects) { CleanupProjectDirectory(projectDir); } } } finally { _semaphore.Release(); } } /// /// Runs a Ghidra script on an existing project. /// /// Path to the Ghidra project directory. /// Name of the Ghidra project. /// Name of the script to run. /// Arguments to pass to the script. /// Timeout in seconds (0 = use default). /// Cancellation token. /// Standard output from Ghidra. public async Task RunScriptAsync( string projectDir, string projectName, string scriptName, string[]? scriptArgs = null, int timeoutSeconds = 0, CancellationToken ct = default) { ObjectDisposedException.ThrowIf(_disposed, this); if (!Directory.Exists(projectDir)) { throw new DirectoryNotFoundException($"Project directory not found: {projectDir}"); } var effectiveTimeout = timeoutSeconds > 0 ? timeoutSeconds : _options.DefaultTimeoutSeconds; await _semaphore.WaitAsync(ct); try { var args = BuildScriptArgs(projectDir, projectName, scriptName, scriptArgs); return await RunGhidraAsync(args, effectiveTimeout, ct); } finally { _semaphore.Release(); } } /// /// Checks if Ghidra is available and properly configured. /// /// Cancellation token. /// True if Ghidra is available. public async Task IsAvailableAsync(CancellationToken ct = default) { try { var executablePath = GetAnalyzeHeadlessPath(); if (!File.Exists(executablePath)) { _logger.LogDebug("Ghidra analyzeHeadless not found at: {Path}", executablePath); return false; } // Quick version check to verify Java is working var result = await RunGhidraAsync(["--help"], timeoutSeconds: 30, ct); return result.ExitCode == 0 || result.StandardOutput.Contains("analyzeHeadless", StringComparison.OrdinalIgnoreCase); } catch (Exception ex) { _logger.LogDebug(ex, "Ghidra availability check failed"); return false; } } /// /// Gets Ghidra version information. /// /// Cancellation token. /// Version string. public async Task GetVersionAsync(CancellationToken ct = default) { var result = await RunGhidraAsync(["--help"], timeoutSeconds: 30, ct); // Parse version from output - typically starts with "Ghidra X.Y" var lines = result.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries); foreach (var line in lines) { if (line.Contains("Ghidra", StringComparison.OrdinalIgnoreCase) && char.IsDigit(line.FirstOrDefault(c => char.IsDigit(c)))) { return line.Trim(); } } return "Unknown"; } private string CreateTempProjectDirectory() { var projectDir = Path.Combine( _options.WorkDir, $"project_{DateTime.UtcNow:yyyyMMddHHmmssfff}_{Guid.NewGuid():N}"); Directory.CreateDirectory(projectDir); _logger.LogDebug("Created temp project directory: {Path}", projectDir); return projectDir; } private void CleanupProjectDirectory(string projectDir) { try { if (Directory.Exists(projectDir)) { Directory.Delete(projectDir, recursive: true); _logger.LogDebug("Cleaned up project directory: {Path}", projectDir); } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to cleanup project directory: {Path}", projectDir); } } private void EnsureWorkDirectoryExists() { if (!Directory.Exists(_options.WorkDir)) { Directory.CreateDirectory(_options.WorkDir); _logger.LogInformation("Created Ghidra work directory: {Path}", _options.WorkDir); } } private string[] BuildAnalyzeArgs( string projectDir, string binaryPath, string? scriptName, string[]? scriptArgs, bool runAnalysis) { var args = new List { projectDir, "TempProject", "-import", binaryPath }; if (!runAnalysis) { args.Add("-noanalysis"); } if (!string.IsNullOrEmpty(scriptName)) { args.AddRange(["-postScript", scriptName]); if (scriptArgs is { Length: > 0 }) { args.AddRange(scriptArgs); } } if (!string.IsNullOrEmpty(_options.ScriptsDir)) { args.AddRange(["-scriptPath", _options.ScriptsDir]); } args.AddRange(["-max-cpu", _options.MaxCpu.ToString(CultureInfo.InvariantCulture)]); return [.. args]; } private static string[] BuildScriptArgs( string projectDir, string projectName, string scriptName, string[]? scriptArgs) { var args = new List { projectDir, projectName, "-postScript", scriptName }; if (scriptArgs is { Length: > 0 }) { args.AddRange(scriptArgs); } return [.. args]; } private async Task RunGhidraAsync( string[] args, int timeoutSeconds, CancellationToken ct) { var executablePath = GetAnalyzeHeadlessPath(); var startInfo = new ProcessStartInfo { FileName = executablePath, Arguments = string.Join(" ", args.Select(QuoteArg)), RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 }; ConfigureEnvironment(startInfo); _logger.LogDebug("Starting Ghidra: {Command} {Args}", executablePath, startInfo.Arguments); var stopwatch = Stopwatch.StartNew(); using var process = new Process { StartInfo = startInfo }; var stdoutBuilder = new StringBuilder(); var stderrBuilder = new StringBuilder(); process.OutputDataReceived += (_, e) => { if (e.Data is not null) { stdoutBuilder.AppendLine(e.Data); } }; process.ErrorDataReceived += (_, e) => { if (e.Data is not null) { stderrBuilder.AppendLine(e.Data); } }; if (!process.Start()) { throw new GhidraException("Failed to start Ghidra process"); } process.BeginOutputReadLine(); process.BeginErrorReadLine(); using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token); try { await process.WaitForExitAsync(linkedCts.Token); } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) { try { process.Kill(entireProcessTree: true); } catch { // Best effort kill } throw new GhidraTimeoutException(timeoutSeconds); } stopwatch.Stop(); var stdout = stdoutBuilder.ToString(); var stderr = stderrBuilder.ToString(); _logger.LogDebug( "Ghidra completed with exit code {ExitCode} in {Duration}ms", process.ExitCode, stopwatch.ElapsedMilliseconds); if (process.ExitCode != 0) { _logger.LogWarning("Ghidra failed: {Error}", stderr); } return new GhidraProcessResult( process.ExitCode, stdout, stderr, stopwatch.Elapsed); } private string GetAnalyzeHeadlessPath() { var basePath = Path.Combine(_options.GhidraHome, "support"); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { return Path.Combine(basePath, "analyzeHeadless.bat"); } return Path.Combine(basePath, "analyzeHeadless"); } private void ConfigureEnvironment(ProcessStartInfo startInfo) { if (!string.IsNullOrEmpty(_options.JavaHome)) { startInfo.EnvironmentVariables["JAVA_HOME"] = _options.JavaHome; } startInfo.EnvironmentVariables["MAXMEM"] = _options.MaxMemory; startInfo.EnvironmentVariables["GHIDRA_HOME"] = _options.GhidraHome; } private static string QuoteArg(string arg) { if (arg.Contains(' ', StringComparison.Ordinal) || arg.Contains('"', StringComparison.Ordinal)) { return $"\"{arg.Replace("\"", "\\\"")}\""; } return arg; } /// public async ValueTask DisposeAsync() { if (_disposed) { return; } _disposed = true; // Wait for any in-flight operations to complete for (var i = 0; i < _options.MaxConcurrentInstances; i++) { await _semaphore.WaitAsync(); } _semaphore.Dispose(); } } /// /// Result of a Ghidra process execution. /// /// Process exit code. /// Standard output content. /// Standard error content. /// Execution duration. public sealed record GhidraProcessResult( int ExitCode, string StandardOutput, string StandardError, TimeSpan Duration) { /// /// Whether the process completed successfully (exit code 0). /// public bool IsSuccess => ExitCode == 0; }