feat: add support for Bun lockfile (#4625)

---------
Signed-off-by: Yoonho Hann <hnnynh125@gmail.com>
Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
Co-authored-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
This commit is contained in:
Yoonho Hann 2026-06-10 02:22:43 +09:00 committed by GitHub
parent 63232bf725
commit b08d3c2970
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 5434 additions and 4 deletions

5
go.mod
View File

@ -101,7 +101,10 @@ require (
modernc.org/sqlite v1.51.0
)
require github.com/pb33f/ordered-map/v2 v2.3.1
require (
github.com/pb33f/ordered-map/v2 v2.3.1
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd
)
require (
cel.dev/expr v0.25.1 // indirect

2
go.sum
View File

@ -924,6 +924,8 @@ github.com/sylabs/sif/v2 v2.24.0 h1:1wB5uMDUQYjk8AckTySaDcP9YnpMb1LyDRr1Jt9A10w=
github.com/sylabs/sif/v2 v2.24.0/go.mod h1:DbXWqWZ1hdLSU+K9ipdds5AmZeHWsyxCOj/oQakBa88=
github.com/sylabs/squashfs v1.0.6 h1:PvJcDzxr+vIm2kH56mEMbaOzvGu79gK7P7IX+R7BDZI=
github.com/sylabs/squashfs v1.0.6/go.mod h1:DlDeUawVXLWAsSRa085Eo0ZenGzAB32JdAUFaB0LZfE=
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA=
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo=
github.com/terminalstatic/go-xsd-validate v0.1.6 h1:TenYeQ3eY631qNi1/cTmLH/s2slHPRKTTHT+XSHkepo=
github.com/terminalstatic/go-xsd-validate v0.1.6/go.mod h1:18lsvYFofBflqCrvo1umpABZ99+GneNTw2kEEc8UPJw=
github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw=

View File

@ -3,12 +3,13 @@ package internal
const (
// JSONSchemaVersion is the current schema version output by the JSON encoder
// This is roughly following the "SchemaVer" guidelines for versioning the JSON schema. Please see schema/json/README.md for details on how to increment.
JSONSchemaVersion = "16.1.3"
JSONSchemaVersion = "16.1.4"
// Changelog
// 16.1.0 - reformulated the python pdm fields (added "URL" and removed the unused "path" field).
// 16.1.1 - correct elf package osCpe field according to the document of systemd (also add appCpe field)
// 16.1.2 - placeholder for 16.1.2 changelog
// 16.1.3 - add GGUFFileParts to GGUFFileHeader metadata
// 16.1.4 - add BunLockEntry metadata type for bun.lock support
)

View File

@ -11,6 +11,7 @@ func AllTypes() []any {
pkg.ApkDBEntry{},
pkg.BinarySignature{},
pkg.BitnamiSBOMEntry{},
pkg.BunLockEntry{},
pkg.CocoaPodfileLockEntry{},
pkg.ConanV1LockEntry{},
pkg.ConanV2LockEntry{},

View File

@ -96,6 +96,7 @@ var jsonTypes = makeJSONTypes(
jsonNames(pkg.NpmPackageLockEntry{}, "javascript-npm-package-lock-entry", "NpmPackageLockJsonMetadata"),
jsonNames(pkg.YarnLockEntry{}, "javascript-yarn-lock-entry", "YarnLockJsonMetadata"),
jsonNames(pkg.PnpmLockEntry{}, "javascript-pnpm-lock-entry"),
jsonNames(pkg.BunLockEntry{}, "javascript-bun-lock-entry"),
jsonNames(pkg.PEBinary{}, "pe-binary"),
jsonNames(pkg.PhpComposerLockEntry{}, "php-composer-lock-entry", "PhpComposerJsonMetadata"),
jsonNamesWithoutLookup(pkg.PhpComposerInstalledEntry{}, "php-composer-installed-entry", "PhpComposerJsonMetadata"), // the legacy value is split into two types, where the other is preferred

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "anchore.io/schema/syft/json/16.1.3/document",
"$id": "anchore.io/schema/syft/json/16.1.4/document",
"$ref": "#/$defs/Document",
"$defs": {
"AlpmDbEntry": {
@ -1918,6 +1918,61 @@
"type": "object",
"description": "JavaVMRelease represents JVM version and build information extracted from the release file in a Java installation."
},
"JavascriptBunLockEntry": {
"properties": {
"integrity": {
"type": "string",
"description": "Integrity is Subresource Integrity hash for verification (SRI format)"
},
"dependencies": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"description": "Dependencies is a map of runtime dependencies and their version specifiers"
},
"optionalDependencies": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"description": "OptionalDependencies is a map of optional dependencies and their version specifiers"
},
"peerDependencies": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"description": "PeerDependencies is a map of peer dependencies and their version specifiers"
},
"bin": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"description": "Bin is a map of binary names to the paths they are installed to"
},
"os": {
"type": "string",
"description": "OS is the operating system constraint for the package (e.g. \"darwin\")"
},
"cpu": {
"type": "string",
"description": "CPU is the CPU architecture constraint for the package (e.g. \"arm64\")"
}
},
"type": "object",
"required": [
"integrity",
"dependencies",
"optionalDependencies",
"peerDependencies",
"bin",
"os",
"cpu"
],
"description": "BunLockEntry represents a single entry in the \"packages\" section of a bun.lock file"
},
"JavascriptNpmPackage": {
"properties": {
"name": {
@ -2657,6 +2712,9 @@
{
"$ref": "#/$defs/JavaJvmInstallation"
},
{
"$ref": "#/$defs/JavascriptBunLockEntry"
},
{
"$ref": "#/$defs/JavascriptNpmPackage"
},

View File

@ -13,6 +13,7 @@ func Test_OriginatorSupplier(t *testing.T) {
completionTester := packagemetadata.NewCompletionTester(t,
pkg.BinarySignature{},
pkg.BitnamiSBOMEntry{},
pkg.BunLockEntry{},
pkg.CocoaPodfileLockEntry{},
pkg.ConanV1LockEntry{},
pkg.ConanV2LockEntry{}, // the field Username might be the username of either the package originator or the supplier (unclear currently)

View File

@ -29,6 +29,38 @@ catalogers:
- npm
- package
parsers: # AUTO-GENERATED structure
- function: parseBunLock
detector: # AUTO-GENERATED
method: glob # AUTO-GENERATED
criteria: # AUTO-GENERATED
- '**/bun.lock'
metadata_types: # AUTO-GENERATED
- pkg.BunLockEntry
package_types: # AUTO-GENERATED
- npm
json_schema_types: # AUTO-GENERATED
- JavascriptBunLockEntry
capabilities: # MANUAL - preserved across regeneration
- name: license
default: false
- name: dependency.depth
default:
- direct
- indirect
- name: dependency.edges
default: ""
- name: dependency.kinds
default:
- runtime
- dev
- name: package_manager.files.listing
default: false
- name: package_manager.files.digests
default: false
- name: package_manager.package_integrity_hash
default: true
evidence:
- BunLockEntry.Integrity
- function: parsePnpmLock
detector: # AUTO-GENERATED
method: glob # AUTO-GENERATED

View File

@ -19,8 +19,10 @@ func NewLockCataloger(cfg CatalogerConfig) pkg.Cataloger {
yarnLockAdapter := newGenericYarnLockAdapter(cfg)
packageLockAdapter := newGenericPackageLockAdapter(cfg)
pnpmLockAdapter := newGenericPnpmLockAdapter(cfg)
bunLockAdapter := newGenericBunLockAdapter(cfg)
return generic.NewCataloger("javascript-lock-cataloger").
WithParserByGlobs(packageLockAdapter.parsePackageLock, "**/package-lock.json").
WithParserByGlobs(yarnLockAdapter.parseYarnLock, "**/yarn.lock").
WithParserByGlobs(pnpmLockAdapter.parsePnpmLock, "**/pnpm-lock.yaml")
WithParserByGlobs(pnpmLockAdapter.parsePnpmLock, "**/pnpm-lock.yaml").
WithParserByGlobs(bunLockAdapter.parseBunLock, "**/bun.lock")
}

View File

@ -176,6 +176,7 @@ func Test_LockCataloger_Globs(t *testing.T) {
"src/package-lock.json",
"src/pnpm-lock.yaml",
"src/yarn.lock",
"src/bun.lock",
},
},
}

View File

@ -83,3 +83,28 @@ func yarnLockDependencySpecifier(p pkg.Package) dependency.Specification {
},
}
}
func bunLockDependencySpecifier(p pkg.Package) dependency.Specification {
meta, ok := p.Metadata.(pkg.BunLockEntry)
if !ok {
log.Tracef("cataloger failed to extract bun lock metadata for package %+v", p.Name)
return dependency.Specification{}
}
provides := []string{p.Name}
var requires []string
for name := range meta.Dependencies {
requires = append(requires, name)
}
for name := range meta.OptionalDependencies {
requires = append(requires, name)
}
return dependency.Specification{
ProvidesRequires: dependency.ProvidesRequires{
Provides: provides,
Requires: requires,
},
}
}

View File

@ -220,6 +220,43 @@ func newYarnLockPackage(ctx context.Context, cfg CatalogerConfig, resolver file.
)
}
func newBunPackage(ctx context.Context, cfg CatalogerConfig, resolver file.Resolver, location file.Location, name, version string, integrity string, metadata bunPackageMetadata) pkg.Package {
var licenseSet pkg.LicenseSet
if cfg.SearchRemoteLicenses {
license, err := getLicenseFromNpmRegistry(cfg.NPMBaseURL, name, version)
if err == nil && license != "" {
licenseSet = pkg.NewLicenseSet(pkg.NewLicensesFromValuesWithContext(ctx, license)...)
}
if err != nil {
log.Debugf("unable to extract licenses from javascript bun.lock for package %s:%s: %+v", name, version, err)
}
}
return finalizeLockPkg(
ctx,
resolver,
location,
pkg.Package{
Name: name,
Version: version,
Licenses: licenseSet,
Locations: file.NewLocationSet(location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
PURL: packageURL(name, version),
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: integrity,
Dependencies: metadata.Dependencies,
OptionalDependencies: metadata.OptionalDependencies,
PeerDependencies: metadata.PeerDependencies,
Bin: metadata.Bin,
OS: metadata.OS,
CPU: metadata.CPU,
},
},
)
}
func formatNpmRegistryURL(baseURL, packageName, version string) (requestURL string, err error) {
urlPath := []string{packageName, version}
requestURL, err = url.JoinPath(baseURL, urlPath...)

View File

@ -0,0 +1,295 @@
package javascript
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"maps"
"strings"
"github.com/tailscale/hujson"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/unknown"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
"github.com/anchore/syft/syft/pkg/cataloger/internal/dependency"
)
// bunPackage holds the name, version, and metadata extracted from a single lockfile entry.
type bunPackage struct {
Name string
Version string
Integrity string
Metadata bunPackageMetadata
}
// bunPackageMetadata is the metadata object (the third element) of a bun.lock package tuple.
type bunPackageMetadata struct {
Dependencies map[string]string `json:"dependencies"`
OptionalDependencies map[string]string `json:"optionalDependencies"`
PeerDependencies map[string]string `json:"peerDependencies"`
Bin map[string]string `json:"bin"`
OS string `json:"os"`
CPU string `json:"cpu"`
}
// bunLockfile represents the structure of a bun.lock file (JSONC format).
type bunLockfile struct {
LockfileVersion int `json:"lockfileVersion"`
ConfigVersion int `json:"configVersion"`
Workspaces map[string]bunWorkspace `json:"workspaces"`
Packages map[string]json.RawMessage `json:"packages"`
}
type bunWorkspace struct {
Name string `json:"name"`
Dependencies map[string]string `json:"dependencies"`
DevDependencies map[string]string `json:"devDependencies"`
}
type genericBunLockAdapter struct {
cfg CatalogerConfig
}
func newGenericBunLockAdapter(cfg CatalogerConfig) genericBunLockAdapter {
return genericBunLockAdapter{
cfg: cfg,
}
}
// parseBunLock is the main parser function for bun.lock files.
func (a genericBunLockAdapter) parseBunLock(ctx context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
data, err := io.ReadAll(reader) //nolint:gocritic // bun.lock is JSONC; the full document must be buffered for hujson.Standardize before unmarshalling
if err != nil {
return nil, nil, fmt.Errorf("failed to load bun.lock file: %w", err)
}
// bun.lock is JSONC (JSON with comments and trailing commas), not strict JSON, so it must
// be standardized before it can be unmarshalled. See https://bun.sh/blog/bun-lock-text-lockfile
data, err = hujson.Standardize(data)
if err != nil {
return nil, nil, fmt.Errorf("failed to standardize bun.lock file: %w", err)
}
var lockfile bunLockfile
if err := json.Unmarshal(data, &lockfile); err != nil {
return nil, nil, fmt.Errorf("failed to parse bun.lock file: %w", err)
}
log.WithFields("lockfileVersion", lockfile.LockfileVersion, "configVersion", lockfile.ConfigVersion).Trace("parsed bun.lock metadata")
bunPkgs := parseBunLockPackages(lockfile)
// Collect dev dependencies from all workspaces
devDeps := make(map[string]bool)
for _, workspace := range lockfile.Workspaces {
for devDepName := range workspace.DevDependencies {
devDeps[devDepName] = true
}
}
// Determine dev-only packages
prodDeps := make(map[string]string)
for _, workspace := range lockfile.Workspaces {
maps.Copy(prodDeps, workspace.Dependencies)
}
devOnlyPkgs := findDevOnlyBunPkgs(bunPkgs, prodDeps, devDeps)
packages := make([]pkg.Package, 0, len(bunPkgs))
for _, p := range bunPkgs {
if devOnlyPkgs[p.Name] && !a.cfg.IncludeDevDependencies {
continue
}
packages = append(packages, newBunPackage(ctx, a.cfg, resolver, reader.Location, p.Name, p.Version, p.Integrity, p.Metadata))
}
pkg.Sort(packages)
return packages, dependency.Resolve(bunLockDependencySpecifier, packages), unknown.IfEmptyf(packages, "unable to determine packages")
}
func parseBunLockPackages(lockfile bunLockfile) []bunPackage {
packages := make([]bunPackage, 0, len(lockfile.Packages))
for pkgName, pkgData := range lockfile.Packages {
var pkgArray []json.RawMessage
if err := json.Unmarshal(pkgData, &pkgArray); err != nil {
log.WithFields("package", pkgName, "error", err).Trace("unable to parse bun.lock package entry")
continue
}
if len(pkgArray) == 0 {
continue
}
// Extract identifier (name@version) from first element
var identifier string
if err := json.Unmarshal(pkgArray[0], &identifier); err != nil {
log.WithFields("package", pkgName, "error", err).Trace("unable to parse bun.lock package identifier")
continue
}
// Parse identifier to handle scoped packages (@scope/name@version)
name, version, ok := parseBunPackageIdentifier(identifier)
if !ok {
log.WithFields("identifier", identifier).Trace("unable to parse bun.lock package identifier format")
continue
}
// root, workspace, link, and file entries are local first-party packages rather than
// resolved third-party dependencies, so they are not cataloged from the lockfile.
if isLocalBunPackage(version) {
continue
}
// tuple length and the positions of the metadata object and integrity hash vary by
// source (registry, git, tarball), locate by type
metadata, integrity := extractBunPackageFields(pkgName, pkgArray[1:])
packages = append(packages, bunPackage{
Name: name,
Version: version,
Integrity: integrity,
Metadata: metadata,
})
}
return packages
}
// extractBunPackageFields locates the metadata object and integrity hash within the trailing
// elements of a bun.lock package tuple. Their positions vary by source: registry entries are
// [identifier, registry, {metadata}, integrity]
func extractBunPackageFields(pkgName string, elements []json.RawMessage) (bunPackageMetadata, string) {
var metadata bunPackageMetadata
var integrity string
for _, raw := range elements {
value := bytes.TrimSpace(raw)
if len(value) == 0 {
continue
}
switch value[0] {
case '{':
if err := json.Unmarshal(raw, &metadata); err != nil {
log.WithFields("package", pkgName, "error", err).Trace("unable to parse bun.lock package metadata")
}
case '"':
var s string
if err := json.Unmarshal(raw, &s); err == nil && isIntegrityHash(s) {
integrity = s
}
}
}
return metadata, integrity
}
// isIntegrityHash reports whether s is a Subresource Integrity hash (SRI format), which lets it
// be distinguished from the other string fields in a tuple
func isIntegrityHash(s string) bool {
for _, prefix := range []string{"sha512-", "sha384-", "sha256-", "sha1-"} {
if strings.HasPrefix(s, prefix) {
return true
}
}
return false
}
// isLocalBunPackage reports whether a package version refers to a local first-party package
// (the root project, a workspace, a symlink, or a folder) rather than a resolved third-party
// dependency. These correspond to the root/workspace/link/file resolution forms in bun.lock.
func isLocalBunPackage(version string) bool {
for _, prefix := range []string{"root:", "workspace:", "link:", "file:"} {
if strings.HasPrefix(version, prefix) {
return true
}
}
return false
}
// parseBunPackageIdentifier extracts the package name and version from a bun.lock identifier.
func parseBunPackageIdentifier(identifier string) (name, version string, ok bool) {
// Find the last @ symbol which separates name from version
lastAtIndex := strings.LastIndex(identifier, "@")
if lastAtIndex == -1 {
return "", "", false
}
// Handle scoped packages (@scope/package@version)
if strings.HasPrefix(identifier, "@") && lastAtIndex > 0 {
name = identifier[:lastAtIndex]
version = identifier[lastAtIndex+1:]
return name, version, true
}
// Handle non-scoped packages (package@version)
name = identifier[:lastAtIndex]
version = identifier[lastAtIndex+1:]
return name, version, true
}
func findDevOnlyBunPkgs(bunPkgs []bunPackage, prodDeps map[string]string, devDeps map[string]bool) map[string]bool {
// Build a simplified dependency graph
depGraph := make(map[string][]string)
for _, p := range bunPkgs {
var deps []string
for depName := range p.Metadata.Dependencies {
deps = append(deps, depName)
}
for depName := range p.Metadata.OptionalDependencies {
deps = append(deps, depName)
}
depGraph[p.Name] = deps
}
// Find all packages reachable from production dependencies
prodReachable := make(map[string]bool)
var visitProd func(string)
visitProd = func(name string) {
if prodReachable[name] {
return
}
prodReachable[name] = true
for _, dep := range depGraph[name] {
visitProd(dep)
}
}
for prodDep := range prodDeps {
visitProd(prodDep)
}
// Find all packages reachable from dev dependencies
devReachable := make(map[string]bool)
var visitDev func(string)
visitDev = func(name string) {
if devReachable[name] {
return
}
devReachable[name] = true
for _, dep := range depGraph[name] {
visitDev(dep)
}
}
for devDep := range devDeps {
visitDev(devDep)
}
// Packages that are dev-only are those reachable from dev but not from prod
devOnly := make(map[string]bool)
for name := range devReachable {
if !prodReachable[name] {
devOnly[name] = true
}
}
return devOnly
}

View File

@ -0,0 +1,476 @@
package javascript
import (
"context"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
)
func TestParseBunLock(t *testing.T) {
fixture := "test-fixtures/bun/bun.lock"
locationSet := file.NewLocationSet(file.NewLocation(fixture))
expectedPkgs := []pkg.Package{
{
Name: "@img/sharp-darwin-arm64",
Version: "0.33.5",
PURL: "pkg:npm/%40img/sharp-darwin-arm64@0.33.5",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
OS: "darwin",
CPU: "arm64",
},
},
{
Name: "@img/sharp-linux-x64",
Version: "0.33.5",
PURL: "pkg:npm/%40img/sharp-linux-x64@0.33.5",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
OS: "linux",
CPU: "x64",
},
},
{
Name: "axios",
Version: "1.6.0",
PURL: "pkg:npm/axios@1.6.0",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==",
Dependencies: map[string]string{
"follow-redirects": "^1.15.0",
},
},
},
{
Name: "color",
Version: "4.2.3",
PURL: "pkg:npm/color@4.2.3",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
},
},
{
Name: "eslint",
Version: "9.0.0",
PURL: "pkg:npm/eslint@9.0.0",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: "sha512-IMryZ5SudxzQvuod6rUdxH8KRx8BKHkTXpHWe3BJ9Qef3PbG9v9vjSB0STcKOVjTvPnG1+9T5e4xfzZ4wKdqiA==",
Dependencies: map[string]string{
"eslint-visitor-keys": "^4.0.0",
},
Bin: map[string]string{
"eslint": "bin/eslint.js",
},
},
},
{
Name: "eslint-visitor-keys",
Version: "4.0.0",
PURL: "pkg:npm/eslint-visitor-keys@4.0.0",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==",
},
},
{
Name: "follow-redirects",
Version: "1.15.3",
PURL: "pkg:npm/follow-redirects@1.15.3",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==",
PeerDependencies: map[string]string{
"debug": "*",
},
},
},
{
Name: "lodash",
Version: "4.17.21",
PURL: "pkg:npm/lodash@4.17.21",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
},
},
{
Name: "sharp",
Version: "0.33.5",
PURL: "pkg:npm/sharp@0.33.5",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgxt/HmijnBghFeqCkXp3e+MH2fYF5o6qzrNJJ2k9s4bsZX0QBUCbFjr8VdLAIww==",
Dependencies: map[string]string{
"color": "^4.2.3",
},
OptionalDependencies: map[string]string{
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-linux-x64": "0.33.5",
},
},
},
{
Name: "typescript",
Version: "5.0.0",
PURL: "pkg:npm/typescript@5.0.0",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: "sha512-w5c493tkwFQLjKSYw0JvvVbqJ9ZM2SXoWBv/wqHYqWN/jP9bGilMKFbAKkpRHfaLxPn6A3K/fmJ6LD8B0EO9oA==",
Bin: map[string]string{
"tsc": "bin/tsc",
"tsserver": "bin/tsserver",
},
},
},
}
// Relationships are automatically generated by dependency.Resolve
// We explicitly define expected relationships for packages with dependencies
expectedRelationships := []artifact.Relationship{
// sharp depends on color
{
From: expectedPkgs[3], // color
To: expectedPkgs[8], // sharp
Type: artifact.DependencyOfRelationship,
},
// sharp optionally depends on @img/sharp-darwin-arm64
{
From: expectedPkgs[0], // @img/sharp-darwin-arm64
To: expectedPkgs[8], // sharp
Type: artifact.DependencyOfRelationship,
},
// sharp optionally depends on @img/sharp-linux-x64
{
From: expectedPkgs[1], // @img/sharp-linux-x64
To: expectedPkgs[8], // sharp
Type: artifact.DependencyOfRelationship,
},
// axios depends on follow-redirects
{
From: expectedPkgs[6], // follow-redirects
To: expectedPkgs[2], // axios
Type: artifact.DependencyOfRelationship,
},
// eslint depends on eslint-visitor-keys
{
From: expectedPkgs[5], // eslint-visitor-keys
To: expectedPkgs[4], // eslint
Type: artifact.DependencyOfRelationship,
},
}
adapter := newGenericBunLockAdapter(CatalogerConfig{IncludeDevDependencies: true})
pkgtest.TestFileParser(t, fixture, adapter.parseBunLock, expectedPkgs, expectedRelationships)
}
func TestParseBunLock_ExcludeDevDependencies(t *testing.T) {
fixture := "test-fixtures/bun/bun.lock"
locationSet := file.NewLocationSet(file.NewLocation(fixture))
expectedPkgs := []pkg.Package{
{
Name: "@img/sharp-darwin-arm64",
Version: "0.33.5",
PURL: "pkg:npm/%40img/sharp-darwin-arm64@0.33.5",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
OS: "darwin",
CPU: "arm64",
},
},
{
Name: "@img/sharp-linux-x64",
Version: "0.33.5",
PURL: "pkg:npm/%40img/sharp-linux-x64@0.33.5",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
OS: "linux",
CPU: "x64",
},
},
{
Name: "axios",
Version: "1.6.0",
PURL: "pkg:npm/axios@1.6.0",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==",
Dependencies: map[string]string{
"follow-redirects": "^1.15.0",
},
},
},
{
Name: "color",
Version: "4.2.3",
PURL: "pkg:npm/color@4.2.3",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
},
},
{
Name: "follow-redirects",
Version: "1.15.3",
PURL: "pkg:npm/follow-redirects@1.15.3",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==",
PeerDependencies: map[string]string{
"debug": "*",
},
},
},
{
Name: "lodash",
Version: "4.17.21",
PURL: "pkg:npm/lodash@4.17.21",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
},
},
{
Name: "sharp",
Version: "0.33.5",
PURL: "pkg:npm/sharp@0.33.5",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.BunLockEntry{
Integrity: "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgxt/HmijnBghFeqCkXp3e+MH2fYF5o6qzrNJJ2k9s4bsZX0QBUCbFjr8VdLAIww==",
Dependencies: map[string]string{
"color": "^4.2.3",
},
OptionalDependencies: map[string]string{
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-linux-x64": "0.33.5",
},
},
},
}
// When excluding dev dependencies, we don't have eslint and typescript,
// so we only have relationships for axios and sharp
expectedRelationships := []artifact.Relationship{
// sharp depends on color
{
From: expectedPkgs[3], // color
To: expectedPkgs[6], // sharp (index 6, not 7)
Type: artifact.DependencyOfRelationship,
},
// sharp optionally depends on @img/sharp-darwin-arm64
{
From: expectedPkgs[0], // @img/sharp-darwin-arm64
To: expectedPkgs[6], // sharp
Type: artifact.DependencyOfRelationship,
},
// sharp optionally depends on @img/sharp-linux-x64
{
From: expectedPkgs[1], // @img/sharp-linux-x64
To: expectedPkgs[6], // sharp
Type: artifact.DependencyOfRelationship,
},
// axios depends on follow-redirects
{
From: expectedPkgs[4], // follow-redirects
To: expectedPkgs[2], // axios
Type: artifact.DependencyOfRelationship,
},
}
adapter := newGenericBunLockAdapter(CatalogerConfig{IncludeDevDependencies: false})
pkgtest.TestFileParser(t, fixture, adapter.parseBunLock, expectedPkgs, expectedRelationships)
}
// TestParseBunLock_Fixtures parses each bun.lock fixture and asserts the set of cataloged
// packages (and, where given, specific integrity hashes). It covers the real-world formats
// bun emits:
// - strict JSON
// - JSONC with trailing commas (see https://bun.sh/blog/bun-lock-text-lockfile)
// - variable-length package tuples whose field positions differ by source:
// - root (2 elements): [identifier, {bin, binDir}], e.g. ["my-monorepo@root:", {...}]
// - workspace (1 element): only the identifier, e.g. ["@my/util@workspace:packages/util"]
// - github without integrity (3 elements): [identifier, {metadata}, resolved]
// - github with integrity (4 elements): [identifier, {metadata}, resolved, integrity]
// - registry (4 elements): [identifier, registry, {metadata}, integrity]
//
// The github and registry forms are both four elements but place the metadata object and
// integrity hash at different indices, so they must be located by type rather than position.
func TestParseBunLock_Fixtures(t *testing.T) {
tests := []struct {
name string
fixture string
includeDevDependencies bool
wantNames []string
wantIntegrity map[string]string // package name -> expected integrity, checked when set
}{
{
name: "strict json",
fixture: "test-fixtures/bun/bun.lock",
includeDevDependencies: true,
wantNames: []string{
"@img/sharp-darwin-arm64",
"@img/sharp-linux-x64",
"axios",
"color",
"eslint",
"eslint-visitor-keys",
"follow-redirects",
"lodash",
"sharp",
"typescript",
},
},
{
name: "jsonc with trailing commas",
fixture: "test-fixtures/bun-trailing-comma/bun.lock",
includeDevDependencies: true,
wantNames: []string{"axios", "follow-redirects", "lodash"},
},
{
name: "variable length tuples",
fixture: "test-fixtures/bun-variable-tuples/bun.lock",
includeDevDependencies: true,
// the workspace package (@my/util) is a local first-party package, not a resolved
// third-party dependency, so it is not cataloged from the lockfile.
wantNames: []string{"axios", "follow-redirects", "ghostty-web", "tracestrings"},
// the github tuple places its integrity hash at index 3 with the metadata object at
// index 1, so a position-based parser (expecting metadata at index 2) would miss it.
wantIntegrity: map[string]string{
"ghostty-web": "sha512-nLx3R2hPwQvmL42LbiaQvbJpPZAXjzUtgU23G2LaKMRuA2mdXHdLQ5Hfw0PmxsohbqO/GhKOnTMcRrlLKS81+g==",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, err := os.Open(tt.fixture)
require.NoError(t, err)
t.Cleanup(func() { _ = f.Close() })
adapter := newGenericBunLockAdapter(CatalogerConfig{IncludeDevDependencies: tt.includeDevDependencies})
pkgs, _, err := adapter.parseBunLock(
context.Background(),
nil,
nil,
file.NewLocationReadCloser(file.NewLocation(tt.fixture), f),
)
require.NoError(t, err)
byName := make(map[string]pkg.Package)
var names []string
for _, p := range pkgs {
byName[p.Name] = p
names = append(names, p.Name)
}
assert.ElementsMatch(t, tt.wantNames, names)
for name, integrity := range tt.wantIntegrity {
require.Contains(t, byName, name)
meta, ok := byName[name].Metadata.(pkg.BunLockEntry)
require.True(t, ok)
assert.Equal(t, integrity, meta.Integrity)
}
})
}
}
func TestParseBunPackageIdentifier(t *testing.T) {
tests := []struct {
name string
identifier string
wantName string
wantVer string
wantOK bool
}{
{
name: "simple package",
identifier: "lodash@4.17.21",
wantName: "lodash",
wantVer: "4.17.21",
wantOK: true,
},
{
name: "scoped package",
identifier: "@babel/core@7.24.0",
wantName: "@babel/core",
wantVer: "7.24.0",
wantOK: true,
},
{
name: "no version",
identifier: "lodash",
wantName: "",
wantVer: "",
wantOK: false,
},
{
name: "scoped package with multiple @",
identifier: "@org/pkg@1.0.0",
wantName: "@org/pkg",
wantVer: "1.0.0",
wantOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotName, gotVer, gotOK := parseBunPackageIdentifier(tt.identifier)
if gotName != tt.wantName || gotVer != tt.wantVer || gotOK != tt.wantOK {
t.Errorf("parseBunPackageIdentifier(%q) = (%q, %q, %v), want (%q, %q, %v)",
tt.identifier, gotName, gotVer, gotOK, tt.wantName, tt.wantVer, tt.wantOK)
}
})
}
}

