using System.Collections.ObjectModel; using Microsoft.Extensions.Logging; using StellaOps.Scanner.Analyzers.OS.Abstractions; using StellaOps.Scanner.Analyzers.OS.Analyzers; using StellaOps.Scanner.Analyzers.OS.Helpers; namespace StellaOps.Scanner.Analyzers.OS.Homebrew; /// /// Analyzes Homebrew Cellar directories to extract installed formula information. /// Scans /usr/local/Cellar (Intel) and /opt/homebrew/Cellar (Apple Silicon) directories. /// internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase { private static readonly IReadOnlyList EmptyPackages = new ReadOnlyCollection(Array.Empty()); /// /// Default paths to scan for Homebrew Cellar directories. /// private static readonly string[] CellarPaths = [ "usr/local/Cellar", // Intel Macs "opt/homebrew/Cellar", // Apple Silicon Macs ]; /// /// Maximum traversal depth within Cellar to prevent runaway scanning. /// Formula structure: Cellar/{formula}/{version}/... /// private const int MaxTraversalDepth = 3; /// /// Maximum formula size in bytes (200MB default per design spec). /// private const long MaxFormulaSizeBytes = 200L * 1024L * 1024L; private readonly HomebrewReceiptParser _parser = new(); public HomebrewPackageAnalyzer(ILogger logger) : base(logger) { } public override string AnalyzerId => "homebrew"; protected override ValueTask> ExecuteCoreAsync( OSPackageAnalyzerContext context, CancellationToken cancellationToken) { var records = new List(); var warnings = new List(); foreach (var cellarRelativePath in CellarPaths) { var cellarPath = Path.Combine(context.RootPath, cellarRelativePath); if (!Directory.Exists(cellarPath)) { continue; } Logger.LogInformation("Scanning Homebrew Cellar at {Path}", cellarPath); try { DiscoverFormulas(cellarPath, records, warnings, cancellationToken); } catch (Exception ex) when (ex is not OperationCanceledException) { Logger.LogWarning(ex, "Failed to scan Homebrew Cellar at {Path}", cellarPath); } } if (records.Count == 0) { Logger.LogInformation("No Homebrew formulas found; skipping analyzer."); return ValueTask.FromResult>(EmptyPackages); } foreach (var warning in warnings) { Logger.LogWarning("Homebrew scan warning: {Warning}", warning); } Logger.LogInformation("Discovered {Count} Homebrew formulas", records.Count); // Sort for deterministic output records.Sort(); return ValueTask.FromResult>(records); } private void DiscoverFormulas( string cellarPath, List records, List warnings, CancellationToken cancellationToken) { // Enumerate formula directories (e.g., /usr/local/Cellar/openssl@3) foreach (var formulaDir in EnumerateDirectoriesSafe(cellarPath)) { cancellationToken.ThrowIfCancellationRequested(); var formulaName = Path.GetFileName(formulaDir); if (string.IsNullOrWhiteSpace(formulaName) || formulaName.StartsWith('.')) { continue; } // Enumerate version directories (e.g., /usr/local/Cellar/openssl@3/3.1.0) foreach (var versionDir in EnumerateDirectoriesSafe(formulaDir)) { cancellationToken.ThrowIfCancellationRequested(); var versionName = Path.GetFileName(versionDir); if (string.IsNullOrWhiteSpace(versionName) || versionName.StartsWith('.')) { continue; } // Check size guardrail if (!CheckFormulaSizeGuardrail(versionDir, out var sizeWarning)) { warnings.Add(sizeWarning!); continue; } // Look for INSTALL_RECEIPT.json var receiptPath = Path.Combine(versionDir, "INSTALL_RECEIPT.json"); if (File.Exists(receiptPath)) { var record = ParseReceiptAndCreateRecord(receiptPath, formulaName, versionName, versionDir); if (record is not null) { records.Add(record); } } else { // Fallback: create record from directory structure var record = CreateRecordFromDirectory(formulaName, versionName, versionDir); if (record is not null) { records.Add(record); warnings.Add($"No INSTALL_RECEIPT.json for {formulaName}@{versionName}; using directory-based discovery."); } } } } } private OSPackageRecord? ParseReceiptAndCreateRecord( string receiptPath, string formulaName, string versionFromDir, string versionDir) { var receipt = _parser.Parse(receiptPath); if (receipt is null) { Logger.LogWarning("Failed to parse INSTALL_RECEIPT.json at {Path}", receiptPath); return null; } // Use receipt version if available, fallback to directory name var version = !string.IsNullOrWhiteSpace(receipt.Version) ? receipt.Version : versionFromDir; // Build PURL per spec: pkg:brew//@?revision= var purl = PackageUrlBuilder.BuildHomebrew( receipt.Tap ?? "homebrew/core", receipt.Name ?? formulaName, version, receipt.Revision); var vendorMetadata = BuildVendorMetadata(receipt, versionDir); var files = DiscoverFormulaFiles(versionDir); return new OSPackageRecord( AnalyzerId, purl, receipt.Name ?? formulaName, version, receipt.Architecture, PackageEvidenceSource.HomebrewCellar, epoch: null, release: receipt.Revision > 0 ? receipt.Revision.ToString() : null, sourcePackage: receipt.Tap, license: receipt.License, cveHints: null, provides: null, depends: receipt.RuntimeDependencies, files: files, vendorMetadata: vendorMetadata); } private OSPackageRecord? CreateRecordFromDirectory( string formulaName, string version, string versionDir) { if (string.IsNullOrWhiteSpace(formulaName) || string.IsNullOrWhiteSpace(version)) { return null; } var purl = PackageUrlBuilder.BuildHomebrew("homebrew/core", formulaName, version, revision: 0); var architecture = DetectArchitectureFromPath(versionDir); var files = DiscoverFormulaFiles(versionDir); var vendorMetadata = new Dictionary(StringComparer.Ordinal) { ["brew:discovery_method"] = "directory", ["brew:install_path"] = versionDir, }; return new OSPackageRecord( AnalyzerId, purl, formulaName, version, architecture, PackageEvidenceSource.HomebrewCellar, epoch: null, release: null, sourcePackage: "homebrew/core", license: null, cveHints: null, provides: null, depends: null, files: files, vendorMetadata: vendorMetadata); } private static Dictionary BuildVendorMetadata(HomebrewReceipt receipt, string versionDir) { var metadata = new Dictionary(StringComparer.Ordinal) { ["brew:tap"] = receipt.Tap, ["brew:poured_from_bottle"] = receipt.PouredFromBottle.ToString().ToLowerInvariant(), ["brew:installed_as_dependency"] = receipt.InstalledAsDependency.ToString().ToLowerInvariant(), ["brew:installed_on_request"] = receipt.InstalledOnRequest.ToString().ToLowerInvariant(), ["brew:install_path"] = versionDir, }; if (receipt.InstallTime.HasValue) { var installTime = DateTimeOffset.FromUnixTimeSeconds(receipt.InstallTime.Value); metadata["brew:install_time"] = installTime.ToString("o"); } if (!string.IsNullOrWhiteSpace(receipt.Description)) { metadata["description"] = receipt.Description; } if (!string.IsNullOrWhiteSpace(receipt.Homepage)) { metadata["homepage"] = receipt.Homepage; } if (!string.IsNullOrWhiteSpace(receipt.SourceUrl)) { metadata["brew:source_url"] = receipt.SourceUrl; } if (!string.IsNullOrWhiteSpace(receipt.SourceChecksum)) { metadata["brew:source_checksum"] = receipt.SourceChecksum; } if (!string.IsNullOrWhiteSpace(receipt.BottleChecksum)) { metadata["brew:bottle_checksum"] = receipt.BottleChecksum; } return metadata; } private List DiscoverFormulaFiles(string versionDir) { var files = new List(); try { // Only discover key files to avoid excessive enumeration // Focus on bin/, lib/, include/, share/man directories var keyDirs = new[] { "bin", "lib", "include", "sbin" }; foreach (var keyDir in keyDirs) { var keyPath = Path.Combine(versionDir, keyDir); if (!Directory.Exists(keyPath)) { continue; } foreach (var file in Directory.EnumerateFiles(keyPath, "*", SearchOption.TopDirectoryOnly)) { var relativePath = Path.GetRelativePath(versionDir, file); files.Add(new OSPackageFileEvidence( relativePath, layerDigest: null, sha256: null, sizeBytes: null, isConfigFile: false)); } } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { // Ignore file enumeration errors } return files; } private static bool CheckFormulaSizeGuardrail(string versionDir, out string? warning) { warning = null; try { long totalSize = 0; foreach (var file in Directory.EnumerateFiles(versionDir, "*", SearchOption.AllDirectories)) { var info = new FileInfo(file); totalSize += info.Length; if (totalSize > MaxFormulaSizeBytes) { warning = $"Formula at {versionDir} exceeds {MaxFormulaSizeBytes / (1024 * 1024)}MB size limit; skipping."; return false; } } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { // Allow if we can't determine size } return true; } private static string DetectArchitectureFromPath(string path) { // /opt/homebrew is Apple Silicon (arm64) // /usr/local is Intel (x86_64) if (path.Contains("/opt/homebrew/", StringComparison.OrdinalIgnoreCase)) { return "arm64"; } return "x86_64"; } private static IEnumerable EnumerateDirectoriesSafe(string path) { try { return Directory.EnumerateDirectories(path); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return Array.Empty(); } } }