feat: Add Bun language analyzer and related functionality
- Implemented BunPackageNormalizer to deduplicate packages by name and version. - Created BunProjectDiscoverer to identify Bun project roots in the filesystem. - Added project files for the Bun analyzer including manifest and project configuration. - Developed comprehensive tests for Bun language analyzer covering various scenarios. - Included fixture files for testing standard installs, isolated linker installs, lockfile-only scenarios, and workspaces. - Established stubs for authentication sessions to facilitate testing in the web application.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Web;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a discovered Bun/npm package with evidence.
|
||||
/// </summary>
|
||||
internal sealed class BunPackage
|
||||
{
|
||||
private readonly List<string> _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; }
|
||||
|
||||
/// <summary>
|
||||
/// Logical path where this package was found (may be symlink).
|
||||
/// </summary>
|
||||
public string? LogicalPath { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Real path after resolving symlinks.
|
||||
/// </summary>
|
||||
public string? RealPath { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// All filesystem paths where this package (name@version) was found.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> 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
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
var metadata = new SortedDictionary<string, string?>(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 (IsDev)
|
||||
{
|
||||
metadata["dev"] = "true";
|
||||
}
|
||||
|
||||
metadata["packageManager"] = "bun";
|
||||
|
||||
if (_occurrencePaths.Count > 1)
|
||||
{
|
||||
metadata["occurrences"] = string.Join(";", _occurrencePaths.Select(NormalizePath).Order(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
public IEnumerable<LanguageComponentEvidence> CreateEvidence()
|
||||
{
|
||||
var evidence = new List<LanguageComponentEvidence>();
|
||||
|
||||
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/<name>@<version>
|
||||
// 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('\\', '/');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user