View File

@ -0,0 +1,20 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "test-project",
"dependencies": {
"axios": "^1.6.0",
"lodash": "^4.17.21",
},
},
},
"packages": {
"axios": ["axios@1.6.0", "", { "dependencies": { "follow-redirects": "^1.15.0" } }, "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg=="],
"follow-redirects": ["follow-redirects@1.15.3", "", {}, "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
},
}

View File

@ -0,0 +1,31 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "my-monorepo",
"dependencies": {
"axios": "^1.6.0",
"ghostty-web": "github:rcarmo/ghostty-web#6c1c75b",
"tracestrings": "github:oven-sh/bun.report#912ca63",
},
},
"packages/util": {
"name": "@my/util",
"version": "1.0.0",
},
},
"packages": {
"my-monorepo": ["my-monorepo@root:", { "bin": { "my-cli": "bin/cli.js" } }],
"@my/util": ["@my/util@workspace:packages/util"],
"axios": ["axios@1.6.0", "", { "dependencies": { "follow-redirects": "^1.15.0" } }, "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg=="],
"follow-redirects": ["follow-redirects@1.15.3", "", {}, "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q=="],
"ghostty-web": ["ghostty-web@github:rcarmo/ghostty-web#6c1c75b", {}, "rcarmo-ghostty-web-6c1c75b", "sha512-nLx3R2hPwQvmL42LbiaQvbJpPZAXjzUtgU23G2LaKMRuA2mdXHdLQ5Hfw0PmxsohbqO/GhKOnTMcRrlLKS81+g=="],
"tracestrings": ["tracestrings@github:oven-sh/bun.report#912ca63", {}, "oven-sh-bun.report-912ca63"],
}
}

