Skip to content

Commit

Permalink
Merge pull request #11268 from dependabot/add-bun-file-parser
Browse files Browse the repository at this point in the history
Add BunLock FileParser
  • Loading branch information
markhallen authored Jan 13, 2025
2 parents 21a9544 + 64e8c6e commit 94edb2f
Show file tree
Hide file tree
Showing 15 changed files with 406 additions and 28 deletions.
10 changes: 9 additions & 1 deletion npm_and_yarn/lib/dependabot/npm_and_yarn/file_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ def lockfiles
{
npm: package_lock || shrinkwrap,
yarn: yarn_lock,
pnpm: pnpm_lock
pnpm: pnpm_lock,
bun: bun_lock
}
end

Expand Down Expand Up @@ -167,6 +168,13 @@ def pnpm_lock
end, T.nilable(Dependabot::DependencyFile))
end

sig { returns(T.nilable(Dependabot::DependencyFile)) }
def bun_lock
@bun_lock ||= T.let(dependency_files.find do |f|
f.name == BunPackageManager::LOCKFILE_NAME
end, T.nilable(Dependabot::DependencyFile))
end

sig { returns(T.nilable(Dependabot::DependencyFile)) }
def npmrc
@npmrc ||= T.let(dependency_files.find do |f|
Expand Down
141 changes: 141 additions & 0 deletions npm_and_yarn/lib/dependabot/npm_and_yarn/file_parser/bun_lock.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# typed: strict
# frozen_string_literal: true

require "yaml"
require "dependabot/errors"
require "dependabot/npm_and_yarn/helpers"
require "sorbet-runtime"

module Dependabot
module NpmAndYarn
class FileParser < Dependabot::FileParsers::Base
class BunLock
extend T::Sig

sig { params(dependency_file: DependencyFile).void }
def initialize(dependency_file)
@dependency_file = dependency_file
end

sig { returns(T::Hash[String, T.untyped]) }
def parsed
@parsed ||= begin
content = begin
# Since bun.lock is a JSONC file, which is a subset of YAML, we can use YAML to parse it
YAML.load(T.must(@dependency_file.content))
rescue Psych::SyntaxError => e
raise_invalid!("malformed JSONC at line #{e.line}, column #{e.column}")
end
raise_invalid!("expected to be an object") unless content.is_a?(Hash)

version = content["lockfileVersion"]
raise_invalid!("expected 'lockfileVersion' to be an integer") unless version.is_a?(Integer)
raise_invalid!("expected 'lockfileVersion' to be >= 0") unless version >= 0
raise_invalid!("unsupported 'lockfileVersion' = #{version}") unless version.zero?

T.let(content, T.untyped)
end
end

sig { returns(Dependabot::FileParsers::Base::DependencySet) }
def dependencies
dependency_set = Dependabot::FileParsers::Base::DependencySet.new

# bun.lock v0 format:
# https://github.com/oven-sh/bun/blob/c130df6c589fdf28f9f3c7f23ed9901140bc9349/src/install/bun.lock.zig#L595-L605

packages = parsed["packages"]
raise_invalid!("expected 'packages' to be an object") unless packages.is_a?(Hash)

packages.each do |key, details|
raise_invalid!("expected 'packages.#{key}' to be an array") unless details.is_a?(Array)

resolution = details.first
raise_invalid!("expected 'packages.#{key}[0]' to be a string") unless resolution.is_a?(String)

name, version = resolution.split(/(?<=\w)\@/)
next if name.empty?

semver = Version.semver_for(version)
next unless semver

dependency_set << Dependency.new(
name: name,
version: semver.to_s,
package_manager: "npm_and_yarn",
requirements: []
)
end

dependency_set
end

sig do
params(dependency_name: String, requirement: T.untyped, _manifest_name: String)
.returns(T.nilable(T::Hash[String, T.untyped]))
end
def details(dependency_name, requirement, _manifest_name)
packages = parsed["packages"]
return unless packages.is_a?(Hash)

candidates =
packages
.select { |name, _| name == dependency_name }
.values

# If there's only one entry for this dependency, use it, even if
# the requirement in the lockfile doesn't match
if candidates.one?
parse_details(candidates.first)
else
candidate = candidates.find do |label, _|
label.scan(/(?<=\w)\@(?:npm:)?([^\s,]+)/).flatten.include?(requirement)
end&.last
parse_details(candidate)
end
end

private

sig { params(message: String).void }
def raise_invalid!(message)
raise Dependabot::DependencyFileNotParseable.new(@dependency_file.path, "Invalid bun.lock file: #{message}")
end

sig do
params(entry: T.nilable(T::Array[T.untyped])).returns(T.nilable(T::Hash[String, T.untyped]))
end
def parse_details(entry)
return unless entry.is_a?(Array)

# Either:
# - "{name}@{version}", registry, details, integrity
# - "{name}@{resolution}", details
resolution = entry.first
return unless resolution.is_a?(String)

name, version = resolution.split(/(?<=\w)\@/)
semver = Version.semver_for(version)

if semver
registry, details, integrity = entry[1..3]
{
"name" => name,
"version" => semver.to_s,
"registry" => registry,
"details" => details,
"integrity" => integrity
}
else
details = entry[1]
{
"name" => name,
"resolution" => version,
"details" => details
}
end
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ class LockfileParser
require "dependabot/npm_and_yarn/file_parser/yarn_lock"
require "dependabot/npm_and_yarn/file_parser/pnpm_lock"
require "dependabot/npm_and_yarn/file_parser/json_lock"
require "dependabot/npm_and_yarn/file_parser/bun_lock"

DEFAULT_LOCKFILES = %w(package-lock.json yarn.lock pnpm-lock.yaml bun.lock npm-shrinkwrap.json).freeze

LockFile = T.type_alias { T.any(JsonLock, YarnLock, PnpmLock, BunLock) }

sig { params(dependency_files: T::Array[DependencyFile]).void }
def initialize(dependency_files:)
Expand All @@ -29,7 +34,7 @@ def parse_set
# end up unique by name. That's not a perfect representation of
# the nested nature of JS resolution, but it makes everything work
# comparably to other flat-resolution strategies
(yarn_locks + pnpm_locks + package_locks + shrinkwraps).each do |file|
(yarn_locks + pnpm_locks + package_locks + bun_locks + shrinkwraps).each do |file|
dependency_set += lockfile_for(file).dependencies
end

Expand Down Expand Up @@ -64,58 +69,59 @@ def lockfile_details(dependency_name:, requirement:, manifest_name:)
sig { params(manifest_filename: String).returns(T::Array[DependencyFile]) }
def potential_lockfiles_for_manifest(manifest_filename)
dir_name = File.dirname(manifest_filename)
possible_lockfile_names =
%w(package-lock.json npm-shrinkwrap.json pnpm-lock.yaml yarn.lock).map do |f|
Pathname.new(File.join(dir_name, f)).cleanpath.to_path
end +
%w(yarn.lock pnpm-lock.yaml package-lock.json npm-shrinkwrap.json)
possible_lockfile_names = DEFAULT_LOCKFILES.map do |f|
Pathname.new(File.join(dir_name, f)).cleanpath.to_path
end + DEFAULT_LOCKFILES

possible_lockfile_names.uniq
.filter_map { |nm| dependency_files.find { |f| f.name == nm } }
end

sig { params(file: DependencyFile).returns(T.any(JsonLock, YarnLock, PnpmLock)) }
sig { params(file: DependencyFile).returns(LockFile) }
def lockfile_for(file)
@lockfiles ||= T.let({}, T.nilable(T::Hash[String, T.any(JsonLock, YarnLock, PnpmLock)]))
@lockfiles[file.name] ||= if [*package_locks, *shrinkwraps].include?(file)
@lockfiles ||= T.let({}, T.nilable(T::Hash[String, LockFile]))
@lockfiles[file.name] ||= case file.name
when *package_locks.map(&:name), *shrinkwraps.map(&:name)
JsonLock.new(file)
elsif yarn_locks.include?(file)
when *yarn_locks.map(&:name)
YarnLock.new(file)
else
when *pnpm_locks.map(&:name)
PnpmLock.new(file)
when *bun_locks.map(&:name)
BunLock.new(file)
else
raise "Unexpected lockfile: #{file.name}"
end
end

sig { params(extension: String).returns(T::Array[DependencyFile]) }
def select_files_by_extension(extension)
dependency_files.select { |f| f.name.end_with?(extension) }
end

sig { returns(T::Array[DependencyFile]) }
def package_locks
@package_locks ||= T.let(
dependency_files
.select { |f| f.name.end_with?("package-lock.json") }, T.nilable(T::Array[DependencyFile])
)
@package_locks ||= T.let(select_files_by_extension("package-lock.json"), T.nilable(T::Array[DependencyFile]))
end

sig { returns(T::Array[DependencyFile]) }
def pnpm_locks
@pnpm_locks ||= T.let(
dependency_files
.select { |f| f.name.end_with?("pnpm-lock.yaml") }, T.nilable(T::Array[DependencyFile])
)
@pnpm_locks ||= T.let(select_files_by_extension("pnpm-lock.yaml"), T.nilable(T::Array[DependencyFile]))
end

sig { returns(T::Array[DependencyFile]) }
def bun_locks
@bun_locks ||= T.let(select_files_by_extension("bun.lock"), T.nilable(T::Array[DependencyFile]))
end

sig { returns(T::Array[DependencyFile]) }
def yarn_locks
@yarn_locks ||= T.let(
dependency_files
.select { |f| f.name.end_with?("yarn.lock") }, T.nilable(T::Array[DependencyFile])
)
@yarn_locks ||= T.let(select_files_by_extension("yarn.lock"), T.nilable(T::Array[DependencyFile]))
end

sig { returns(T::Array[DependencyFile]) }
def shrinkwraps
@shrinkwraps ||= T.let(
dependency_files
.select { |f| f.name.end_with?("npm-shrinkwrap.json") }, T.nilable(T::Array[DependencyFile])
)
@shrinkwraps ||= T.let(select_files_by_extension("npm-shrinkwrap.json"), T.nilable(T::Array[DependencyFile]))
end

sig { returns(T.class_of(Dependabot::NpmAndYarn::Version)) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,70 @@
end
end
end

context "when dealing with bun.lock" do
context "when the lockfile is invalid" do
let(:dependency_files) { project_dependency_files("bun/invalid_lockfile") }

it "raises a DependencyFileNotParseable error" do
expect { dependencies }
.to raise_error(Dependabot::DependencyFileNotParseable) do |error|
expect(error.file_name).to eq("bun.lock")
expect(error.message).to eq("Invalid bun.lock file: malformed JSONC at line 3, column 1")
end
end
end

context "when the lockfile version is invalid" do
let(:dependency_files) { project_dependency_files("bun/invalid_lockfile_version") }

it "raises a DependencyFileNotParseable error" do
expect { dependencies }
.to raise_error(Dependabot::DependencyFileNotParseable) do |error|
expect(error.file_name).to eq("bun.lock")
expect(error.message).to include("lockfileVersion")
end
end
end

context "when dealing with v0 format" do
context "with a simple project" do
let(:dependency_files) { project_dependency_files("bun/simple_v0") }

it "parses dependencies properly" do
expect(dependencies.find { |d| d.name == "fetch-factory" }).to have_attributes(
name: "fetch-factory",
version: "0.0.1"
)
expect(dependencies.find { |d| d.name == "etag" }).to have_attributes(
name: "etag",
version: "1.8.1"
)
expect(dependencies.length).to eq(11)
end
end

context "with a simple workspace project" do
let(:dependency_files) { project_dependency_files("bun/simple_workspace_v0") }

it "parses dependencies properly" do
expect(dependencies.find { |d| d.name == "etag" }).to have_attributes(
name: "etag",
version: "1.8.1"
)
expect(dependencies.find { |d| d.name == "lodash" }).to have_attributes(
name: "lodash",
version: "1.3.1"
)
expect(dependencies.find { |d| d.name == "chalk" }).to have_attributes(
name: "chalk",
version: "0.3.0"
)
expect(dependencies.length).to eq(5)
end
end
end
end
end

describe "#lockfile_details" do
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# This is an invalid bun.lock file!
[
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"lockfileVersion": -1,
"workspaces": {
"": {},
},
"dependencies": {},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
36 changes: 36 additions & 0 deletions npm_and_yarn/spec/fixtures/projects/bun/simple_v0/bun.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"lockfileVersion": 0,
"workspaces": {
"": {
"dependencies": {
"fetch-factory": "^0.0.1",
},
"devDependencies": {
"etag": "^1.0.0",
},
},
},
"packages": {
"encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="],

"es6-promise": ["es6-promise@3.3.1", "", {}, "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg=="],

"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],

"fetch-factory": ["fetch-factory@0.0.1", "", { "dependencies": { "es6-promise": "^3.0.2", "isomorphic-fetch": "^2.1.1", "lodash": "^3.10.1" } }, "sha512-gexRwqIhwzDJ2pJvL0UYfiZwW06/bdYWxAmswFFts7C87CF8i6liApihTk7TZFYMDcQjvvDIvyHv0q379z0aWA=="],

"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],

"is-stream": ["is-stream@1.1.0", "", {}, "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ=="],

"isomorphic-fetch": ["isomorphic-fetch@2.2.1", "", { "dependencies": { "node-fetch": "^1.0.1", "whatwg-fetch": ">=0.10.0" } }, "sha512-9c4TNAKYXM5PRyVcwUZrF3W09nQ+sO7+jydgs4ZGW9dhsLG2VOlISJABombdQqQRXCwuYG3sYV/puGf5rp0qmA=="],

"lodash": ["lodash@3.10.1", "", {}, "sha512-9mDDwqVIma6OZX79ZlDACZl8sBm0TEnkf99zV3iMA4GzkIT/9hiqP5mY0HoT1iNLCrKc/R1HByV+yJfRWVJryQ=="],

"node-fetch": ["node-fetch@1.7.3", "", { "dependencies": { "encoding": "^0.1.11", "is-stream": "^1.0.1" } }, "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ=="],

"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],

"whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
}
}
Loading

0 comments on commit 94edb2f

Please sign in to comment.