Files
git.stella-ops.org/src/Zastava/StellaOps.Zastava.Observer/Runtime/ProcSnapshot/JavaClasspathCollector.cs
2026-02-01 21:37:40 +02:00

420 lines
14 KiB
C#

using Microsoft.Extensions.Logging;
using StellaOps.Signals.Models;
using System.Globalization;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace StellaOps.Zastava.Observer.Runtime.ProcSnapshot;
/// <summary>
/// Collects Java classpath information from a running JVM process.
/// Parses /proc/<pid>/cmdline for -cp/-classpath arguments and extracts JAR metadata.
/// </summary>
internal sealed partial class JavaClasspathCollector
{
private static readonly Regex JavaRegex = GenerateJavaRegex();
private static readonly char[] ClasspathSeparators = { ':', ';' };
private const int MaxJarFiles = 256;
private const long MaxJarSize = 100 * 1024 * 1024; // 100 MiB
private readonly string _procRoot;
private readonly ILogger<JavaClasspathCollector> _logger;
public JavaClasspathCollector(string procRoot, ILogger<JavaClasspathCollector> logger)
{
_procRoot = procRoot?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
?? throw new ArgumentNullException(nameof(procRoot));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Check if a process appears to be a Java process.
/// </summary>
public async Task<bool> IsJavaProcessAsync(int pid, CancellationToken cancellationToken)
{
var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false);
if (cmdline.Count == 0)
{
return false;
}
return JavaRegex.IsMatch(cmdline[0]);
}
/// <summary>
/// Collect classpath entries from a Java process.
/// </summary>
public async Task<IReadOnlyList<ClasspathEntry>> CollectAsync(int pid, CancellationToken cancellationToken)
{
var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false);
if (cmdline.Count == 0)
{
return Array.Empty<ClasspathEntry>();
}
if (!JavaRegex.IsMatch(cmdline[0]))
{
_logger.LogDebug("Process {Pid} is not a Java process", pid);
return Array.Empty<ClasspathEntry>();
}
var classpathValue = ExtractClasspath(cmdline);
if (string.IsNullOrWhiteSpace(classpathValue))
{
// Try to find classpath from environment or use jcmd if available
classpathValue = await TryGetClasspathFromJcmdAsync(pid, cancellationToken).ConfigureAwait(false);
}
if (string.IsNullOrWhiteSpace(classpathValue))
{
_logger.LogDebug("No classpath found for Java process {Pid}", pid);
return Array.Empty<ClasspathEntry>();
}
var entries = new List<ClasspathEntry>();
var paths = classpathValue.Split(ClasspathSeparators, StringSplitOptions.RemoveEmptyEntries);
foreach (var path in paths.Take(MaxJarFiles))
{
cancellationToken.ThrowIfCancellationRequested();
var entry = await ProcessClasspathEntryAsync(path.Trim(), cancellationToken).ConfigureAwait(false);
if (entry != null)
{
entries.Add(entry);
}
}
_logger.LogDebug("Collected {Count} classpath entries for Java process {Pid}", entries.Count, pid);
return entries;
}
private async Task<List<string>> ReadCmdlineAsync(int pid, CancellationToken cancellationToken)
{
var cmdlinePath = Path.Combine(_procRoot, pid.ToString(CultureInfo.InvariantCulture), "cmdline");
if (!File.Exists(cmdlinePath))
{
return new List<string>();
}
try
{
var content = await File.ReadAllBytesAsync(cmdlinePath, cancellationToken).ConfigureAwait(false);
if (content.Length == 0)
{
return new List<string>();
}
return Encoding.UTF8.GetString(content)
.Split('\0', StringSplitOptions.RemoveEmptyEntries)
.ToList();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogDebug(ex, "Failed to read cmdline for PID {Pid}", pid);
return new List<string>();
}
}
private static string? ExtractClasspath(IReadOnlyList<string> cmdline)
{
for (var i = 0; i < cmdline.Count; i++)
{
var arg = cmdline[i];
// -cp <classpath> or -classpath <classpath>
if ((string.Equals(arg, "-cp", StringComparison.Ordinal) ||
string.Equals(arg, "-classpath", StringComparison.Ordinal)) &&
i + 1 < cmdline.Count)
{
return cmdline[i + 1];
}
// -cp:<classpath> or -classpath:<classpath> (some JVMs)
if (arg.StartsWith("-cp:", StringComparison.Ordinal))
{
return arg[4..];
}
if (arg.StartsWith("-classpath:", StringComparison.Ordinal))
{
return arg[11..];
}
// -jar <jarfile> - the jar file is effectively the classpath
if (string.Equals(arg, "-jar", StringComparison.Ordinal) && i + 1 < cmdline.Count)
{
return cmdline[i + 1];
}
}
return null;
}
private async Task<string?> TryGetClasspathFromJcmdAsync(int pid, CancellationToken cancellationToken)
{
// Try to use jcmd to get the classpath
// This requires jcmd to be available and the process to be accessible
try
{
var jcmdPath = FindJcmd();
if (jcmdPath == null)
{
return null;
}
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = jcmdPath,
Arguments = $"{pid} VM.system_properties",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = System.Diagnostics.Process.Start(startInfo);
if (process == null)
{
return null;
}
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (process.ExitCode != 0)
{
return null;
}
// Parse java.class.path from output
foreach (var line in output.Split('\n'))
{
if (line.StartsWith("java.class.path=", StringComparison.Ordinal))
{
return line[16..].Trim();
}
}
return null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to get classpath from jcmd for PID {Pid}", pid);
return null;
}
}
private static string? FindJcmd()
{
var javaHome = Environment.GetEnvironmentVariable("JAVA_HOME");
if (!string.IsNullOrWhiteSpace(javaHome))
{
var jcmdPath = Path.Combine(javaHome, "bin", "jcmd");
if (File.Exists(jcmdPath))
{
return jcmdPath;
}
}
// Try common paths
var paths = new[]
{
"/usr/bin/jcmd",
"/usr/local/bin/jcmd",
"/opt/java/bin/jcmd"
};
return paths.FirstOrDefault(File.Exists);
}
private async Task<ClasspathEntry?> ProcessClasspathEntryAsync(string path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var type = DetermineEntryType(path);
if (type == "jar" && File.Exists(path))
{
return await ProcessJarFileAsync(path, cancellationToken).ConfigureAwait(false);
}
if (type == "directory" && Directory.Exists(path))
{
return new ClasspathEntry
{
Path = path,
Type = "directory"
};
}
// Entry doesn't exist or is a wildcard
return new ClasspathEntry
{
Path = path,
Type = type
};
}
private async Task<ClasspathEntry> ProcessJarFileAsync(string jarPath, CancellationToken cancellationToken)
{
var entry = new ClasspathEntry
{
Path = jarPath,
Type = "jar"
};
try
{
var fileInfo = new FileInfo(jarPath);
entry = entry with { SizeBytes = fileInfo.Length };
if (fileInfo.Length <= MaxJarSize)
{
// Compute hash
var hash = await ComputeFileHashAsync(jarPath, cancellationToken).ConfigureAwait(false);
entry = entry with { Sha256 = hash };
// Try to extract Maven coordinates from manifest
var (groupId, artifactId, version) = await ExtractMavenCoordinatesAsync(jarPath, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(groupId) && !string.IsNullOrWhiteSpace(artifactId))
{
var coordinate = string.IsNullOrWhiteSpace(version)
? $"{groupId}:{artifactId}"
: $"{groupId}:{artifactId}:{version}";
entry = entry with
{
MavenCoordinate = coordinate,
Purl = $"pkg:maven/{groupId}/{artifactId}" + (string.IsNullOrWhiteSpace(version) ? "" : $"@{version}")
};
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
_logger.LogDebug(ex, "Failed to process JAR file: {Path}", jarPath);
}
return entry;
}
private static async Task<string> ComputeFileHashAsync(string path, CancellationToken cancellationToken)
{
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static async Task<(string? groupId, string? artifactId, string? version)> ExtractMavenCoordinatesAsync(
string jarPath,
CancellationToken cancellationToken)
{
try
{
await using var stream = new FileStream(jarPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
// Try to find pom.properties in META-INF/maven/
var pomProperties = archive.Entries
.FirstOrDefault(e => e.FullName.EndsWith("pom.properties", StringComparison.OrdinalIgnoreCase) &&
e.FullName.StartsWith("META-INF/maven/", StringComparison.OrdinalIgnoreCase));
if (pomProperties != null)
{
await using var entryStream = pomProperties.Open();
using var reader = new StreamReader(entryStream);
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
string? groupId = null, artifactId = null, version = null;
foreach (var line in content.Split('\n'))
{
var trimmed = line.Trim();
if (trimmed.StartsWith("groupId=", StringComparison.Ordinal))
{
groupId = trimmed[8..];
}
else if (trimmed.StartsWith("artifactId=", StringComparison.Ordinal))
{
artifactId = trimmed[11..];
}
else if (trimmed.StartsWith("version=", StringComparison.Ordinal))
{
version = trimmed[8..];
}
}
return (groupId, artifactId, version);
}
// Fallback: try to parse from MANIFEST.MF
var manifest = archive.GetEntry("META-INF/MANIFEST.MF");
if (manifest != null)
{
await using var entryStream = manifest.Open();
using var reader = new StreamReader(entryStream);
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
string? implTitle = null, implVersion = null, implVendor = null;
foreach (var line in content.Split('\n'))
{
var trimmed = line.Trim();
if (trimmed.StartsWith("Implementation-Title:", StringComparison.OrdinalIgnoreCase))
{
implTitle = trimmed[21..].Trim();
}
else if (trimmed.StartsWith("Implementation-Version:", StringComparison.OrdinalIgnoreCase))
{
implVersion = trimmed[23..].Trim();
}
else if (trimmed.StartsWith("Implementation-Vendor-Id:", StringComparison.OrdinalIgnoreCase))
{
implVendor = trimmed[25..].Trim();
}
}
if (!string.IsNullOrWhiteSpace(implTitle))
{
return (implVendor, implTitle, implVersion);
}
}
}
catch
{
// Ignore errors extracting coordinates
}
return (null, null, null);
}
private static string DetermineEntryType(string path)
{
if (path.EndsWith(".jar", StringComparison.OrdinalIgnoreCase))
{
return "jar";
}
if (path.EndsWith(".jmod", StringComparison.OrdinalIgnoreCase))
{
return "jmod";
}
if (path.EndsWith("/*", StringComparison.Ordinal) || path.EndsWith("\\*", StringComparison.Ordinal))
{
return "wildcard";
}
return "directory";
}
[GeneratedRegex(@"(^|/)(java|javaw)(\.exe)?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex GenerateJavaRegex();
}