View File

@ -0,0 +1,114 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "test-project",
"dependencies": {
"lodash": "^4.17.21",
"axios": "^1.6.0",
"sharp": "^0.33.5"
},
"devDependencies": {
"typescript": "^5.0.0",
"eslint": "^9.0.0"
}
}
},
"packages": {
"@img/sharp-darwin-arm64": [
"@img/sharp-darwin-arm64@0.33.5",
"",
{
"os": "darwin",
"cpu": "arm64"
},
"sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="
],
"@img/sharp-linux-x64": [
"@img/sharp-linux-x64@0.33.5",
"",
{
"os": "linux",
"cpu": "x64"
},
"sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="
],
"axios": [
"axios@1.6.0",
"",
{
"dependencies": {
"follow-redirects": "^1.15.0"
}
},
"sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg=="
],
"color": [
"color@4.2.3",
"",
{},
"sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="
],
"eslint": [
"eslint@9.0.0",
"",
{
"dependencies": {
"eslint-visitor-keys": "^4.0.0"
},
"bin": {
"eslint": "bin/eslint.js"
}
},
"sha512-IMryZ5SudxzQvuod6rUdxH8KRx8BKHkTXpHWe3BJ9Qef3PbG9v9vjSB0STcKOVjTvPnG1+9T5e4xfzZ4wKdqiA=="
],
"eslint-visitor-keys": [
"eslint-visitor-keys@4.0.0",
"",
{},
"sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw=="
],
"follow-redirects": [
"follow-redirects@1.15.3",
"",
{
"peerDependencies": {
"debug": "*"
}
},
"sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q=="
],
"lodash": [
"lodash@4.17.21",
"",
{},
"sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
],
"sharp": [
"sharp@0.33.5",
"",
{
"dependencies": {
"color": "^4.2.3"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-linux-x64": "0.33.5"
}
},
"sha512-haPVm1EkS9pgvHrQ/F3Xy+hgxt/HmijnBghFeqCkXp3e+MH2fYF5o6qzrNJJ2k9s4bsZX0QBUCbFjr8VdLAIww=="
],
"typescript": [
"typescript@5.0.0",
"",
{
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
}
},
"sha512-w5c493tkwFQLjKSYw0JvvVbqJ9ZM2SXoWBv/wqHYqWN/jP9bGilMKFbAKkpRHfaLxPn6A3K/fmJ6LD8B0EO9oA=="
]
}
}

