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('\\', '/');
}
}