Skip to content

Commit abf6098

Browse files
G-Rathcopybara-github
authored andcommitted
feat: add support for bun.lock (#379)
As part of improving ergonomics, [Bun has introduced a new text-based lockfile](https://bun.sh/blog/bun-lock-text-lockfile) which this adds support for extracting. While the blog post claims that it's "JSON with comments" (aka `JSONC`), it's actually "JSON with comments _and_ trailing commas" (aka [`JWCC`](https://nigeltao.github.io/blog/2021/json-with-commas-comments.html) or [`HuJSON`](https://github.com/tailscale/hujson)) - since the Go standard library only supports parsing standard JSON, I've had to bring in a third-party library to handle parsing, which is designed to leverage the existing standard library by instead handling "standardizing" the input into valid boring JSON. ~Aside from the general question of if this library is acceptable to use here, it also seems to require Go 1.23 resulting in the addition of a `toolchain` line in `go.mod` which I've never managed to quite figure out what the right thing to do with - overall, I'd like someone from Google to confirm what library they'd prefer we use here.~ (I've since switched to using a library that requires Go 1.16) I have also specified the testdata fixtures as JSON5 as that's the most appropriate format supported by both VSCode and IntelliJ/GoLand, though that technically supports more features like single quotes (which it actually seems like `bun` does not mind if you use in your lockfile, though it'll always use double quotes itself) - personally I think that's fine, but don't mind renaming the files to be `.hujson` if folks would prefer. Resolves google/osv-scanner#1405 Closes #379 COPYBARA_INTEGRATE_REVIEW=#379 from ackama:bun/support 0771c5a23520c523202fe5209f75e79e961da8e0 PiperOrigin-RevId: 720546722
1 parent 34aef7c commit abf6098

25 files changed

+1345
-1
lines changed

docs/supported_inventory_types.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ SCALIBR supports extracting software package information from a variety of OS an
5151
* Lockfiles: pom.xml, gradle.lockfile, verification-metadata.xml
5252
* Javascript
5353
* Installed NPM packages (package.json)
54-
* Lockfiles: package-lock.json, yarn.lock, pnpm-lock.yaml
54+
* Lockfiles: package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lock
5555
* ObjectiveC
5656
* Podfile.lock
5757
* PHP:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package bunlock extracts bun.lock files
16+
package bunlock
17+
18+
import (
19+
"context"
20+
"encoding/json"
21+
"errors"
22+
"fmt"
23+
"io"
24+
"path/filepath"
25+
"strings"
26+
27+
"github.com/google/osv-scalibr/extractor"
28+
"github.com/google/osv-scalibr/extractor/filesystem"
29+
"github.com/google/osv-scalibr/extractor/filesystem/osv"
30+
"github.com/google/osv-scalibr/plugin"
31+
"github.com/google/osv-scalibr/purl"
32+
"github.com/tidwall/jsonc/pkg/jsonc"
33+
)
34+
35+
type bunLockfile struct {
36+
Version int `json:"lockfileVersion"`
37+
Packages map[string][]any `json:"packages"`
38+
}
39+
40+
// Extractor extracts npm packages from bun.lock files.
41+
type Extractor struct{}
42+
43+
// Name of the extractor.
44+
func (e Extractor) Name() string { return "javascript/bunlock" }
45+
46+
// Version of the extractor.
47+
func (e Extractor) Version() int { return 0 }
48+
49+
// Requirements of the extractor.
50+
func (e Extractor) Requirements() *plugin.Capabilities {
51+
return &plugin.Capabilities{}
52+
}
53+
54+
// FileRequired returns true if the specified file matches bun lockfile patterns.
55+
func (e Extractor) FileRequired(api filesystem.FileAPI) bool {
56+
return filepath.Base(api.Path()) == "bun.lock"
57+
}
58+
59+
// structurePackageDetails returns the name, version, and commit of a package
60+
// specified as a tuple in a bun.lock
61+
func structurePackageDetails(pkg []any) (string, string, string, error) {
62+
if len(pkg) == 0 {
63+
return "", "", "", fmt.Errorf("empty package tuple")
64+
}
65+
66+
str, ok := pkg[0].(string)
67+
68+
if !ok {
69+
return "", "", "", fmt.Errorf("first element of package tuple is not a string")
70+
}
71+
72+
str, isScoped := strings.CutPrefix(str, "@")
73+
name, version, _ := strings.Cut(str, "@")
74+
75+
if isScoped {
76+
name = "@" + name
77+
}
78+
79+
version, commit, _ := strings.Cut(version, "#")
80+
81+
// bun.lock does not track both the commit and version,
82+
// so if we have a commit then we don't have a version
83+
if commit != "" {
84+
version = ""
85+
}
86+
87+
// file dependencies do not have a semantic version recorded
88+
if strings.HasPrefix(version, "file:") {
89+
version = ""
90+
}
91+
92+
return name, version, commit, nil
93+
}
94+
95+
// Extract extracts packages from bun.lock files passed through the scan input.
96+
func (e Extractor) Extract(ctx context.Context, input *filesystem.ScanInput) ([]*extractor.Inventory, error) {
97+
var parsedLockfile *bunLockfile
98+
99+
b, err := io.ReadAll(input.Reader)
100+
101+
if err != nil {
102+
return nil, fmt.Errorf("could not extract from %q: %w", input.Path, err)
103+
}
104+
105+
if err := json.Unmarshal(jsonc.ToJSON(b), &parsedLockfile); err != nil {
106+
return nil, fmt.Errorf("could not extract from %q: %w", input.Path, err)
107+
}
108+
109+
inventories := make([]*extractor.Inventory, 0, len(parsedLockfile.Packages))
110+
111+
var errs []error
112+
113+
for key, pkg := range parsedLockfile.Packages {
114+
name, version, commit, err := structurePackageDetails(pkg)
115+
116+
if err != nil {
117+
errs = append(errs, fmt.Errorf("could not extract '%s' from %q: %w", key, input.Path, err))
118+
119+
continue
120+
}
121+
122+
inventories = append(inventories, &extractor.Inventory{
123+
Name: name,
124+
Version: version,
125+
SourceCode: &extractor.SourceCodeIdentifier{
126+
Commit: commit,
127+
},
128+
Metadata: osv.DepGroupMetadata{
129+
DepGroupVals: []string{},
130+
},
131+
Locations: []string{input.Path},
132+
})
133+
}
134+
135+
return inventories, errors.Join(errs...)
136+
}
137+
138+
// ToPURL converts an inventory created by this extractor into a PURL.
139+
func (e Extractor) ToPURL(i *extractor.Inventory) *purl.PackageURL {
140+
return &purl.PackageURL{
141+
Type: purl.TypeNPM,
142+
Name: strings.ToLower(i.Name),
143+
Version: i.Version,
144+
}
145+
}
146+
147+
// Ecosystem returns the OSV ecosystem ('npm') of the software extracted by this extractor.
148+
func (e Extractor) Ecosystem(_ *extractor.Inventory) string { return "npm" }

0 commit comments

Comments
 (0)