View File

@ -62,3 +62,27 @@ type PnpmLockEntry struct {
// Dependencies is a map of dependencies and their versions
Dependencies map[string]string `mapstructure:"dependencies" json:"dependencies"`
}
// BunLockEntry represents a single entry in the "packages" section of a bun.lock file
type BunLockEntry struct {
// Integrity is Subresource Integrity hash for verification (SRI format)
Integrity string `mapstructure:"integrity" json:"integrity"`
// Dependencies is a map of runtime dependencies and their version specifiers
Dependencies map[string]string `mapstructure:"dependencies" json:"dependencies"`
// OptionalDependencies is a map of optional dependencies and their version specifiers
OptionalDependencies map[string]string `mapstructure:"optionalDependencies" json:"optionalDependencies"`
// PeerDependencies is a map of peer dependencies and their version specifiers
PeerDependencies map[string]string `mapstructure:"peerDependencies" json:"peerDependencies"`
// Bin is a map of binary names to the paths they are installed to
Bin map[string]string `mapstructure:"bin" json:"bin"`
// OS is the operating system constraint for the package (e.g. "darwin")
OS string `mapstructure:"os" json:"os"`
// CPU is the CPU architecture constraint for the package (e.g. "arm64")
CPU string `mapstructure:"cpu" json:"cpu"`
}