Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add BunLock FileParser #11268

Merged
merged 3 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading