using System.Globalization; using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using StellaOps.Signals.Models; namespace StellaOps.Zastava.Observer.Runtime.ProcSnapshot; /// /// Collects PHP autoload information from Composer-based applications. /// Parses composer.json, composer.lock, and vendor/autoload.php for package metadata. /// internal sealed partial class PhpAutoloadCollector { private static readonly Regex PhpProcessRegex = GeneratePhpRegex(); private const int MaxAutoloadEntries = 1024; private const long MaxComposerLockSize = 10 * 1024 * 1024; // 10 MiB private readonly string _procRoot; private readonly ILogger _logger; public PhpAutoloadCollector(string procRoot, ILogger logger) { _procRoot = procRoot?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) ?? throw new ArgumentNullException(nameof(procRoot)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// /// Check if a process appears to be a PHP process. /// public async Task IsPhpProcessAsync(int pid, CancellationToken cancellationToken) { var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false); if (cmdline.Count == 0) { return false; } return PhpProcessRegex.IsMatch(cmdline[0]); } /// /// Collect autoload entries from a PHP process. /// public async Task> CollectAsync(int pid, CancellationToken cancellationToken) { var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false); if (cmdline.Count == 0) { return Array.Empty(); } if (!PhpProcessRegex.IsMatch(cmdline[0])) { _logger.LogDebug("Process {Pid} is not a PHP process", pid); return Array.Empty(); } // Find the application root by looking for composer.json var appRoot = await FindApplicationRootAsync(pid, cmdline, cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(appRoot)) { _logger.LogDebug("No composer.json found for PHP process {Pid}", pid); return Array.Empty(); } // Parse composer.lock for installed packages var installedPackages = await ParseComposerLockAsync(appRoot, cancellationToken).ConfigureAwait(false); // Collect autoload entries var entries = new List(); // Parse composer.json for autoload configuration await CollectAutoloadFromComposerJsonAsync(appRoot, installedPackages, entries, cancellationToken).ConfigureAwait(false); // Scan vendor directory for additional package autoloads await CollectVendorPackagesAsync(appRoot, installedPackages, entries, cancellationToken).ConfigureAwait(false); _logger.LogDebug("Collected {Count} autoload entries for PHP process {Pid}", entries.Count, pid); return entries.Take(MaxAutoloadEntries).ToList(); } private async Task> ReadCmdlineAsync(int pid, CancellationToken cancellationToken) { var cmdlinePath = Path.Combine(_procRoot, pid.ToString(CultureInfo.InvariantCulture), "cmdline"); if (!File.Exists(cmdlinePath)) { return new List(); } try { var content = await File.ReadAllBytesAsync(cmdlinePath, cancellationToken).ConfigureAwait(false); if (content.Length == 0) { return new List(); } return System.Text.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(); } } private async Task FindApplicationRootAsync( int pid, IReadOnlyList cmdline, CancellationToken cancellationToken) { var candidateDirs = new List(); // Check script path from cmdline foreach (var arg in cmdline.Skip(1)) { if (arg.EndsWith(".php", StringComparison.OrdinalIgnoreCase) && File.Exists(arg)) { var scriptDir = Path.GetDirectoryName(arg); if (!string.IsNullOrEmpty(scriptDir)) { candidateDirs.Add(scriptDir); } break; } } // Check process working directory var cwdLink = Path.Combine(_procRoot, pid.ToString(CultureInfo.InvariantCulture), "cwd"); try { if (Directory.Exists(cwdLink)) { var cwd = Path.GetFullPath(cwdLink); candidateDirs.Add(cwd); } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { _logger.LogDebug(ex, "Failed to read cwd for PID {Pid}", pid); } // Search up the directory tree for composer.json foreach (var startDir in candidateDirs) { var dir = startDir; for (var i = 0; i < 10 && !string.IsNullOrEmpty(dir); i++) // Max 10 levels up { var composerPath = Path.Combine(dir, "composer.json"); if (File.Exists(composerPath)) { return dir; } dir = Path.GetDirectoryName(dir); } } return null; } private async Task> ParseComposerLockAsync( string appRoot, CancellationToken cancellationToken) { var packages = new Dictionary(StringComparer.OrdinalIgnoreCase); var lockPath = Path.Combine(appRoot, "composer.lock"); if (!File.Exists(lockPath)) { return packages; } try { var fileInfo = new FileInfo(lockPath); if (fileInfo.Length > MaxComposerLockSize) { _logger.LogDebug("composer.lock too large: {Path} ({Size} bytes)", lockPath, fileInfo.Length); return packages; } await using var stream = new FileStream(lockPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); var root = doc.RootElement; // Parse packages array foreach (var section in new[] { "packages", "packages-dev" }) { if (!root.TryGetProperty(section, out var packagesArray)) { continue; } foreach (var package in packagesArray.EnumerateArray()) { if (!package.TryGetProperty("name", out var nameElement)) { continue; } var name = nameElement.GetString(); if (string.IsNullOrWhiteSpace(name)) { continue; } var pkg = new ComposerPackage { Name = name }; if (package.TryGetProperty("version", out var versionElement)) { pkg.Version = versionElement.GetString(); } if (package.TryGetProperty("autoload", out var autoload)) { pkg.Autoload = ParseAutoloadSection(autoload); } packages[name] = pkg; } } } catch (Exception ex) when (ex is JsonException or IOException) { _logger.LogDebug(ex, "Failed to parse composer.lock: {Path}", lockPath); } return packages; } private async Task CollectAutoloadFromComposerJsonAsync( string appRoot, Dictionary installedPackages, List entries, CancellationToken cancellationToken) { var composerPath = Path.Combine(appRoot, "composer.json"); if (!File.Exists(composerPath)) { return; } try { await using var stream = new FileStream(composerPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); var root = doc.RootElement; // Get project name for PURL string? projectName = null; if (root.TryGetProperty("name", out var nameElement)) { projectName = nameElement.GetString(); } // Parse autoload sections foreach (var section in new[] { "autoload", "autoload-dev" }) { if (!root.TryGetProperty(section, out var autoload)) { continue; } var autoloadConfig = ParseAutoloadSection(autoload); AddAutoloadEntries(appRoot, autoloadConfig, projectName, null, entries); } } catch (Exception ex) when (ex is JsonException or IOException) { _logger.LogDebug(ex, "Failed to parse composer.json: {Path}", composerPath); } } private async Task CollectVendorPackagesAsync( string appRoot, Dictionary installedPackages, List entries, CancellationToken cancellationToken) { var vendorDir = Path.Combine(appRoot, "vendor"); if (!Directory.Exists(vendorDir)) { return; } foreach (var package in installedPackages.Values) { cancellationToken.ThrowIfCancellationRequested(); if (package.Autoload == null) { continue; } var packageDir = Path.Combine(vendorDir, package.Name.Replace('/', Path.DirectorySeparatorChar)); if (!Directory.Exists(packageDir)) { continue; } AddAutoloadEntries(packageDir, package.Autoload, package.Name, package.Version, entries); } } private void AddAutoloadEntries( string baseDir, AutoloadConfig autoload, string? packageName, string? packageVersion, List entries) { // PSR-4 foreach (var (ns, paths) in autoload.Psr4) { foreach (var path in paths) { var fullPath = Path.Combine(baseDir, path.TrimStart('/', '\\')); entries.Add(CreateEntry("psr-4", ns, fullPath, packageName, packageVersion)); } } // PSR-0 foreach (var (ns, paths) in autoload.Psr0) { foreach (var path in paths) { var fullPath = Path.Combine(baseDir, path.TrimStart('/', '\\')); entries.Add(CreateEntry("psr-0", ns, fullPath, packageName, packageVersion)); } } // Classmap foreach (var path in autoload.Classmap) { var fullPath = Path.Combine(baseDir, path.TrimStart('/', '\\')); entries.Add(CreateEntry("classmap", null, fullPath, packageName, packageVersion)); } // Files foreach (var path in autoload.Files) { var fullPath = Path.Combine(baseDir, path.TrimStart('/', '\\')); entries.Add(CreateEntry("files", null, fullPath, packageName, packageVersion)); } } private static AutoloadPathEntry CreateEntry( string type, string? ns, string path, string? packageName, string? packageVersion) { var entry = new AutoloadPathEntry { Type = type, Namespace = ns, Path = path, ComposerPackage = packageName, ComposerVersion = packageVersion }; if (!string.IsNullOrWhiteSpace(packageName)) { // Composer package names are vendor/package format var purl = $"pkg:composer/{packageName}"; if (!string.IsNullOrWhiteSpace(packageVersion)) { // Normalize version (remove 'v' prefix if present) var version = packageVersion.TrimStart('v', 'V'); purl += $"@{version}"; } entry = new AutoloadPathEntry { Type = entry.Type, Namespace = entry.Namespace, Path = entry.Path, ComposerPackage = entry.ComposerPackage, ComposerVersion = entry.ComposerVersion, Purl = purl }; } return entry; } private static AutoloadConfig ParseAutoloadSection(JsonElement autoload) { var config = new AutoloadConfig(); // PSR-4 if (autoload.TryGetProperty("psr-4", out var psr4)) { foreach (var item in psr4.EnumerateObject()) { var ns = item.Name; var paths = new List(); if (item.Value.ValueKind == JsonValueKind.String) { var path = item.Value.GetString(); if (!string.IsNullOrEmpty(path)) { paths.Add(path); } } else if (item.Value.ValueKind == JsonValueKind.Array) { foreach (var pathElement in item.Value.EnumerateArray()) { var path = pathElement.GetString(); if (!string.IsNullOrEmpty(path)) { paths.Add(path); } } } if (paths.Count > 0) { config.Psr4[ns] = paths; } } } // PSR-0 if (autoload.TryGetProperty("psr-0", out var psr0)) { foreach (var item in psr0.EnumerateObject()) { var ns = item.Name; var paths = new List(); if (item.Value.ValueKind == JsonValueKind.String) { var path = item.Value.GetString(); if (!string.IsNullOrEmpty(path)) { paths.Add(path); } } else if (item.Value.ValueKind == JsonValueKind.Array) { foreach (var pathElement in item.Value.EnumerateArray()) { var path = pathElement.GetString(); if (!string.IsNullOrEmpty(path)) { paths.Add(path); } } } if (paths.Count > 0) { config.Psr0[ns] = paths; } } } // Classmap if (autoload.TryGetProperty("classmap", out var classmap) && classmap.ValueKind == JsonValueKind.Array) { foreach (var item in classmap.EnumerateArray()) { var path = item.GetString(); if (!string.IsNullOrEmpty(path)) { config.Classmap.Add(path); } } } // Files if (autoload.TryGetProperty("files", out var files) && files.ValueKind == JsonValueKind.Array) { foreach (var item in files.EnumerateArray()) { var path = item.GetString(); if (!string.IsNullOrEmpty(path)) { config.Files.Add(path); } } } return config; } [GeneratedRegex(@"(^|/)(php|php-fpm|php-cgi|php\d+(\.\d+)?)(\.exe)?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex GeneratePhpRegex(); private sealed class ComposerPackage { public string Name { get; init; } = string.Empty; public string? Version { get; set; } public AutoloadConfig? Autoload { get; set; } } private sealed class AutoloadConfig { public Dictionary> Psr4 { get; } = new(StringComparer.Ordinal); public Dictionary> Psr0 { get; } = new(StringComparer.Ordinal); public List Classmap { get; } = new(); public List Files { get; } = new(); } }