// 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;
}