Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
console-runner-image / build-runner-image (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
514 lines
17 KiB
C#
514 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Collects PHP autoload information from Composer-based applications.
|
|
/// Parses composer.json, composer.lock, and vendor/autoload.php for package metadata.
|
|
/// </summary>
|
|
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<PhpAutoloadCollector> _logger;
|
|
|
|
public PhpAutoloadCollector(string procRoot, ILogger<PhpAutoloadCollector> 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 PHP process.
|
|
/// </summary>
|
|
public async Task<bool> IsPhpProcessAsync(int pid, CancellationToken cancellationToken)
|
|
{
|
|
var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false);
|
|
if (cmdline.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return PhpProcessRegex.IsMatch(cmdline[0]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Collect autoload entries from a PHP process.
|
|
/// </summary>
|
|
public async Task<IReadOnlyList<AutoloadPathEntry>> CollectAsync(int pid, CancellationToken cancellationToken)
|
|
{
|
|
var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false);
|
|
if (cmdline.Count == 0)
|
|
{
|
|
return Array.Empty<AutoloadPathEntry>();
|
|
}
|
|
|
|
if (!PhpProcessRegex.IsMatch(cmdline[0]))
|
|
{
|
|
_logger.LogDebug("Process {Pid} is not a PHP process", pid);
|
|
return Array.Empty<AutoloadPathEntry>();
|
|
}
|
|
|
|
// 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<AutoloadPathEntry>();
|
|
}
|
|
|
|
// Parse composer.lock for installed packages
|
|
var installedPackages = await ParseComposerLockAsync(appRoot, cancellationToken).ConfigureAwait(false);
|
|
|
|
// Collect autoload entries
|
|
var entries = new List<AutoloadPathEntry>();
|
|
|
|
// 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<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 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<string>();
|
|
}
|
|
}
|
|
|
|
private async Task<string?> FindApplicationRootAsync(
|
|
int pid,
|
|
IReadOnlyList<string> cmdline,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var candidateDirs = new List<string>();
|
|
|
|
// 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<Dictionary<string, ComposerPackage>> ParseComposerLockAsync(
|
|
string appRoot,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var packages = new Dictionary<string, ComposerPackage>(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<string, ComposerPackage> installedPackages,
|
|
List<AutoloadPathEntry> 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<string, ComposerPackage> installedPackages,
|
|
List<AutoloadPathEntry> 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<AutoloadPathEntry> 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<string>();
|
|
|
|
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<string>();
|
|
|
|
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<string, List<string>> Psr4 { get; } = new(StringComparer.Ordinal);
|
|
public Dictionary<string, List<string>> Psr0 { get; } = new(StringComparer.Ordinal);
|
|
public List<string> Classmap { get; } = new();
|
|
public List<string> Files { get; } = new();
|
|
}
|
|
}
|