using System.Collections.Immutable; using System.Web; namespace StellaOps.Scanner.Analyzers.Lang.Bun.Internal; /// /// Represents a discovered Bun/npm package with evidence. /// internal sealed class BunPackage { private readonly List _occurrencePaths = []; private BunPackage(string name, string version) { Name = name; Version = version; Purl = BuildPurl(name, version); ComponentKey = $"purl::{Purl}"; } public string Name { get; } public string Version { get; } public string Purl { get; } public string ComponentKey { get; } public string? Resolved { get; private init; } public string? Integrity { get; private init; } public string? Source { get; private init; } public bool IsPrivate { get; private init; } public bool IsDev { get; private init; } public bool IsOptional { get; private init; } public bool IsPeer { get; private init; } /// /// Source type: npm, git, tarball, file, link, workspace. /// public string SourceType { get; private init; } = "npm"; /// /// Git commit hash for git dependencies. /// public string? GitCommit { get; private init; } /// /// Original specifier (e.g., "github:user/repo#tag"). /// public string? Specifier { get; private init; } /// /// Direct dependencies of this package (for transitive analysis). /// public IReadOnlyList Dependencies { get; private init; } = Array.Empty(); /// /// Whether this is a direct dependency (in root package.json) or transitive. /// public bool IsDirect { get; set; } /// /// Whether this package has been patched (via patchedDependencies or .patches directory). /// public bool IsPatched { get; set; } /// /// Path to the patch file if this package is patched. /// public string? PatchFile { get; set; } /// /// Custom registry URL if this package comes from a non-default registry. /// public string? CustomRegistry { get; set; } /// /// Logical path where this package was found (may be symlink). /// public string? LogicalPath { get; private init; } /// /// Real path after resolving symlinks. /// public string? RealPath { get; private init; } /// /// All filesystem paths where this package (name@version) was found. /// public IReadOnlyList OccurrencePaths => _occurrencePaths.ToImmutableArray(); public void AddOccurrence(string path) { if (!string.IsNullOrWhiteSpace(path) && !_occurrencePaths.Contains(path, StringComparer.Ordinal)) { _occurrencePaths.Add(path); } } public static BunPackage FromPackageJson( string name, string version, string logicalPath, string? realPath, bool isPrivate, BunLockEntry? lockEntry) { return new BunPackage(name, version) { LogicalPath = logicalPath, RealPath = realPath, IsPrivate = isPrivate, Source = "node_modules", Resolved = lockEntry?.Resolved, Integrity = lockEntry?.Integrity, IsDev = lockEntry?.IsDev ?? false, IsOptional = lockEntry?.IsOptional ?? false, IsPeer = lockEntry?.IsPeer ?? false, SourceType = lockEntry?.SourceType ?? "npm", GitCommit = lockEntry?.GitCommit, Specifier = lockEntry?.Specifier, Dependencies = lockEntry?.Dependencies ?? Array.Empty() }; } public static BunPackage FromLockEntry(BunLockEntry entry, string source) { ArgumentNullException.ThrowIfNull(entry); return new BunPackage(entry.Name, entry.Version) { Source = source, Resolved = entry.Resolved, Integrity = entry.Integrity, IsDev = entry.IsDev, IsOptional = entry.IsOptional, IsPeer = entry.IsPeer, SourceType = entry.SourceType, GitCommit = entry.GitCommit, Specifier = entry.Specifier, Dependencies = entry.Dependencies }; } public IEnumerable> CreateMetadata() { var metadata = new SortedDictionary(StringComparer.Ordinal); if (!string.IsNullOrEmpty(LogicalPath)) { metadata["path"] = NormalizePath(LogicalPath); } if (!string.IsNullOrEmpty(RealPath) && RealPath != LogicalPath) { metadata["realPath"] = NormalizePath(RealPath); } if (!string.IsNullOrEmpty(Source)) { metadata["source"] = Source; } if (!string.IsNullOrEmpty(Resolved)) { metadata["resolved"] = Resolved; } if (!string.IsNullOrEmpty(Integrity)) { metadata["integrity"] = Integrity; } if (IsPrivate) { metadata["private"] = "true"; } if (!string.IsNullOrEmpty(CustomRegistry)) { metadata["customRegistry"] = CustomRegistry; } if (IsDev) { metadata["dev"] = "true"; } if (IsDirect) { metadata["direct"] = "true"; } if (!string.IsNullOrEmpty(GitCommit)) { metadata["gitCommit"] = GitCommit; } if (IsOptional) { metadata["optional"] = "true"; } metadata["packageManager"] = "bun"; if (IsPatched) { metadata["patched"] = "true"; } if (!string.IsNullOrEmpty(PatchFile)) { metadata["patchFile"] = NormalizePath(PatchFile); } if (IsPeer) { metadata["peer"] = "true"; } if (SourceType != "npm") { metadata["sourceType"] = SourceType; } if (!string.IsNullOrEmpty(Specifier)) { metadata["specifier"] = Specifier; } if (_occurrencePaths.Count > 1) { metadata["occurrences"] = string.Join(";", _occurrencePaths.Select(NormalizePath).Order(StringComparer.Ordinal)); } return metadata; } public IEnumerable CreateEvidence() { var evidence = new List(); if (!string.IsNullOrEmpty(LogicalPath)) { evidence.Add(new LanguageComponentEvidence( LanguageEvidenceKind.File, Source ?? "node_modules", NormalizePath(Path.Combine(LogicalPath, "package.json")), null, null)); } if (!string.IsNullOrEmpty(Resolved)) { evidence.Add(new LanguageComponentEvidence( LanguageEvidenceKind.Metadata, "resolved", "bun.lock", Resolved, null)); } if (!string.IsNullOrEmpty(Integrity)) { evidence.Add(new LanguageComponentEvidence( LanguageEvidenceKind.Metadata, "integrity", "bun.lock", Integrity, null)); } return evidence; } private static string BuildPurl(string name, string version) { // pkg:npm/@ // Scoped packages: @scope/name → %40scope/name var encodedName = name.StartsWith('@') ? $"%40{HttpUtility.UrlEncode(name[1..]).Replace("%2f", "/", StringComparison.OrdinalIgnoreCase)}" : HttpUtility.UrlEncode(name); return $"pkg:npm/{encodedName}@{version}"; } private static string NormalizePath(string path) { // Normalize to forward slashes for cross-platform consistency return path.Replace('\\', '/'); } }