diff --git a/test/fixtures/cargo/Cargo.lock b/test/fixtures/cargo/Cargo.lock new file mode 100644 index 0000000..27ce744 --- /dev/null +++ b/test/fixtures/cargo/Cargo.lock @@ -0,0 +1,25 @@ +[[package]] +name = "fixture-cargo" +version = "0.1.0" +dependencies = [ + "serde 1.0.0", + "tokio 1.37.0" +] + +[[package]] +name = "serde" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "tokio" +version = "1.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "bytes 1.6.0" +] + +[[package]] +name = "bytes" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/test/fixtures/cargo/Cargo.toml b/test/fixtures/cargo/Cargo.toml new file mode 100644 index 0000000..f29e154 --- /dev/null +++ b/test/fixtures/cargo/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "fixture-cargo" +version = "0.1.0" + +[dependencies] +serde = "1.0.0" + +[dev-dependencies] +tokio = { version = "1.37.0", features = ["full"] } diff --git a/test/fixtures/composer/composer.json b/test/fixtures/composer/composer.json new file mode 100644 index 0000000..9c39117 --- /dev/null +++ b/test/fixtures/composer/composer.json @@ -0,0 +1,8 @@ +{ + "require": { + "laravel/framework": "^11.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + } +} diff --git a/test/fixtures/composer/composer.lock b/test/fixtures/composer/composer.lock new file mode 100644 index 0000000..0603532 --- /dev/null +++ b/test/fixtures/composer/composer.lock @@ -0,0 +1,28 @@ +{ + "packages": [ + { + "name": "laravel/framework", + "version": "v11.0.0", + "require": { + "symfony/http-foundation": "^7.0", + "php": "^8.2" + } + }, + { + "name": "symfony/http-foundation", + "version": "v7.0.0", + "require": { + "php": "^8.2" + } + } + ], + "packages-dev": [ + { + "name": "phpunit/phpunit", + "version": "11.0.0", + "require": { + "php": "^8.2" + } + } + ] +} diff --git a/test/fixtures/dart/pubspec.lock b/test/fixtures/dart/pubspec.lock new file mode 100644 index 0000000..1384cb4 --- /dev/null +++ b/test/fixtures/dart/pubspec.lock @@ -0,0 +1,15 @@ +packages: + http: + dependency: "direct main" + description: + name: http + source: hosted + version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + source: hosted + version: "1.25.2" +sdks: + dart: ">=3.0.0 <4.0.0" diff --git a/test/fixtures/dart/pubspec.yaml b/test/fixtures/dart/pubspec.yaml new file mode 100644 index 0000000..e7d0b60 --- /dev/null +++ b/test/fixtures/dart/pubspec.yaml @@ -0,0 +1,7 @@ +name: fixture_dart + +dependencies: + http: ^1.2.1 + +dev_dependencies: + test: ^1.25.2 diff --git a/test/fixtures/docker/Dockerfile b/test/fixtures/docker/Dockerfile new file mode 100644 index 0000000..1ee1611 --- /dev/null +++ b/test/fixtures/docker/Dockerfile @@ -0,0 +1,5 @@ +ARG BASE_IMAGE=python:3.11-slim +FROM --platform=linux/amd64 $BASE_IMAGE AS base +FROM base AS test +FROM scratch AS export +FROM alpine:3.19 diff --git a/test/fixtures/docker/docker-compose.yml b/test/fixtures/docker/docker-compose.yml new file mode 100644 index 0000000..d44c448 --- /dev/null +++ b/test/fixtures/docker/docker-compose.yml @@ -0,0 +1,7 @@ +services: + api: + image: redis:7.2 + worker: + build: . + db: + image: postgres:16 diff --git a/test/fixtures/go/go.mod b/test/fixtures/go/go.mod new file mode 100644 index 0000000..8877575 --- /dev/null +++ b/test/fixtures/go/go.mod @@ -0,0 +1,8 @@ +module example.com/fixture + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/stretchr/testify v1.8.4 // indirect +) diff --git a/test/fixtures/gradle/build.gradle b/test/fixtures/gradle/build.gradle new file mode 100644 index 0000000..1c95cc4 --- /dev/null +++ b/test/fixtures/gradle/build.gradle @@ -0,0 +1,8 @@ +plugins { + id "java" +} + +dependencies { + implementation "org.springframework:spring-core:6.1.0" + testImplementation "junit:junit:4.13.2" +} diff --git a/test/fixtures/gradle/gradle.lockfile b/test/fixtures/gradle/gradle.lockfile new file mode 100644 index 0000000..458d7fb --- /dev/null +++ b/test/fixtures/gradle/gradle.lockfile @@ -0,0 +1,2 @@ +org.springframework:spring-core:6.1.0=compileClasspath +junit:junit:4.13.2=testCompileClasspath diff --git a/test/fixtures/helm/Chart.lock b/test/fixtures/helm/Chart.lock new file mode 100644 index 0000000..3f291a3 --- /dev/null +++ b/test/fixtures/helm/Chart.lock @@ -0,0 +1,5 @@ +dependencies: + - name: redis + version: 19.6.0 +digest: sha256:fixture +generated: "2026-04-05T00:00:00Z" diff --git a/test/fixtures/helm/Chart.yaml b/test/fixtures/helm/Chart.yaml new file mode 100644 index 0000000..2c94405 --- /dev/null +++ b/test/fixtures/helm/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: fixture-chart +version: 0.1.0 +dependencies: + - name: redis + version: 19.6.0 diff --git a/test/fixtures/hex/mix.exs b/test/fixtures/hex/mix.exs new file mode 100644 index 0000000..61c6617 --- /dev/null +++ b/test/fixtures/hex/mix.exs @@ -0,0 +1,16 @@ +defmodule FixtureHex.MixProject do + use Mix.Project + + def project do + [ + app: :fixture_hex, + version: "0.1.0" + ] + end + + defp deps do + [ + {:jason, "~> 1.4"} + ] + end +end diff --git a/test/fixtures/hex/mix.lock b/test/fixtures/hex/mix.lock new file mode 100644 index 0000000..4847e99 --- /dev/null +++ b/test/fixtures/hex/mix.lock @@ -0,0 +1,3 @@ +%{ + "jason": {:hex, :jason, "1.4.1", "checksum", [:mix], [], "hexpm", "checksum"} +} diff --git a/test/fixtures/maven/dependency-tree.txt b/test/fixtures/maven/dependency-tree.txt new file mode 100644 index 0000000..51150ba --- /dev/null +++ b/test/fixtures/maven/dependency-tree.txt @@ -0,0 +1,3 @@ +[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:3.2.0:compile +[INFO] | \- org.springframework:spring-core:jar:6.1.0:compile +[INFO] \- junit:junit:jar:4.13.2:test diff --git a/test/fixtures/maven/pom.xml b/test/fixtures/maven/pom.xml new file mode 100644 index 0000000..1146e69 --- /dev/null +++ b/test/fixtures/maven/pom.xml @@ -0,0 +1,14 @@ + + + + org.springframework.boot + spring-boot-starter-web + 3.2.0 + + + junit + junit + test + + + diff --git a/test/fixtures/npm/package-lock.json b/test/fixtures/npm/package-lock.json new file mode 100644 index 0000000..5b9e20e --- /dev/null +++ b/test/fixtures/npm/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "fixture-app", + "lockfileVersion": 3, + "packages": { + "": { + "name": "fixture-app", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.2", + "@scope/pkg": "^1.0.0" + } + }, + "node_modules/express": { + "version": "4.18.2", + "dependencies": { + "accepts": "~1.3.8" + } + }, + "node_modules/accepts": { + "version": "1.3.8" + }, + "node_modules/@scope/pkg": { + "version": "1.0.0" + }, + "node_modules/another/node_modules/accepts": { + "version": "1.3.8" + } + } +} diff --git a/test/fixtures/npm/package.json b/test/fixtures/npm/package.json new file mode 100644 index 0000000..71c3c31 --- /dev/null +++ b/test/fixtures/npm/package.json @@ -0,0 +1,8 @@ +{ + "name": "fixture-app", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.2", + "@scope/pkg": "^1.0.0" + } +} diff --git a/test/fixtures/npm/pnpm-lock.yaml b/test/fixtures/npm/pnpm-lock.yaml new file mode 100644 index 0000000..6888293 --- /dev/null +++ b/test/fixtures/npm/pnpm-lock.yaml @@ -0,0 +1,20 @@ +lockfileVersion: "9.0" +importers: + .: + dependencies: + express: + specifier: ^4.18.2 + version: 4.18.2 + "@scope/pkg": + specifier: ^1.0.0 + version: 1.0.0 +packages: + express@4.18.2: + dependencies: + accepts: 1.3.8 + accepts@1.3.8: + resolution: + integrity: sha512-abc + "@scope/pkg@1.0.0": + resolution: + integrity: sha512-def diff --git a/test/fixtures/npm/yarn.lock b/test/fixtures/npm/yarn.lock new file mode 100644 index 0000000..69e5716 --- /dev/null +++ b/test/fixtures/npm/yarn.lock @@ -0,0 +1,10 @@ +express@^4.18.2: + version "4.18.2" + dependencies: + accepts "~1.3.8" + +accepts@~1.3.8: + version "1.3.8" + +"@scope/pkg@^1.0.0": + version "1.0.0" diff --git a/test/fixtures/nuget/Fixture.csproj b/test/fixtures/nuget/Fixture.csproj new file mode 100644 index 0000000..cbf45f6 --- /dev/null +++ b/test/fixtures/nuget/Fixture.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/test/fixtures/nuget/packages.lock.json b/test/fixtures/nuget/packages.lock.json new file mode 100644 index 0000000..550fcac --- /dev/null +++ b/test/fixtures/nuget/packages.lock.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "dependencies": { + ".NETCoreApp,Version=v8.0": { + "Newtonsoft.Json": { + "type": "Direct", + "resolved": "13.0.3", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0" + } + } + } +} diff --git a/test/fixtures/python/Pipfile.lock b/test/fixtures/python/Pipfile.lock new file mode 100644 index 0000000..5687d4c --- /dev/null +++ b/test/fixtures/python/Pipfile.lock @@ -0,0 +1,12 @@ +{ + "default": { + "flask": { + "version": "==2.3.0" + } + }, + "develop": { + "pytest": { + "version": "==8.2.0" + } + } +} diff --git a/test/fixtures/python/poetry.lock b/test/fixtures/python/poetry.lock new file mode 100644 index 0000000..d2d422d --- /dev/null +++ b/test/fixtures/python/poetry.lock @@ -0,0 +1,14 @@ +[[package]] +name = "flask" +version = "2.3.0" + +[package.dependencies] +click = ">=8.0" + +[[package]] +name = "requests" +version = "2.28.0" + +[[package]] +name = "click" +version = "8.1.7" diff --git a/test/fixtures/python/pyproject.toml b/test/fixtures/python/pyproject.toml new file mode 100644 index 0000000..5f49c62 --- /dev/null +++ b/test/fixtures/python/pyproject.toml @@ -0,0 +1,15 @@ +[tool.poetry] +name = "fixture-python" +version = "0.1.0" + +[tool.poetry.dependencies] +python = "^3.11" +# Web +flask = "^2.3.0" +requests = { version = "^2.28.0", optional = true } # client + +[project] +name = "fixture-python" +dependencies = [ + "fastapi==0.111.0", # API +] diff --git a/test/fixtures/python/requirements.txt b/test/fixtures/python/requirements.txt new file mode 100644 index 0000000..56868ab --- /dev/null +++ b/test/fixtures/python/requirements.txt @@ -0,0 +1,2 @@ +flask==2.3.0 +requests>=2.28.0 diff --git a/test/fixtures/python/uv.lock b/test/fixtures/python/uv.lock new file mode 100644 index 0000000..f003f62 --- /dev/null +++ b/test/fixtures/python/uv.lock @@ -0,0 +1,18 @@ +[[package]] +name = "fixture-python" +version = "0.1.0" +source = { editable = "." } +dependencies = [{ name = "fastapi" }] + +[[package]] +name = "fastapi" +version = "0.111.0" +dependencies = ["starlette", "pydantic"] + +[[package]] +name = "starlette" +version = "0.37.2" + +[[package]] +name = "pydantic" +version = "2.7.0" diff --git a/test/fixtures/ruby/Gemfile b/test/fixtures/ruby/Gemfile new file mode 100644 index 0000000..cc4b5e5 --- /dev/null +++ b/test/fixtures/ruby/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "rails", "~> 7.1.3" diff --git a/test/fixtures/ruby/Gemfile.lock b/test/fixtures/ruby/Gemfile.lock new file mode 100644 index 0000000..035bc10 --- /dev/null +++ b/test/fixtures/ruby/Gemfile.lock @@ -0,0 +1,9 @@ +GEM + remote: https://rubygems.org/ + specs: + rails (7.1.3) + actionpack (= 7.1.3) + actionpack (7.1.3) + +DEPENDENCIES + rails (~> 7.1.3) diff --git a/test/fixtures/swift/Package.resolved b/test/fixtures/swift/Package.resolved new file mode 100644 index 0000000..4294e8a --- /dev/null +++ b/test/fixtures/swift/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins": [ + { + "identity": "alamofire", + "kind": "remoteSourceControl", + "location": "https://github.com/Alamofire/Alamofire.git", + "state": { + "revision": "abcdef1234567890", + "version": "5.8.0" + } + } + ], + "version": 2 +} diff --git a/test/fixtures/swift/Package.swift b/test/fixtures/swift/Package.swift new file mode 100644 index 0000000..206e9ed --- /dev/null +++ b/test/fixtures/swift/Package.swift @@ -0,0 +1,9 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "FixtureSwift", + dependencies: [ + .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.8.0") + ] +) diff --git a/test/helpers/fixtureWorkspace.js b/test/helpers/fixtureWorkspace.js new file mode 100644 index 0000000..c074d2d --- /dev/null +++ b/test/helpers/fixtureWorkspace.js @@ -0,0 +1,46 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +async function makeTempWorkspace(prefix = "cloudsmith-lockfile-") { + return fs.promises.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +async function copyFixtureDir(fixtureName, targetDir) { + const sourceDir = path.join(__dirname, "..", "fixtures", fixtureName); + await copyDirectory(sourceDir, targetDir); +} + +async function copyDirectory(sourceDir, targetDir) { + await fs.promises.mkdir(targetDir, { recursive: true }); + const entries = await fs.promises.readdir(sourceDir, { withFileTypes: true }); + + for (const entry of entries) { + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + + if (entry.isDirectory()) { + await copyDirectory(sourcePath, targetPath); + continue; + } + + await fs.promises.copyFile(sourcePath, targetPath); + } +} + +async function writeTextFile(targetPath, content) { + await fs.promises.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.promises.writeFile(targetPath, content, "utf8"); +} + +async function removeDirectory(targetDir) { + await fs.promises.rm(targetDir, { recursive: true, force: true }); +} + +module.exports = { + copyDirectory, + copyFixtureDir, + makeTempWorkspace, + removeDirectory, + writeTextFile, +}; diff --git a/test/lockfileParsers/cargoParser.test.js b/test/lockfileParsers/cargoParser.test.js new file mode 100644 index 0000000..c046811 --- /dev/null +++ b/test/lockfileParsers/cargoParser.test.js @@ -0,0 +1,119 @@ +const assert = require("assert"); +const path = require("path"); +const cargoParser = require("../../util/lockfileParsers/cargoParser"); +const { + makeTempWorkspace, + removeDirectory, + writeTextFile, +} = require("../helpers/fixtureWorkspace"); + +suite("cargoParser Test Suite", () => { + const fixtureDir = path.join(__dirname, "..", "fixtures", "cargo"); + const tempDirs = []; + + async function createWorkspace() { + const workspace = await makeTempWorkspace("cloudsmith-cargo-parser-"); + tempDirs.push(workspace); + return workspace; + } + + suiteTeardown(async () => { + await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir))); + }); + + test("resolves Cargo.lock uniquely, skips the root package, and marks direct dependencies from Cargo.toml", async () => { + const tree = await cargoParser.resolve({ + lockfilePath: path.join(fixtureDir, "Cargo.lock"), + manifestPath: path.join(fixtureDir, "Cargo.toml"), + }); + + assert.strictEqual(tree.sourceFile, "Cargo.lock"); + assert.strictEqual(tree.dependencies.length, 3); + assert.strictEqual(tree.dependencies.some((dependency) => dependency.name === "fixture-cargo"), false); + + const serde = tree.dependencies.find((dependency) => dependency.name === "serde"); + const tokio = tree.dependencies.find((dependency) => dependency.name === "tokio"); + const bytes = tree.dependencies.find((dependency) => dependency.name === "bytes"); + + assert.ok(serde); + assert.ok(tokio); + assert.ok(bytes); + assert.strictEqual(serde.isDirect, true); + assert.strictEqual(tokio.isDirect, true); + assert.strictEqual(bytes.isDirect, false); + assert.deepStrictEqual(bytes.parentChain, ["tokio"]); + }); + + test("detect returns no matches when Cargo files are missing", async () => { + const workspace = await createWorkspace(); + + const matches = await cargoParser.detect(workspace); + + assert.deepStrictEqual(matches, []); + assert.strictEqual(await cargoParser.canResolve(workspace), false); + }); + + test("throws for malformed Cargo.lock files", async () => { + const workspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "Cargo.lock"); + const manifestPath = path.join(workspace, "Cargo.toml"); + await writeTextFile(lockfilePath, "[[package]]\nname = \"broken\"\n"); + await writeTextFile(manifestPath, "[dependencies]\nserde = \"1.0.0\"\n"); + + await assert.rejects( + () => cargoParser.resolve({ lockfilePath, manifestPath }), + /Malformed Cargo\.lock: no package entries found/ + ); + }); + + test("deduplicates large Cargo graphs down to unique packages", async () => { + const workspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "Cargo.lock"); + const manifestPath = path.join(workspace, "Cargo.toml"); + const packageCount = 300; + const registrySource = "registry+https://github.com/rust-lang/crates.io-index"; + + const manifestLines = [ + "[package]", + 'name = "fixture-cargo"', + 'version = "0.1.0"', + "", + "[dependencies]", + 'crate-000 = "1.0.0"', + ]; + + const lockEntries = []; + for (let index = 0; index < packageCount; index += 1) { + const currentName = `crate-${String(index).padStart(3, "0")}`; + const nextName = index + 1 < packageCount + ? `crate-${String(index + 1).padStart(3, "0")}` + : null; + lockEntries.push( + [ + "[[package]]", + `name = "${currentName}"`, + 'version = "1.0.0"', + `source = "${registrySource}"`, + nextName + ? `dependencies = ["${nextName} 1.0.0"]` + : "", + "", + ].filter(Boolean).join("\n") + ); + } + + await writeTextFile(manifestPath, manifestLines.join("\n")); + await writeTextFile(lockfilePath, lockEntries.join("\n")); + + const tree = await cargoParser.resolve({ + lockfilePath, + manifestPath, + }); + + assert.strictEqual(tree.dependencies.length, packageCount); + assert.strictEqual( + new Set(tree.dependencies.map((dependency) => `${dependency.name}@${dependency.version}`)).size, + packageCount + ); + }); +}); diff --git a/test/lockfileParsers/dockerParser.test.js b/test/lockfileParsers/dockerParser.test.js new file mode 100644 index 0000000..ec6d670 --- /dev/null +++ b/test/lockfileParsers/dockerParser.test.js @@ -0,0 +1,78 @@ +const assert = require("assert"); +const path = require("path"); +const dockerParser = require("../../util/lockfileParsers/dockerParser"); +const { + makeTempWorkspace, + removeDirectory, + writeTextFile, +} = require("../helpers/fixtureWorkspace"); + +suite("dockerParser Test Suite", () => { + const fixtureDir = path.join(__dirname, "..", "fixtures", "docker"); + const tempDirs = []; + + async function createWorkspace() { + const workspace = await makeTempWorkspace("cloudsmith-docker-parser-"); + tempDirs.push(workspace); + return workspace; + } + + suiteTeardown(async () => { + await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir))); + }); + + test("parses Dockerfile FROM instructions and skips scratch and stage references", async () => { + const tree = await dockerParser.resolve({ + lockfilePath: path.join(fixtureDir, "Dockerfile"), + }); + + assert.strictEqual(tree.sourceFile, "Dockerfile"); + assert.deepStrictEqual( + tree.dependencies.map((dependency) => `${dependency.name}:${dependency.version}`), + ["python:3.11-slim", "alpine:3.19"] + ); + }); + + test("parses docker-compose images and skips build-only services", async () => { + const tree = await dockerParser.resolve({ + lockfilePath: path.join(fixtureDir, "docker-compose.yml"), + }); + + assert.strictEqual(tree.sourceFile, "docker-compose.yml"); + assert.deepStrictEqual( + tree.dependencies.map((dependency) => `${dependency.name}:${dependency.version}`), + ["redis:7.2", "postgres:16"] + ); + }); + + test("detect returns no matches when Docker manifests are missing", async () => { + const workspace = await createWorkspace(); + + const matches = await dockerParser.detect(workspace); + + assert.deepStrictEqual(matches, []); + assert.strictEqual(await dockerParser.canResolve(workspace), false); + }); + + test("detect returns no matches for invalid workspace roots", async () => { + const workspace = await createWorkspace(); + const matches = await dockerParser.detect(path.join(workspace, "missing-workspace")); + + assert.deepStrictEqual(matches, []); + }); + + test("ignores malformed FROM lines that do not resolve to image references", async () => { + const workspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "Dockerfile"); + await writeTextFile(lockfilePath, [ + "ARG BASE_IMAGE", + "FROM $BASE_IMAGE", + "FROM scratch", + "", + ].join("\n")); + + const tree = await dockerParser.resolve({ lockfilePath }); + + assert.strictEqual(tree.dependencies.length, 0); + }); +}); diff --git a/test/lockfileParsers/mavenParser.test.js b/test/lockfileParsers/mavenParser.test.js new file mode 100644 index 0000000..a95cdba --- /dev/null +++ b/test/lockfileParsers/mavenParser.test.js @@ -0,0 +1,81 @@ +const assert = require("assert"); +const path = require("path"); +const mavenParser = require("../../util/lockfileParsers/mavenParser"); +const { + makeTempWorkspace, + removeDirectory, + writeTextFile, +} = require("../helpers/fixtureWorkspace"); + +suite("mavenParser Test Suite", () => { + const fixtureDir = path.join(__dirname, "..", "fixtures", "maven"); + const tempDirs = []; + + async function createWorkspace() { + const workspace = await makeTempWorkspace("cloudsmith-maven-parser-"); + tempDirs.push(workspace); + return workspace; + } + + suiteTeardown(async () => { + await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir))); + }); + + test("hydrates direct dependencies from pom.xml and transitives from dependency-tree.txt", async () => { + const tree = await mavenParser.resolve({ + lockfilePath: path.join(fixtureDir, "dependency-tree.txt"), + manifestPath: path.join(fixtureDir, "pom.xml"), + }); + + assert.strictEqual(tree.sourceFile, "pom.xml"); + assert.strictEqual(tree.dependencies.length, 3); + + const starter = tree.dependencies.find((dependency) => ( + dependency.name === "org.springframework.boot:spring-boot-starter-web" + )); + const springCore = tree.dependencies.find((dependency) => dependency.name === "org.springframework:spring-core"); + const junit = tree.dependencies.find((dependency) => dependency.name === "junit:junit"); + + assert.ok(starter); + assert.ok(springCore); + assert.ok(junit); + assert.strictEqual(starter.isDirect, true); + assert.strictEqual(springCore.isDirect, false); + assert.strictEqual(junit.version, "4.13.2"); + assert.strictEqual(junit.isDevelopmentDependency, true); + }); + + test("detect returns no matches when pom.xml is missing", async () => { + const workspace = await createWorkspace(); + + const matches = await mavenParser.detect(workspace); + + assert.deepStrictEqual(matches, []); + assert.strictEqual(await mavenParser.canResolve(workspace), false); + }); + + test("ignores malformed dependency tree lines and still returns manifest dependencies", async () => { + const workspace = await createWorkspace(); + const manifestPath = path.join(workspace, "pom.xml"); + const lockfilePath = path.join(workspace, "dependency-tree.txt"); + await writeTextFile(manifestPath, [ + "", + " ", + " ", + " org.springframework.boot", + " spring-boot-starter", + " 3.2.0", + " ", + " ", + "", + "", + ].join("\n")); + await writeTextFile(lockfilePath, "this is not a Maven dependency tree\n"); + + const tree = await mavenParser.resolve({ lockfilePath, manifestPath }); + + assert.strictEqual(tree.dependencies.length, 1); + assert.strictEqual(tree.dependencies[0].name, "org.springframework.boot:spring-boot-starter"); + assert.strictEqual(tree.dependencies[0].isDirect, true); + }); +}); diff --git a/test/lockfileParsers/npmParser.test.js b/test/lockfileParsers/npmParser.test.js new file mode 100644 index 0000000..1a5f0ca --- /dev/null +++ b/test/lockfileParsers/npmParser.test.js @@ -0,0 +1,258 @@ +const assert = require("assert"); +const path = require("path"); +const npmParser = require("../../util/lockfileParsers/npmParser"); +const { + makeTempWorkspace, + removeDirectory, + writeTextFile, +} = require("../helpers/fixtureWorkspace"); + +suite("npmParser Test Suite", () => { + const fixtureDir = path.join(__dirname, "..", "fixtures", "npm"); + const tempDirs = []; + + async function createWorkspace() { + const workspace = await makeTempWorkspace("cloudsmith-npm-parser-"); + tempDirs.push(workspace); + return workspace; + } + + suiteTeardown(async () => { + await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir))); + }); + + test("resolves package-lock.json with deduplication, scoped packages, and root skipping", async () => { + const tree = await npmParser.resolve({ + lockfilePath: path.join(fixtureDir, "package-lock.json"), + manifestPath: path.join(fixtureDir, "package.json"), + options: { maxDependenciesToScan: 10000 }, + }); + + assert.strictEqual(tree.sourceFile, "package-lock.json"); + assert.strictEqual(tree.dependencies.length, 3); + assert.strictEqual(tree.dependencies.some((dependency) => dependency.name === "fixture-app"), false); + + const express = tree.dependencies.find((dependency) => dependency.name === "express"); + const accepts = tree.dependencies.find((dependency) => dependency.name === "accepts"); + const scoped = tree.dependencies.find((dependency) => dependency.name === "@scope/pkg"); + + assert.ok(express); + assert.ok(accepts); + assert.ok(scoped); + assert.strictEqual(express.isDirect, true); + assert.strictEqual(accepts.isDirect, false); + assert.deepStrictEqual(accepts.parentChain, ["express"]); + assert.strictEqual(scoped.version, "1.0.0"); + }); + + test("resolves yarn.lock fixtures", async () => { + const tree = await npmParser.resolve({ + lockfilePath: path.join(fixtureDir, "yarn.lock"), + manifestPath: path.join(fixtureDir, "package.json"), + options: { maxDependenciesToScan: 10000 }, + }); + + assert.strictEqual(tree.sourceFile, "yarn.lock"); + assert.strictEqual(tree.dependencies.length, 3); + assert.ok(tree.dependencies.some((dependency) => dependency.name === "@scope/pkg")); + }); + + test("preserves multiple resolved versions for the same yarn package", async () => { + const workspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "yarn.lock"); + const manifestPath = path.join(workspace, "package.json"); + + await writeTextFile(manifestPath, JSON.stringify({ + name: "fixture-app", + version: "1.0.0", + dependencies: { + "package-a": "^1.0.0", + "package-b": "^1.0.0", + }, + }, null, 2)); + + await writeTextFile(lockfilePath, [ + "package-a@^1.0.0:", + ' version "1.0.0"', + " dependencies:", + ' left-pad "^1.0.0"', + "", + "package-b@^1.0.0:", + ' version "1.0.0"', + " dependencies:", + ' left-pad "^2.0.0"', + "", + "left-pad@^1.0.0:", + ' version "1.0.1"', + "", + "left-pad@^2.0.0:", + ' version "2.0.0"', + "", + ].join("\n")); + + const tree = await npmParser.resolve({ + lockfilePath, + manifestPath, + options: { maxDependenciesToScan: 10000 }, + }); + + const packageKeys = tree.dependencies.map((dependency) => `${dependency.name}@${dependency.version}`); + const packageA = tree.dependencies.find((dependency) => dependency.name === "package-a"); + const packageB = tree.dependencies.find((dependency) => dependency.name === "package-b"); + + assert.ok(packageA); + assert.ok(packageB); + assert.strictEqual(packageA.transitives[0].version, "1.0.1"); + assert.strictEqual(packageB.transitives[0].version, "2.0.0"); + assert.ok(packageKeys.includes("left-pad@1.0.1")); + assert.ok(packageKeys.includes("left-pad@2.0.0")); + assert.strictEqual(packageKeys.filter((key) => key.startsWith("left-pad@")).length, 2); + }); + + test("resolves pnpm-lock.yaml fixtures", async () => { + const tree = await npmParser.resolve({ + lockfilePath: path.join(fixtureDir, "pnpm-lock.yaml"), + manifestPath: path.join(fixtureDir, "package.json"), + options: { maxDependenciesToScan: 10000 }, + }); + + assert.strictEqual(tree.sourceFile, "pnpm-lock.yaml"); + assert.strictEqual(tree.dependencies.length, 3); + assert.ok(tree.dependencies.some((dependency) => dependency.name === "accepts")); + }); + + test("detect returns no matches when npm lockfiles are missing", async () => { + const workspace = await createWorkspace(); + + const matches = await npmParser.detect(workspace); + + assert.deepStrictEqual(matches, []); + assert.strictEqual(await npmParser.canResolve(workspace), false); + }); + + test("resolve ignores manifests outside the provided workspace folder", async () => { + const workspace = await createWorkspace(); + const outsideWorkspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "package-lock.json"); + const manifestPath = path.join(outsideWorkspace, "package.json"); + + await writeTextFile(lockfilePath, JSON.stringify({ + name: "fixture-app", + lockfileVersion: 3, + packages: { + "": {}, + "node_modules/express": { + version: "4.18.2", + }, + }, + }, null, 2)); + await writeTextFile(manifestPath, JSON.stringify({ + dependencies: { + express: "^4.18.0", + }, + }, null, 2)); + + const tree = await npmParser.resolve({ + workspaceFolder: workspace, + lockfilePath, + manifestPath, + options: { maxDependenciesToScan: 10000 }, + }); + + const express = tree.dependencies.find((dependency) => dependency.name === "express"); + + assert.ok(express); + assert.strictEqual( + express.isDirect, + false, + "out-of-workspace manifests should not influence direct dependency classification" + ); + }); + + test("throws for malformed package-lock.json files", async () => { + const workspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "package-lock.json"); + const manifestPath = path.join(workspace, "package.json"); + await writeTextFile(lockfilePath, "{\n \"name\": \"broken\"\n}\n"); + await writeTextFile(manifestPath, "{\n \"dependencies\": {}\n}\n"); + + await assert.rejects( + () => npmParser.resolve({ + lockfilePath, + manifestPath, + options: { maxDependenciesToScan: 10000 }, + }), + /missing packages object/ + ); + }); + + test("adds a warning when the unique dependency count exceeds the scan cap", async () => { + const tree = await npmParser.resolve({ + lockfilePath: path.join(fixtureDir, "package-lock.json"), + manifestPath: path.join(fixtureDir, "package.json"), + options: { maxDependenciesToScan: 2 }, + }); + + assert.strictEqual(tree.warnings.length, 1); + assert.match(tree.warnings[0], /Display is capped at 2 dependencies/); + }); + + test("includes orphaned package-lock entries once even when duplicate package records share a key", async () => { + const workspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "package-lock.json"); + const manifestPath = path.join(workspace, "package.json"); + + await writeTextFile(manifestPath, JSON.stringify({ + name: "fixture-app", + version: "1.0.0", + dependencies: { + express: "1.0.0", + }, + }, null, 2)); + + await writeTextFile(lockfilePath, JSON.stringify({ + name: "fixture-app", + version: "1.0.0", + lockfileVersion: 3, + packages: { + "": { + dependencies: { + express: "1.0.0", + }, + }, + "node_modules/express": { + version: "1.0.0", + dependencies: { + accepts: "1.0.0", + shared: "1.0.0", + }, + }, + "node_modules/accepts": { + version: "1.0.0", + }, + "node_modules/shared": { + version: "1.0.0", + }, + "node_modules/express/node_modules/shared": { + version: "1.0.0", + }, + "node_modules/orphan": { + version: "2.0.0", + }, + }, + }, null, 2)); + + const tree = await npmParser.resolve({ + lockfilePath, + manifestPath, + options: { maxDependenciesToScan: 10000 }, + }); + + const packageKeys = tree.dependencies.map((dependency) => `${dependency.name}@${dependency.version}`); + + assert.strictEqual(packageKeys.filter((key) => key === "shared@1.0.0").length, 1); + assert.strictEqual(packageKeys.filter((key) => key === "orphan@2.0.0").length, 1); + assert.ok(packageKeys.includes("express@1.0.0")); + assert.ok(packageKeys.includes("accepts@1.0.0")); + }); +}); diff --git a/test/lockfileParsers/nugetParser.test.js b/test/lockfileParsers/nugetParser.test.js new file mode 100644 index 0000000..68454ca --- /dev/null +++ b/test/lockfileParsers/nugetParser.test.js @@ -0,0 +1,28 @@ +const assert = require("assert"); +const path = require("path"); +const nugetParser = require("../../util/lockfileParsers/nugetParser"); +const { + makeTempWorkspace, + removeDirectory, +} = require("../helpers/fixtureWorkspace"); + +suite("nugetParser Test Suite", () => { + const tempDirs = []; + + async function createWorkspace() { + const workspace = await makeTempWorkspace("cloudsmith-nuget-parser-"); + tempDirs.push(workspace); + return workspace; + } + + suiteTeardown(async () => { + await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir))); + }); + + test("detect returns no matches for invalid workspace roots", async () => { + const workspace = await createWorkspace(); + const matches = await nugetParser.detect(path.join(workspace, "missing-workspace")); + + assert.deepStrictEqual(matches, []); + }); +}); diff --git a/test/lockfileParsers/pythonParser.test.js b/test/lockfileParsers/pythonParser.test.js new file mode 100644 index 0000000..f040ea1 --- /dev/null +++ b/test/lockfileParsers/pythonParser.test.js @@ -0,0 +1,109 @@ +const assert = require("assert"); +const path = require("path"); +const pythonParser = require("../../util/lockfileParsers/pythonParser"); +const { + makeTempWorkspace, + removeDirectory, + writeTextFile, +} = require("../helpers/fixtureWorkspace"); + +suite("pythonParser Test Suite", () => { + const fixtureDir = path.join(__dirname, "..", "fixtures", "python"); + const tempDirs = []; + + async function createWorkspace() { + const workspace = await makeTempWorkspace("cloudsmith-python-parser-"); + tempDirs.push(workspace); + return workspace; + } + + suiteTeardown(async () => { + await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir))); + }); + + test("resolves poetry.lock and keeps all package entries while marking directs from pyproject.toml", async () => { + const tree = await pythonParser.resolve({ + lockfilePath: path.join(fixtureDir, "poetry.lock"), + manifestPath: path.join(fixtureDir, "pyproject.toml"), + }); + + assert.strictEqual(tree.sourceFile, "poetry.lock"); + assert.strictEqual(tree.dependencies.length, 3); + + const flask = tree.dependencies.find((dependency) => dependency.name === "flask"); + const requests = tree.dependencies.find((dependency) => dependency.name === "requests"); + const click = tree.dependencies.find((dependency) => dependency.name === "click"); + + assert.ok(flask); + assert.ok(requests); + assert.ok(click); + assert.strictEqual(flask.isDirect, true); + assert.strictEqual(requests.isDirect, true); + assert.strictEqual(click.isDirect, false); + assert.deepStrictEqual(click.parentChain, ["flask"]); + }); + + test("skips the editable uv root package and resolves its transitive dependencies", async () => { + const tree = await pythonParser.resolve({ + lockfilePath: path.join(fixtureDir, "uv.lock"), + manifestPath: path.join(fixtureDir, "pyproject.toml"), + }); + + assert.strictEqual(tree.sourceFile, "uv.lock"); + assert.strictEqual(tree.dependencies.some((dependency) => dependency.name === "fixture-python"), false); + assert.strictEqual(tree.dependencies.length, 3); + assert.ok(tree.dependencies.some((dependency) => dependency.name === "fastapi" && dependency.isDirect)); + assert.ok(tree.dependencies.some((dependency) => dependency.name === "starlette" && !dependency.isDirect)); + assert.ok(tree.dependencies.some((dependency) => dependency.name === "pydantic" && !dependency.isDirect)); + }); + + test("warns when only requirements.txt is available", async () => { + const tree = await pythonParser.resolve({ + lockfilePath: path.join(fixtureDir, "requirements.txt"), + }); + + assert.strictEqual(tree.sourceFile, "requirements.txt"); + assert.strictEqual(tree.dependencies.length, 2); + assert.strictEqual(tree.dependencies.every((dependency) => dependency.isDirect), true); + assert.strictEqual(tree.warnings.length, 1); + assert.match(tree.warnings[0], /requirements\.txt does not encode transitive dependencies/i); + }); + + test("detect returns no matches when Python dependency files are missing", async () => { + const workspace = await createWorkspace(); + + const matches = await pythonParser.detect(workspace); + + assert.deepStrictEqual(matches, []); + assert.strictEqual(await pythonParser.canResolve(workspace), false); + }); + + test("resolve rejects lockfiles outside the provided workspace folder", async () => { + const workspace = await createWorkspace(); + const outsideWorkspace = await createWorkspace(); + const lockfilePath = path.join(outsideWorkspace, "requirements.txt"); + + await writeTextFile(lockfilePath, "requests==2.31.0\n"); + + await assert.rejects( + () => pythonParser.resolve({ + workspaceFolder: workspace, + lockfilePath, + }), + /Refusing to read files outside the workspace folder/ + ); + }); + + test("throws for malformed poetry.lock files", async () => { + const workspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "poetry.lock"); + const manifestPath = path.join(workspace, "pyproject.toml"); + await writeTextFile(lockfilePath, "[metadata]\nlock-version = \"2.0\"\n"); + await writeTextFile(manifestPath, "[tool.poetry.dependencies]\nflask = \"^2.3.0\"\n"); + + await assert.rejects( + () => pythonParser.resolve({ lockfilePath, manifestPath }), + /no package entries found/ + ); + }); +}); diff --git a/util/lockfileParsers/cargoParser.js b/util/lockfileParsers/cargoParser.js new file mode 100644 index 0000000..06199ff --- /dev/null +++ b/util/lockfileParsers/cargoParser.js @@ -0,0 +1,283 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + parseKeyValueLine, + pathExists, + readUtf8, +} = require("./shared"); +const { parseCargoTomlManifest } = require("./manifestHelpers"); + +const cargoParser = { + name: "cargoParser", + ecosystem: "cargo", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "Cargo.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "Cargo.toml"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "Cargo.lock"), workspaceFolder) + ? path.join(rootPath, "Cargo.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "Cargo.toml"), workspaceFolder) + ? path.join(rootPath, "Cargo.toml") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const manifestDependencies = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? parseCargoTomlManifest(await readUtf8(manifestPath, workspaceFolder)) + : []; + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + + if (!lockfilePath) { + return buildTree("cargo", sourceFile, manifestDependencies.map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "cargo", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + }))); + } + + const records = parseCargoLock(await readUtf8(lockfilePath, workspaceFolder)); + if (records.length === 0) { + throw new Error("Malformed Cargo.lock: no package entries found"); + } + const directNames = new Set(manifestDependencies.map((dependency) => dependency.name.toLowerCase())); + const recordsByName = new Map(); + const incomingCounts = new Map(); + + for (const record of records) { + if (!recordsByName.has(record.name.toLowerCase())) { + recordsByName.set(record.name.toLowerCase(), []); + } + recordsByName.get(record.name.toLowerCase()).push(record); + for (const dependency of record.dependencies) { + incomingCounts.set( + dependency.name.toLowerCase(), + (incomingCounts.get(dependency.name.toLowerCase()) || 0) + 1 + ); + } + } + + const rootRecords = manifestDependencies.length > 0 + ? manifestDependencies.map((dependency) => selectCargoRecord(recordsByName, dependency.name, dependency.version)).filter(Boolean) + : records.filter((record) => !incomingCounts.get(record.name.toLowerCase())); + + const directRoots = deduplicateDeps(rootRecords.map((record) => buildCargoDependency( + record, + [], + recordsByName, + new Set(), + sourceFile, + directNames + ))); + + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + for (const record of records) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) { + continue; + } + dependencies.push(createDependency({ + name: record.name, + version: record.version, + ecosystem: "cargo", + isDirect: directNames.has(record.name.toLowerCase()), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return buildTree("cargo", sourceFile, dependencies); + }, +}; + +function parseCargoLock(content) { + const records = []; + let current = null; + let inDependenciesArray = false; + + const flushCurrent = () => { + if (!current || !current.name || !current.version) { + current = null; + inDependenciesArray = false; + return; + } + const source = String(current.source || "").trim(); + if (source && !source.startsWith("path+") && !source.startsWith("git+")) { + records.push(current); + } + current = null; + inDependenciesArray = false; + }; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + if (line === "[[package]]") { + flushCurrent(); + current = { name: "", version: "", source: "", dependencies: [] }; + continue; + } + if (!current) { + continue; + } + + if (inDependenciesArray) { + if (line === "]") { + inDependenciesArray = false; + continue; + } + const match = line.trim().replace(/,$/, "").replace(/^"|"$/g, "").match(/^([^ ]+)(?: ([^ ]+))?/); + if (match) { + current.dependencies.push({ + name: match[1], + version: match[2] ? match[2].replace(/^\(/, "").replace(/\)$/, "") : "", + }); + } + continue; + } + + if (line.startsWith("name =")) { + current.name = parseKeyValueLine(line).value.replace(/^"|"$/g, ""); + continue; + } + if (line.startsWith("version =")) { + current.version = parseKeyValueLine(line).value.replace(/^"|"$/g, ""); + continue; + } + if (line.startsWith("source =")) { + current.source = parseKeyValueLine(line).value.replace(/^"|"$/g, ""); + continue; + } + if (line.startsWith("dependencies = [")) { + inDependenciesArray = true; + const inline = line.slice(line.indexOf("[") + 1, line.lastIndexOf("]")); + if (inline.trim()) { + for (const item of inline.split(",")) { + const cleaned = item.trim().replace(/^"|"$/g, ""); + if (!cleaned) { + continue; + } + const match = cleaned.match(/^([^ ]+)(?: ([^ ]+))?/); + if (match) { + current.dependencies.push({ name: match[1], version: match[2] || "" }); + } + } + inDependenciesArray = false; + } + } + } + + flushCurrent(); + return deduplicateCargoRecords(records); +} + +function deduplicateCargoRecords(records) { + const seen = new Set(); + const results = []; + for (const record of records) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + results.push(record); + } + return results; +} + +function selectCargoRecord(recordsByName, name, version) { + const candidates = recordsByName.get(name.toLowerCase()) || []; + if (candidates.length === 0) { + return null; + } + if (version) { + const exactMatch = candidates.find((record) => record.version === version); + if (exactMatch) { + return exactMatch; + } + } + return candidates[0]; +} + +function buildCargoDependency(record, parentChain, recordsByName, visiting, sourceFile, directNames) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "cargo", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(record.name); + const transitives = []; + + for (const dependency of record.dependencies) { + const childRecord = selectCargoRecord(recordsByName, dependency.name, dependency.version); + if (!childRecord) { + continue; + } + transitives.push(buildCargoDependency( + childRecord, + nextParentChain, + recordsByName, + nextVisiting, + sourceFile, + directNames + )); + } + + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "cargo", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: false, + }); +} + +module.exports = cargoParser; diff --git a/util/lockfileParsers/composerParser.js b/util/lockfileParsers/composerParser.js new file mode 100644 index 0000000..0f250fc --- /dev/null +++ b/util/lockfileParsers/composerParser.js @@ -0,0 +1,174 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + readJson, + pathExists, + readUtf8, +} = require("./shared"); +const { parseComposerManifest } = require("./manifestHelpers"); + +const composerParser = { + name: "composerParser", + ecosystem: "composer", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "composer.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "composer.json"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "composer.lock"), workspaceFolder) + ? path.join(rootPath, "composer.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "composer.json"), workspaceFolder) + ? path.join(rootPath, "composer.json") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + const manifestDependencies = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? parseComposerManifest(await readUtf8(manifestPath, workspaceFolder)) + : []; + + if (!lockfilePath) { + return buildTree("composer", sourceFile, manifestDependencies.map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "composer", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + }))); + } + + const root = await readJson(lockfilePath); + const records = []; + + for (const record of [...(root.packages || []), ...(root["packages-dev"] || [])]) { + if (!record || !record.name) { + continue; + } + records.push({ + name: record.name, + version: record.version || "", + dependencies: Object.keys(record.require || {}).filter((name) => name.includes("/") && !name.startsWith("ext-") && !name.startsWith("lib-") && name !== "php"), + }); + } + + const directNames = new Set(manifestDependencies.map((dependency) => dependency.name.toLowerCase())); + const recordsByName = new Map(records.map((record) => [record.name.toLowerCase(), record])); + const incomingCounts = new Map(); + for (const record of records) { + for (const dependencyName of record.dependencies) { + incomingCounts.set(dependencyName.toLowerCase(), (incomingCounts.get(dependencyName.toLowerCase()) || 0) + 1); + } + } + + const rootRecords = directNames.size > 0 + ? [...directNames].map((name) => recordsByName.get(name)).filter(Boolean) + : records.filter((record) => !incomingCounts.get(record.name.toLowerCase())); + + const directRoots = deduplicateDeps(rootRecords.map((record) => buildComposerDependency( + record, + [], + recordsByName, + new Set(), + sourceFile, + directNames + ))); + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + + for (const record of records) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) { + continue; + } + dependencies.push(createDependency({ + name: record.name, + version: record.version, + ecosystem: "composer", + isDirect: directNames.has(record.name.toLowerCase()), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return buildTree("composer", sourceFile, dependencies); + }, +}; + +function buildComposerDependency(record, parentChain, recordsByName, visiting, sourceFile, directNames) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "composer", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(record.name); + const transitives = []; + + for (const dependencyName of record.dependencies) { + const childRecord = recordsByName.get(dependencyName.toLowerCase()); + if (!childRecord) { + continue; + } + transitives.push(buildComposerDependency( + childRecord, + nextParentChain, + recordsByName, + nextVisiting, + sourceFile, + directNames + )); + } + + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "composer", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: false, + }); +} + +module.exports = composerParser; diff --git a/util/lockfileParsers/dartParser.js b/util/lockfileParsers/dartParser.js new file mode 100644 index 0000000..7e3689b --- /dev/null +++ b/util/lockfileParsers/dartParser.js @@ -0,0 +1,124 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + countIndent, + createDependency, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, + stripYamlComment, +} = require("./shared"); +const { parsePubspecManifest } = require("./manifestHelpers"); + +const dartParser = { + name: "dartParser", + ecosystem: "dart", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "pubspec.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "pubspec.yaml"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "pubspec.lock"), workspaceFolder) + ? path.join(rootPath, "pubspec.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "pubspec.yaml"), workspaceFolder) + ? path.join(rootPath, "pubspec.yaml") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + if (!lockfilePath) { + return buildTree("dart", sourceFile, parsePubspecManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "dart", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + }))); + } + + const dependencies = []; + let inPackages = false; + let current = null; + + const flushCurrent = () => { + if (!current || !current.name) { + current = null; + return; + } + dependencies.push(createDependency({ + name: current.name, + version: current.version, + ecosystem: "dart", + isDirect: !String(current.dependencyType || "").toLowerCase().includes("transitive"), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: String(current.dependencyType || "").toLowerCase().includes("dev"), + })); + current = null; + }; + + for (const rawLine of String(await readUtf8(lockfilePath, workspaceFolder)).split(/\r?\n/)) { + const line = stripYamlComment(rawLine).trim(); + if (!line) { + continue; + } + + const indent = countIndent(rawLine); + if (indent === 0 && line === "packages:") { + inPackages = true; + continue; + } + if (indent === 0 && line.endsWith(":") && line !== "packages:") { + inPackages = false; + flushCurrent(); + continue; + } + if (!inPackages) { + continue; + } + if (indent === 2 && line.endsWith(":")) { + flushCurrent(); + current = { name: line.slice(0, -1), version: "", dependencyType: "" }; + continue; + } + if (!current) { + continue; + } + if (indent === 4 && line.startsWith("dependency:")) { + current.dependencyType = line.slice("dependency:".length).trim().replace(/^["']|["']$/g, ""); + } + if (indent === 4 && line.startsWith("version:")) { + current.version = line.slice("version:".length).trim().replace(/^["']|["']$/g, ""); + } + } + + flushCurrent(); + return buildTree("dart", sourceFile, dependencies); + }, +}; + +module.exports = dartParser; diff --git a/util/lockfileParsers/dockerParser.js b/util/lockfileParsers/dockerParser.js new file mode 100644 index 0000000..7abf4d7 --- /dev/null +++ b/util/lockfileParsers/dockerParser.js @@ -0,0 +1,300 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + countIndent, + createDependency, + getSourceFileName, + getWorkspacePath, + readUtf8, + resolveWorkspaceFilePath, + stripYamlComment, +} = require("./shared"); + +const dockerParser = { + name: "dockerParser", + ecosystem: "docker", + + async canResolve(workspaceFolder) { + const matches = await this.detect(workspaceFolder); + return matches.length > 0; + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const safeRootPath = await resolveWorkspaceFilePath(rootPath, workspaceFolder); + if (!safeRootPath) { + return []; + } + const entries = []; + const allFiles = await require("fs").promises.readdir(safeRootPath); + + for (const fileName of allFiles.sort()) { + const isDockerfile = fileName === "Dockerfile" || fileName.startsWith("Dockerfile."); + const isComposeFile = [ + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", + ].includes(fileName); + if (!isDockerfile && !isComposeFile) { + continue; + } + entries.push({ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath: path.join(safeRootPath, fileName), + manifestPath: null, + sourceFile: fileName, + }); + } + + return entries; + }, + + async resolve({ lockfilePath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath); + const content = await readUtf8(lockfilePath, workspaceFolder); + const dependencies = isComposeFileName(sourceFile) + ? parseCompose(content, sourceFile) + : parseDockerfile(content, sourceFile); + return buildTree("docker", sourceFile, dependencies); + }, +}; + +function parseDockerfile(content, sourceFile) { + const dependencies = []; + const stageAliases = new Set(); + const argDefaults = new Map(); + + for (const instruction of toLogicalDockerLines(content)) { + const cleaned = stripDockerComment(instruction).trim(); + if (!cleaned) { + continue; + } + + if (/^ARG\s+/i.test(cleaned)) { + const definition = cleaned.replace(/^ARG\s+/i, ""); + const [name, value] = definition.split("=", 2); + if (name && value) { + argDefaults.set(name.trim(), resolveDockerArgs(value.trim(), argDefaults)); + } + continue; + } + + if (!/^FROM\s+/i.test(cleaned)) { + continue; + } + + const parsed = parseFromInstruction(cleaned, argDefaults, stageAliases); + if (!parsed) { + continue; + } + if (parsed.alias) { + stageAliases.add(parsed.alias.toLowerCase()); + } + if (!parsed.isDependency) { + continue; + } + + dependencies.push(createDependency({ + name: parsed.name, + version: parsed.version, + ecosystem: "docker", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return dependencies; +} + +function parseCompose(content, sourceFile) { + const dependencies = []; + let servicesIndent = null; + let currentService = null; + + const flushCurrentService = () => { + if (!currentService) { + return; + } + if (!currentService.hasBuild && currentService.image) { + const parsed = parseDockerImageReference(currentService.image); + if (parsed && parsed.name.toLowerCase() !== "scratch") { + dependencies.push(createDependency({ + name: parsed.name, + version: parsed.version, + ecosystem: "docker", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + } + currentService = null; + }; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const cleaned = stripYamlComment(rawLine).trim(); + if (!cleaned) { + continue; + } + + const indent = countIndent(rawLine); + if (cleaned === "services:") { + flushCurrentService(); + servicesIndent = indent; + continue; + } + if (servicesIndent != null && indent <= servicesIndent && cleaned.endsWith(":")) { + flushCurrentService(); + servicesIndent = null; + } + if (servicesIndent == null || indent <= servicesIndent) { + continue; + } + + if (indent === servicesIndent + 2 && cleaned.endsWith(":")) { + flushCurrentService(); + currentService = { indent, hasBuild: false, image: "" }; + continue; + } + + if (!currentService || indent <= currentService.indent || cleaned.startsWith("- ")) { + continue; + } + + if (cleaned.startsWith("build:")) { + currentService.hasBuild = true; + continue; + } + if (cleaned.startsWith("image:")) { + currentService.image = unquote(cleaned.slice("image:".length).trim()); + } + } + + flushCurrentService(); + return dependencies; +} + +function toLogicalDockerLines(content) { + const lines = []; + let current = ""; + for (const rawLine of String(content || "").split(/\r?\n/)) { + const trimmed = rawLine.trimEnd(); + if (!trimmed) { + if (current) { + lines.push(current); + current = ""; + } + continue; + } + + const continues = trimmed.endsWith("\\"); + const segment = continues ? trimmed.slice(0, -1).trimEnd() : trimmed; + current += current ? ` ${segment}` : segment; + if (!continues) { + lines.push(current); + current = ""; + } + } + if (current) { + lines.push(current); + } + return lines; +} + +function stripDockerComment(line) { + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const previous = index > 0 ? line[index - 1] : ""; + if (char === "'" && !inDoubleQuote && previous !== "\\") { + inSingleQuote = !inSingleQuote; + continue; + } + if (char === "\"" && !inSingleQuote && previous !== "\\") { + inDoubleQuote = !inDoubleQuote; + continue; + } + if (char === "#" && !inSingleQuote && !inDoubleQuote) { + return line.slice(0, index); + } + } + return line; +} + +function resolveDockerArgs(value, args) { + return String(value || "") + .replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-+?])([^}]*))?}/g, (_match, name, operator, fallback) => { + if (args.has(name)) { + return args.get(name); + } + return operator === "-" || operator === ":-" ? fallback : _match; + }) + .replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, name) => (args.has(name) ? args.get(name) : match)); +} + +function parseFromInstruction(line, argDefaults, stageAliases) { + const parts = line.split(/\s+/).filter(Boolean); + let index = 1; + while (parts[index] && parts[index].startsWith("--")) { + index += 1; + } + const imageToken = parts[index]; + if (!imageToken) { + return null; + } + const alias = parts[index + 1] && /^AS$/i.test(parts[index + 1]) ? parts[index + 2] : ""; + const resolvedImage = resolveDockerArgs(unquote(imageToken), argDefaults).trim(); + if (!resolvedImage) { + return null; + } + const stageReference = stageAliases.has(resolvedImage.toLowerCase()); + const parsed = parseDockerImageReference(resolvedImage); + if (!parsed) { + return null; + } + return { + ...parsed, + alias: alias ? unquote(alias) : "", + isDependency: !stageReference && parsed.name.toLowerCase() !== "scratch", + }; +} + +function parseDockerImageReference(reference) { + const raw = unquote(reference); + if (!raw || raw.includes("$")) { + return null; + } + const withoutDigest = raw.split("@")[0]; + const digest = raw.includes("@") ? raw.split("@")[1] : ""; + const lastSlash = withoutDigest.lastIndexOf("/"); + const lastColon = withoutDigest.lastIndexOf(":"); + const hasTag = lastColon > lastSlash; + const name = hasTag ? withoutDigest.slice(0, lastColon) : withoutDigest; + const version = hasTag ? withoutDigest.slice(lastColon + 1) : digest || "latest"; + if (!name) { + return null; + } + return { name, version }; +} + +function unquote(value) { + return String(value || "").trim().replace(/^["']|["']$/g, ""); +} + +function isComposeFileName(fileName) { + return ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"].includes(fileName); +} + +module.exports = dockerParser; diff --git a/util/lockfileParsers/goParser.js b/util/lockfileParsers/goParser.js new file mode 100644 index 0000000..2ec141b --- /dev/null +++ b/util/lockfileParsers/goParser.js @@ -0,0 +1,82 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, +} = require("./shared"); + +const goParser = { + name: "goParser", + ecosystem: "go", + + async canResolve(workspaceFolder) { + return pathExists(path.join(getWorkspacePath(workspaceFolder), "go.mod"), workspaceFolder); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const manifestPath = path.join(rootPath, "go.mod"); + if (!(await pathExists(manifestPath, workspaceFolder))) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath: manifestPath, + manifestPath, + sourceFile: "go.mod", + }]; + }, + + async resolve({ manifestPath, workspaceFolder }) { + const dependencies = []; + const sourceFile = getSourceFileName(manifestPath); + let inRequireBlock = false; + + for (const rawLine of String(await readUtf8(manifestPath, workspaceFolder)).split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("//")) { + continue; + } + if (line === "require (") { + inRequireBlock = true; + continue; + } + if (line === ")" && inRequireBlock) { + inRequireBlock = false; + continue; + } + + const lineToParse = line.startsWith("require ") ? line.slice("require ".length).trim() : line; + if (!inRequireBlock && !line.startsWith("require ")) { + continue; + } + + const cleaned = lineToParse.split("//")[0].trim(); + const parts = cleaned.split(/\s+/); + if (parts.length < 2) { + continue; + } + + dependencies.push(createDependency({ + name: parts[0], + version: parts[1].replace(/^v/, ""), + ecosystem: "go", + isDirect: !rawLine.includes("// indirect"), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: rawLine.includes("// indirect"), + })); + } + + return buildTree("go", sourceFile, dependencies); + }, +}; + +module.exports = goParser; diff --git a/util/lockfileParsers/gradleParser.js b/util/lockfileParsers/gradleParser.js new file mode 100644 index 0000000..180bba9 --- /dev/null +++ b/util/lockfileParsers/gradleParser.js @@ -0,0 +1,124 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, +} = require("./shared"); +const { parseBuildGradleManifest } = require("./manifestHelpers"); + +const BUILD_FILES = ["build.gradle", "build.gradle.kts"]; + +const gradleParser = { + name: "gradleParser", + ecosystem: "gradle", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + for (const buildFile of BUILD_FILES) { + if (await pathExists(path.join(rootPath, buildFile), workspaceFolder)) { + return true; + } + } + return false; + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + for (const buildFile of BUILD_FILES) { + const manifestPath = path.join(rootPath, buildFile); + if (!(await pathExists(manifestPath, workspaceFolder))) { + continue; + } + const lockfilePath = await pathExists(path.join(rootPath, "gradle.lockfile"), workspaceFolder) + ? path.join(rootPath, "gradle.lockfile") + : null; + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: buildFile, + }]; + } + return []; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const directDependencies = parseBuildGradleManifest(await readUtf8(manifestPath, workspaceFolder)); + const sourceFile = getSourceFileName(manifestPath); + + if (!lockfilePath) { + return buildTree("gradle", sourceFile, directDependencies.map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "gradle", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + }))); + } + + const lockVersions = parseGradleLockfile(await readUtf8(lockfilePath, workspaceFolder)); + const dependencies = []; + + for (const directDependency of directDependencies) { + const resolvedVersion = lockVersions.get(directDependency.name) || directDependency.version; + dependencies.push(createDependency({ + name: directDependency.name, + version: resolvedVersion, + ecosystem: "gradle", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: directDependency.isDevelopmentDependency, + })); + } + + for (const [name, version] of lockVersions.entries()) { + dependencies.push(createDependency({ + name, + version, + ecosystem: "gradle", + isDirect: directDependencies.some((dependency) => dependency.name === name), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return buildTree("gradle", sourceFile, deduplicateDeps(dependencies)); + }, +}; + +function parseGradleLockfile(content) { + const versions = new Map(); + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + const entry = line.split("=", 1)[0].trim(); + const parts = entry.split(":"); + if (parts.length < 3) { + continue; + } + versions.set(`${parts[0]}:${parts[1]}`, parts[2]); + } + + return versions; +} + +module.exports = gradleParser; diff --git a/util/lockfileParsers/helmParser.js b/util/lockfileParsers/helmParser.js new file mode 100644 index 0000000..abc5a61 --- /dev/null +++ b/util/lockfileParsers/helmParser.js @@ -0,0 +1,62 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, +} = require("./shared"); +const { parseChartManifest } = require("./manifestHelpers"); + +const helmParser = { + name: "helmParser", + ecosystem: "helm", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "Chart.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "Chart.yaml"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "Chart.lock"), workspaceFolder) + ? path.join(rootPath, "Chart.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "Chart.yaml"), workspaceFolder) + ? path.join(rootPath, "Chart.yaml") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourcePath = lockfilePath || manifestPath; + const sourceFile = getSourceFileName(sourcePath); + const dependencies = parseChartManifest(await readUtf8(sourcePath, workspaceFolder)).map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "helm", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + + return buildTree("helm", sourceFile, dependencies); + }, +}; + +module.exports = helmParser; diff --git a/util/lockfileParsers/hexParser.js b/util/lockfileParsers/hexParser.js new file mode 100644 index 0000000..a0808b2 --- /dev/null +++ b/util/lockfileParsers/hexParser.js @@ -0,0 +1,86 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, +} = require("./shared"); +const { parseMixExsManifest } = require("./manifestHelpers"); + +const hexParser = { + name: "hexParser", + ecosystem: "hex", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "mix.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "mix.exs"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "mix.lock"), workspaceFolder) + ? path.join(rootPath, "mix.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "mix.exs"), workspaceFolder) + ? path.join(rootPath, "mix.exs") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + if (!lockfilePath) { + return buildTree("hex", sourceFile, parseMixExsManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "hex", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }))); + } + + const directNames = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? new Set(parseMixExsManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => dependency.name.toLowerCase())) + : new Set(); + const entryPattern = /"([^"]+)"\s*:\s*\{\s*:hex,\s*(?::"[^"]+"|:[^,]+)\s*,\s*"([^"]+)"/g; + const dependencies = []; + for (const match of String(await readUtf8(lockfilePath, workspaceFolder)).matchAll(entryPattern)) { + dependencies.push(createDependency({ + name: match[1], + version: match[2], + ecosystem: "hex", + isDirect: directNames.size === 0 || directNames.has(match[1].toLowerCase()), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + if (dependencies.length === 0) { + throw new Error("Malformed mix.lock: no Hex package entries found"); + } + + return buildTree("hex", sourceFile, dependencies); + }, +}; + +module.exports = hexParser; diff --git a/util/lockfileParsers/manifestHelpers.js b/util/lockfileParsers/manifestHelpers.js new file mode 100644 index 0000000..3aa59d9 --- /dev/null +++ b/util/lockfileParsers/manifestHelpers.js @@ -0,0 +1,650 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const { + escapeRegExp, + normalizeVersion, + parseInlineTomlValue, + parseKeyValueLine, + parseQuotedArray, + stripTomlComment, + stripYamlComment, +} = require("./shared"); + +function parsePackageJsonManifest(content) { + let parsed; + try { + parsed = JSON.parse(content); + } catch { + return { + dependencies: [], + directNames: new Set(), + devNames: new Set(), + }; + } + + const dependencies = []; + const directNames = new Set(); + const devNames = new Set(); + + const addSection = (sectionName, isDevelopmentDependency) => { + const section = parsed[sectionName]; + if (!section || typeof section !== "object") { + return; + } + + for (const [name, version] of Object.entries(section)) { + dependencies.push({ + name, + version: normalizeVersion(version), + isDevelopmentDependency, + }); + if (isDevelopmentDependency) { + devNames.add(name); + } else { + directNames.add(name); + } + } + }; + + addSection("dependencies", false); + addSection("devDependencies", true); + addSection("optionalDependencies", false); + addSection("peerDependencies", false); + + return { + dependencies, + directNames, + devNames, + }; +} + +function parsePyprojectManifest(content) { + const lines = String(content || "").split(/\r?\n/); + const dependencies = []; + const directNames = new Set(); + const devNames = new Set(); + let projectName = ""; + let section = ""; + let collectingProjectDependencies = false; + let projectDependenciesBuffer = ""; + + const flushProjectDependencies = () => { + if (!projectDependenciesBuffer) { + return; + } + for (const item of parseQuotedArray(projectDependenciesBuffer)) { + const parsed = parseRequirementSpec(item); + if (!parsed) { + continue; + } + dependencies.push({ + ...parsed, + isDevelopmentDependency: false, + }); + directNames.add(parsed.name); + } + projectDependenciesBuffer = ""; + collectingProjectDependencies = false; + }; + + for (const rawLine of lines) { + const withoutComment = stripTomlComment(rawLine); + const line = withoutComment.trim(); + if (!line || line.startsWith("#")) { + continue; + } + + if (collectingProjectDependencies) { + projectDependenciesBuffer += projectDependenciesBuffer ? ` ${line}` : line; + if (projectDependenciesBuffer.includes("]")) { + flushProjectDependencies(); + } + continue; + } + + if (line.startsWith("[") && line.endsWith("]")) { + section = line; + continue; + } + + if (section === "[project]" && line.startsWith("name =")) { + projectName = unquote(parseKeyValueLine(line).value); + continue; + } + + if (section === "[tool.poetry]" && line.startsWith("name =")) { + projectName = unquote(parseKeyValueLine(line).value); + continue; + } + + if (section === "[project]" && line.startsWith("dependencies")) { + projectDependenciesBuffer = parseKeyValueLine(line).value; + if (projectDependenciesBuffer.includes("]")) { + flushProjectDependencies(); + } else { + collectingProjectDependencies = true; + } + continue; + } + + if ( + section === "[tool.poetry.dependencies]" + || section === "[tool.poetry.dev-dependencies]" + || /^\[tool\.poetry\.group\.[^.]+\.dependencies]$/.test(section) + ) { + const parts = parseKeyValueLine(line); + if (!parts) { + continue; + } + + const name = parts.key; + if (!name || name.toLowerCase() === "python") { + continue; + } + + const rawValue = parts.value; + const version = rawValue.startsWith("{") + ? normalizeVersion(parseInlineTomlValue(rawValue, "version")) + : normalizeVersion(unquote(rawValue)); + const isDevelopmentDependency = section !== "[tool.poetry.dependencies]"; + + dependencies.push({ + name, + version: version === "*" ? "" : version, + isDevelopmentDependency, + }); + + if (isDevelopmentDependency) { + devNames.add(name); + } else { + directNames.add(name); + } + } + } + + return { + projectName, + dependencies, + directNames, + devNames, + }; +} + +function parseRequirementSpec(spec) { + const rawSpec = String(spec || "").trim().replace(/^["']|["']$/g, ""); + if (!rawSpec) { + return null; + } + + const withoutMarker = rawSpec.split(";")[0].trim(); + const match = withoutMarker.match(/^([A-Za-z0-9_.-]+)(?:\[[^\]]+])?\s*(.*)$/); + if (!match) { + return null; + } + + return { + name: match[1], + version: normalizeVersion(match[2] || ""), + }; +} + +function parseCargoTomlManifest(content) { + const dependencies = []; + let section = ""; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const line = stripTomlComment(rawLine).trim(); + if (!line) { + continue; + } + + if (line.startsWith("[") && line.endsWith("]")) { + section = line; + continue; + } + + if (![ + "[dependencies]", + "[dev-dependencies]", + "[build-dependencies]", + "[workspace.dependencies]", + ].includes(section)) { + continue; + } + + const parts = parseKeyValueLine(line); + if (!parts) { + continue; + } + + const declaredName = parts.key; + const rawValue = parts.value; + const actualName = parseInlineTomlValue(rawValue, "package") || declaredName; + const version = rawValue.startsWith("{") + ? normalizeVersion(parseInlineTomlValue(rawValue, "version")) + : normalizeVersion(unquote(rawValue)); + + dependencies.push({ + name: actualName, + version, + isDevelopmentDependency: section !== "[dependencies]" && section !== "[workspace.dependencies]", + }); + } + + return dependencies; +} + +function parseGemfileManifest(content) { + const dependencies = []; + const pattern = /^\s*gem\s+["']([^"']+)["'](?:\s*,\s*["']([^"']+)["'])?/; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const line = stripRubyComment(rawLine).trim(); + const match = line.match(pattern); + if (!match) { + continue; + } + dependencies.push({ + name: match[1], + version: normalizeVersion(match[2] || ""), + isDevelopmentDependency: false, + }); + } + + return dependencies; +} + +function parseBuildGradleManifest(content) { + const dependencies = []; + const lines = String(content || "").split(/\r?\n/); + let inDependenciesBlock = false; + let braceDepth = 0; + let dependencyBlockDepth = 0; + + for (const rawLine of lines) { + const line = stripJavaLikeComment(rawLine).trim(); + if (!line) { + braceDepth += countBraces(rawLine); + continue; + } + + if (!inDependenciesBlock && /^dependencies\s*\{/.test(line)) { + inDependenciesBlock = true; + dependencyBlockDepth = braceDepth + 1; + } else if (!inDependenciesBlock && line === "dependencies") { + inDependenciesBlock = true; + dependencyBlockDepth = braceDepth + 1; + } else if (inDependenciesBlock) { + const parsed = parseGradleDependencyLine(line); + if (parsed) { + dependencies.push(parsed); + } + } + + braceDepth += countBraces(rawLine); + if (inDependenciesBlock && braceDepth < dependencyBlockDepth) { + inDependenciesBlock = false; + dependencyBlockDepth = 0; + } + } + + return dedupeManifestDeps(dependencies); +} + +function parseGradleDependencyLine(line) { + const match = line.match(/^\s*([A-Za-z][A-Za-z0-9_-]*)\s*\(?\s*["']([^"']+)["']/); + if (!match) { + return null; + } + + const configuration = match[1].toLowerCase(); + const coordinates = match[2].split(":").filter(Boolean); + if (coordinates.length < 2) { + return null; + } + + return { + name: `${coordinates[0]}:${coordinates[1]}`, + version: coordinates[2] ? normalizeVersion(coordinates[2]) : "", + isDevelopmentDependency: configuration.includes("test"), + }; +} + +function parseCsprojManifest(content) { + const dependencies = []; + const inlinePattern = /]*)\/>/gi; + const blockPattern = /]*)>([\s\S]*?)<\/PackageReference>/gi; + + const parseAttributes = (attributesText, blockText) => { + const includeMatch = attributesText.match(/\b(?:Include|Update)="([^"]+)"/i); + if (!includeMatch) { + return; + } + + let version = ""; + const attributeVersionMatch = attributesText.match(/\bVersion="([^"]+)"/i); + if (attributeVersionMatch) { + version = attributeVersionMatch[1]; + } else if (blockText) { + const nestedVersionMatch = blockText.match(/\s*([^<]+)\s*<\/Version>/i); + if (nestedVersionMatch) { + version = nestedVersionMatch[1]; + } + } + + dependencies.push({ + name: includeMatch[1].trim(), + version: normalizeVersion(version), + isDevelopmentDependency: false, + }); + }; + + for (const match of content.matchAll(inlinePattern)) { + parseAttributes(match[1], ""); + } + + for (const match of content.matchAll(blockPattern)) { + parseAttributes(match[1], match[2]); + } + + return dedupeManifestDeps(dependencies); +} + +function parsePubspecManifest(content) { + const dependencies = []; + let section = ""; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const lineWithoutComment = stripYamlComment(rawLine); + const line = lineWithoutComment.trim(); + if (!line) { + continue; + } + + const indent = rawLine.search(/\S/); + if (indent === 0 && line.endsWith(":")) { + section = line.slice(0, -1); + continue; + } + + if (!["dependencies", "dev_dependencies"].includes(section) || indent !== 2) { + continue; + } + + if (line.startsWith("-")) { + continue; + } + + const name = line.split(":", 1)[0].trim(); + const rawValue = line.includes(":") ? line.split(":").slice(1).join(":").trim() : ""; + const version = rawValue.startsWith("{") + ? normalizeVersion(parseYamlInlineValue(rawValue, "version")) + : normalizeVersion(unquote(rawValue)); + + if (!name) { + continue; + } + + dependencies.push({ + name, + version, + isDevelopmentDependency: section === "dev_dependencies", + }); + } + + return dedupeManifestDeps(dependencies); +} + +function parseComposerManifest(content) { + let parsed; + try { + parsed = JSON.parse(content); + } catch { + return []; + } + + const dependencies = []; + for (const [name, version] of Object.entries(parsed.require || {})) { + if (!isComposerPackageName(name)) { + continue; + } + dependencies.push({ name, version: normalizeVersion(version), isDevelopmentDependency: false }); + } + for (const [name, version] of Object.entries(parsed["require-dev"] || {})) { + if (!isComposerPackageName(name)) { + continue; + } + dependencies.push({ name, version: normalizeVersion(version), isDevelopmentDependency: true }); + } + return dedupeManifestDeps(dependencies); +} + +function parseChartManifest(content) { + return parseSimpleYamlDependencyList(content, "dependencies"); +} + +function parsePackageSwiftManifest(content) { + const dependencies = []; + const pattern = /\.package\s*\(([\s\S]*?)\)/g; + + for (const match of content.matchAll(pattern)) { + const declaration = match[1]; + const identityMatch = declaration.match(/\b(?:name|id|identity)\s*:\s*"([^"]+)"/); + const urlMatch = declaration.match(/\burl\s*:\s*"([^"]+)"/); + const versionMatch = declaration.match(/\b(?:from|exact|branch|revision)\s*:\s*"([^"]+)"/); + const name = normalizeSwiftIdentity(identityMatch ? identityMatch[1] : urlMatch ? urlMatch[1] : ""); + if (!name) { + continue; + } + dependencies.push({ + name, + version: normalizeVersion(versionMatch ? versionMatch[1] : ""), + isDevelopmentDependency: false, + }); + } + + return dedupeManifestDeps(dependencies); +} + +function normalizeSwiftIdentity(name) { + return String(name || "") + .trim() + .split("/") + .filter(Boolean) + .pop() + ?.replace(/\.git$/i, "") + .toLowerCase() || ""; +} + +function parseMixExsManifest(content) { + const dependencies = []; + const depsBlockMatch = content.match(/defp\s+deps\s+do\s*\[([\s\S]*?)\]\s*end/m); + if (!depsBlockMatch) { + return []; + } + + const pattern = /\{\s*:([A-Za-z0-9_]+)\s*,\s*"([^"]*)"/g; + for (const match of depsBlockMatch[1].matchAll(pattern)) { + dependencies.push({ + name: match[1], + version: normalizeVersion(match[2]), + isDevelopmentDependency: false, + }); + } + return dedupeManifestDeps(dependencies); +} + +function parsePomManifest(content) { + const dependencies = []; + const dependencyBlocks = content.match(/[\s\S]*?<\/dependency>/gi) || []; + + for (const block of dependencyBlocks) { + const groupId = matchXmlValue(block, "groupId"); + const artifactId = matchXmlValue(block, "artifactId"); + const scope = matchXmlValue(block, "scope"); + if (!groupId || !artifactId) { + continue; + } + + let version = matchXmlValue(block, "version"); + if (version && /\$\{[^}]+}/.test(version)) { + version = ""; + } + + dependencies.push({ + name: `${groupId}:${artifactId}`, + version: normalizeVersion(version), + isDevelopmentDependency: scope === "test", + }); + } + + return dedupeManifestDeps(dependencies); +} + +function parseSimpleYamlDependencyList(content, sectionName) { + const dependencies = []; + let inSection = false; + let currentDependency = null; + + const flushCurrent = () => { + if (!currentDependency || !currentDependency.name) { + currentDependency = null; + return; + } + dependencies.push({ + name: currentDependency.name, + version: normalizeVersion(currentDependency.version), + isDevelopmentDependency: false, + }); + currentDependency = null; + }; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const lineWithoutComment = stripYamlComment(rawLine); + const line = lineWithoutComment.trim(); + if (!line) { + continue; + } + + const indent = rawLine.search(/\S/); + if (indent === 0 && line === `${sectionName}:`) { + inSection = true; + continue; + } + if (indent === 0 && line.endsWith(":") && line !== `${sectionName}:`) { + inSection = false; + flushCurrent(); + continue; + } + if (!inSection) { + continue; + } + + if (indent === 2 && line.startsWith("- ")) { + flushCurrent(); + currentDependency = { name: "", version: "" }; + const remainder = line.slice(2).trim(); + if (remainder.startsWith("name:")) { + currentDependency.name = remainder.slice("name:".length).trim(); + } + continue; + } + + if (!currentDependency) { + continue; + } + + if (indent >= 4 && line.startsWith("name:")) { + currentDependency.name = line.slice("name:".length).trim(); + } + if (indent >= 4 && line.startsWith("version:")) { + currentDependency.version = line.slice("version:".length).trim(); + } + } + + flushCurrent(); + return dedupeManifestDeps(dependencies); +} + +function dedupeManifestDeps(dependencies) { + const seen = new Set(); + const results = []; + for (const dependency of dependencies) { + const key = `${dependency.name.toLowerCase()}:${dependency.version.toLowerCase()}:${dependency.isDevelopmentDependency}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + results.push(dependency); + } + return results; +} + +function parseYamlInlineValue(block, key) { + const match = String(block || "").match(new RegExp(`${escapeRegExp(key)}\\s*:\\s*([^,}]+)`)); + return match ? unquote(match[1].trim()) : ""; +} + +function unquote(value) { + return String(value || "").trim().replace(/^["']|["']$/g, ""); +} + +function stripRubyComment(line) { + let inSingleQuote = false; + let inDoubleQuote = false; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const previous = index > 0 ? line[index - 1] : ""; + if (char === "'" && !inDoubleQuote && previous !== "\\") { + inSingleQuote = !inSingleQuote; + continue; + } + if (char === "\"" && !inSingleQuote && previous !== "\\") { + inDoubleQuote = !inDoubleQuote; + continue; + } + if (char === "#" && !inSingleQuote && !inDoubleQuote) { + return line.slice(0, index); + } + } + return line; +} + +function stripJavaLikeComment(line) { + return String(line || "").replace(/\/\/.*$/, "").trimEnd(); +} + +function countBraces(line) { + const openBraces = (line.match(/\{/g) || []).length; + const closeBraces = (line.match(/\}/g) || []).length; + return openBraces - closeBraces; +} + +function isComposerPackageName(name) { + return typeof name === "string" + && name.includes("/") + && !name.startsWith("ext-") + && !name.startsWith("lib-") + && name !== "php"; +} + +function matchXmlValue(block, tagName) { + const match = String(block || "").match(new RegExp(`<${tagName}>\\s*([^<]+)\\s*`, "i")); + return match ? match[1].trim() : ""; +} + +module.exports = { + normalizeSwiftIdentity, + parseBuildGradleManifest, + parseCargoTomlManifest, + parseChartManifest, + parseComposerManifest, + parseCsprojManifest, + parseGemfileManifest, + parseMixExsManifest, + parsePackageJsonManifest, + parsePackageSwiftManifest, + parsePomManifest, + parsePubspecManifest, + parsePyprojectManifest, + parseRequirementSpec, +}; diff --git a/util/lockfileParsers/mavenParser.js b/util/lockfileParsers/mavenParser.js new file mode 100644 index 0000000..173a006 --- /dev/null +++ b/util/lockfileParsers/mavenParser.js @@ -0,0 +1,181 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, +} = require("./shared"); +const { parsePomManifest } = require("./manifestHelpers"); + +const TREE_FILE_CANDIDATES = [ + "dependency-tree.txt", + path.join("target", "dependency-tree.txt"), + path.join(".mvn", "dependency-tree.txt"), +]; + +const mavenParser = { + name: "mavenParser", + ecosystem: "maven", + + async canResolve(workspaceFolder) { + return await pathExists(path.join(getWorkspacePath(workspaceFolder), "pom.xml"), workspaceFolder); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const pomPath = path.join(rootPath, "pom.xml"); + if (!(await pathExists(pomPath, workspaceFolder))) { + return []; + } + + let lockfilePath = null; + for (const candidate of TREE_FILE_CANDIDATES) { + const candidatePath = path.join(rootPath, candidate); + if (await pathExists(candidatePath, workspaceFolder)) { + lockfilePath = candidatePath; + break; + } + } + + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath: pomPath, + sourceFile: "pom.xml", + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const directDependencies = parsePomManifest(await readUtf8(manifestPath, workspaceFolder)) + .map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "maven", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile: getSourceFileName(manifestPath), + isDevelopmentDependency: dependency.isDevelopmentDependency, + })); + + if (!lockfilePath) { + return buildTree("maven", getSourceFileName(manifestPath), directDependencies); + } + + const treeRoots = parseDependencyTree(await readUtf8(lockfilePath, workspaceFolder)); + const hydratedDirectDependencies = directDependencies.map((dependency) => { + const matchingTreeNode = treeRoots.find((node) => ( + node.name === dependency.name + && (!dependency.version || node.version === dependency.version || !node.version) + )) || treeRoots.find((node) => node.name === dependency.name); + + if (!matchingTreeNode) { + return dependency; + } + + return { + ...dependency, + version: dependency.version || matchingTreeNode.version, + transitives: matchingTreeNode.children.map((child) => toMavenDependency(child, [dependency.name], getSourceFileName(manifestPath))), + }; + }); + + let dependencies = deduplicateDeps(flattenDependencies(hydratedDirectDependencies)); + for (const rootNode of treeRoots) { + appendTreeNodeIfMissing(rootNode, dependencies, getSourceFileName(manifestPath)); + } + + return buildTree("maven", getSourceFileName(manifestPath), dependencies); + }, +}; + +function parseDependencyTree(content) { + const roots = []; + const stack = []; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const body = rawLine.replace(/^\[INFO\]\s*/, ""); + if (!body.trim()) { + continue; + } + + const markerIndex = body.search(/[+\\]-/); + if (markerIndex === -1) { + continue; + } + + const depth = Math.floor(markerIndex / 3); + const coordinates = body.slice(markerIndex + 2).trim().replace(/\s+\(\*\)$/, ""); + const node = parseMavenCoordinate(coordinates); + if (!node) { + continue; + } + + while (stack.length > depth) { + stack.pop(); + } + + if (stack.length === 0) { + roots.push(node); + } else { + stack[stack.length - 1].children.push(node); + } + stack.push(node); + } + + return roots; +} + +function parseMavenCoordinate(coordinates) { + const withoutScopeHint = coordinates.split(" -> ")[0].trim(); + const parts = withoutScopeHint.split(":"); + if (parts.length < 4) { + return null; + } + + const resolvedVersion = coordinates.includes(" -> ") + ? coordinates.split(" -> ")[1].trim().split(" ")[0] + : parts[3]; + + return { + name: `${parts[0]}:${parts[1]}`, + version: resolvedVersion || "", + children: [], + }; +} + +function toMavenDependency(node, parentChain, sourceFile) { + return createDependency({ + name: node.name, + version: node.version, + ecosystem: "maven", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(node.children.map((child) => toMavenDependency(child, parentChain.concat(node.name), sourceFile))), + sourceFile, + isDevelopmentDependency: false, + }); +} + +function appendTreeNodeIfMissing(node, dependencies, sourceFile) { + const key = `${node.name.toLowerCase()}@${node.version.toLowerCase()}`; + const exists = dependencies.some((dependency) => ( + `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key + )); + if (!exists) { + dependencies.push(toMavenDependency(node, [], sourceFile)); + } + for (const child of node.children) { + appendTreeNodeIfMissing(child, dependencies, sourceFile); + } +} + +module.exports = mavenParser; diff --git a/util/lockfileParsers/npmParser.js b/util/lockfileParsers/npmParser.js new file mode 100644 index 0000000..8df4970 --- /dev/null +++ b/util/lockfileParsers/npmParser.js @@ -0,0 +1,836 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + normalizeVersion, + readJson, + pathExists, + readUtf8, + statSafe, + stripYamlComment, +} = require("./shared"); +const { parsePackageJsonManifest } = require("./manifestHelpers"); + +const LOCKFILE_NAMES = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]; + +const npmParser = { + name: "npmParser", + ecosystem: "npm", + + async canResolve(workspaceFolder) { + const matches = await this.detect(workspaceFolder); + return matches.length > 0; + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + for (const fileName of LOCKFILE_NAMES) { + const lockfilePath = path.join(rootPath, fileName); + if (await pathExists(lockfilePath, workspaceFolder)) { + const manifestPath = await pathExists(path.join(rootPath, "package.json"), workspaceFolder) + ? path.join(rootPath, "package.json") + : null; + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: fileName, + }]; + } + } + return []; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder, options = {} }) { + const sourceFile = getSourceFileName(lockfilePath); + const manifest = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? parsePackageJsonManifest(await readUtf8(manifestPath, workspaceFolder)) + : { dependencies: [], directNames: new Set(), devNames: new Set() }; + + if (sourceFile === "package-lock.json") { + return parsePackageLock(lockfilePath, manifest, workspaceFolder, options); + } + + if (sourceFile === "yarn.lock") { + return parseYarnLock(lockfilePath, manifest, workspaceFolder, options); + } + + if (sourceFile === "pnpm-lock.yaml") { + return parsePnpmLock(lockfilePath, manifest, workspaceFolder, options); + } + + throw new Error(`Unsupported npm lockfile: ${sourceFile}`); + }, +}; + +async function parsePackageLock(lockfilePath, manifest, workspaceFolder, options) { + const warnings = []; + const stats = await statSafe(lockfilePath, workspaceFolder); + if (stats && stats.size > 50 * 1024 * 1024) { + warnings.push("Large package-lock.json detected. Parsing may take longer than usual."); + } + + const root = await readJson(lockfilePath, workspaceFolder); + const packages = root && typeof root === "object" && root.packages && typeof root.packages === "object" + ? root.packages + : null; + + if (!packages) { + throw new Error("Malformed package-lock.json: missing packages object"); + } + + const rootEntry = packages[""] || {}; + const rootDependencyMap = { + ...(rootEntry.dependencies || {}), + ...(rootEntry.optionalDependencies || {}), + ...(rootEntry.devDependencies || {}), + }; + + const mergedManifestVersionHints = new Map(); + for (const dependency of manifest.dependencies) { + mergedManifestVersionHints.set(dependency.name, dependency.version); + } + for (const [name, version] of Object.entries(rootDependencyMap)) { + if (!mergedManifestVersionHints.has(name)) { + mergedManifestVersionHints.set(name, normalizeVersion(version)); + } + } + + const uniqueEntries = new Map(); + const nameIndex = new Map(); + const nameIndexKeys = new Map(); + + for (const [packagePath, packageInfo] of Object.entries(packages)) { + if (packagePath === "" || !packageInfo || typeof packageInfo !== "object") { + continue; + } + + const name = extractPackageLockName(packagePath); + const version = String(packageInfo.version || "").trim(); + if (!name || !version) { + continue; + } + + const key = `${name.toLowerCase()}@${version.toLowerCase()}`; + const existing = uniqueEntries.get(key); + const dependencies = { + ...(packageInfo.dependencies || {}), + ...(packageInfo.optionalDependencies || {}), + }; + const merged = existing || { + key, + name, + version, + dependencies: {}, + }; + Object.assign(merged.dependencies, dependencies); + uniqueEntries.set(key, merged); + + const existingByName = nameIndex.get(name) || []; + const existingKeys = nameIndexKeys.get(name) || new Set(); + if (!existingKeys.has(key)) { + existingKeys.add(key); + existingByName.push(merged); + nameIndex.set(name, existingByName); + nameIndexKeys.set(name, existingKeys); + } + } + + const directNames = manifest.directNames.size > 0 || manifest.devNames.size > 0 + ? new Set([...manifest.directNames, ...manifest.devNames]) + : new Set(Object.keys(rootDependencyMap)); + + const directRoots = []; + const seenDirectKeys = new Set(); + + for (const directName of directNames) { + const entry = selectEntryByName(nameIndex, directName, mergedManifestVersionHints.get(directName)); + const dependency = buildNpmDependency(entry, directName, [], nameIndex, new Set(), { + sourceFile: getSourceFileName(lockfilePath), + directNames, + devNames: manifest.devNames, + }); + const key = `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}`; + if (!seenDirectKeys.has(key)) { + seenDirectKeys.add(key); + directRoots.push(dependency); + } + } + + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + const addedKeys = new Set(); + collectDependencyKeys(dependencies, addedKeys); + for (const entry of uniqueEntries.values()) { + const key = `${entry.name.toLowerCase()}@${entry.version.toLowerCase()}`; + if (addedKeys.has(key)) { + continue; + } + addedKeys.add(key); + dependencies.push(createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: false, + parent: null, + parentChain: [], + transitives: [], + sourceFile: getSourceFileName(lockfilePath), + isDevelopmentDependency: manifest.devNames.has(entry.name), + })); + } + + if (options.maxDependenciesToScan && dependencies.length > options.maxDependenciesToScan) { + warnings.push( + `Large npm dependency tree (${dependencies.length} unique packages). ` + + `Display is capped at ${options.maxDependenciesToScan} dependencies.` + ); + } + + return buildTree("npm", getSourceFileName(lockfilePath), dependencies, warnings); +} + +function collectDependencyKeys(dependencies, addedKeys) { + for (const dependency of Array.isArray(dependencies) ? dependencies : []) { + addedKeys.add(`${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}`); + if (Array.isArray(dependency.transitives) && dependency.transitives.length > 0) { + collectDependencyKeys(dependency.transitives, addedKeys); + } + } +} + +function buildNpmDependency(entry, fallbackName, parentChain, nameIndex, visiting, context) { + if (!entry) { + return createDependency({ + name: fallbackName, + version: "", + ecosystem: "npm", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile: context.sourceFile, + isDevelopmentDependency: context.devNames.has(fallbackName), + }); + } + + if (visiting.has(entry.key)) { + return createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile: context.sourceFile, + isDevelopmentDependency: context.devNames.has(entry.name), + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(entry.key); + const nextParentChain = parentChain.concat(entry.name); + const transitives = []; + + for (const [dependencyName, versionHint] of Object.entries(entry.dependencies || {})) { + const childEntry = selectEntryByName(nameIndex, dependencyName, normalizeVersion(versionHint)); + if (childEntry && nextVisiting.has(childEntry.key)) { + continue; + } + transitives.push(buildNpmDependency( + childEntry, + dependencyName, + nextParentChain, + nameIndex, + nextVisiting, + context + )); + } + + return createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: parentChain.length === 0 || context.directNames.has(entry.name), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile: context.sourceFile, + isDevelopmentDependency: context.devNames.has(entry.name), + }); +} + +function selectEntryByName(nameIndex, dependencyName, versionHint) { + const entries = nameIndex.get(dependencyName) || []; + if (entries.length === 0) { + return null; + } + + const normalizedHint = normalizeVersion(versionHint); + if (normalizedHint) { + const exactMatch = entries.find((entry) => entry.version === normalizedHint); + if (exactMatch) { + return exactMatch; + } + } + + return entries[0]; +} + +function extractPackageLockName(packagePath) { + const marker = "node_modules/"; + const lastMarkerIndex = packagePath.lastIndexOf(marker); + const relativePath = lastMarkerIndex === -1 + ? packagePath + : packagePath.slice(lastMarkerIndex + marker.length); + const segments = relativePath.split("/").filter(Boolean); + if (segments.length === 0) { + return ""; + } + if (segments[0].startsWith("@") && segments.length >= 2) { + return `${segments[0]}/${segments[1]}`; + } + return segments[0]; +} + +async function parseYarnLock(lockfilePath, manifest, workspaceFolder, options) { + const content = await readUtf8(lockfilePath, workspaceFolder); + const parsed = parseYarnEntries(content); + if (parsed.entries.size === 0) { + throw new Error("Malformed yarn.lock: no package entries found"); + } + + const sourceFile = getSourceFileName(lockfilePath); + const manifestVersionHints = new Map(); + for (const dependency of manifest.dependencies) { + manifestVersionHints.set(dependency.name, dependency.version); + } + const directNames = manifest.directNames.size > 0 || manifest.devNames.size > 0 + ? new Set([...manifest.directNames, ...manifest.devNames]) + : new Set([...parsed.entries.values()].map((entry) => entry.name)); + + const directRoots = []; + for (const directName of directNames) { + const entry = selectYarnEntry(parsed, directName, manifestVersionHints.get(directName)); + directRoots.push(buildYarnDependency( + entry, + directName, + [], + parsed, + new Set(), + sourceFile, + manifest.devNames + )); + } + + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + for (const entry of parsed.entries.values()) { + const key = `${entry.name.toLowerCase()}@${entry.version.toLowerCase()}`; + if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) { + continue; + } + dependencies.push(createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: false, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: manifest.devNames.has(entry.name), + })); + } + + const warnings = []; + if (options.maxDependenciesToScan && dependencies.length > options.maxDependenciesToScan) { + warnings.push( + `Large npm dependency tree (${dependencies.length} unique packages). ` + + `Display is capped at ${options.maxDependenciesToScan} dependencies.` + ); + } + + return buildTree("npm", sourceFile, dependencies, warnings); +} + +function buildYarnDependency(entry, fallbackName, parentChain, parsedEntries, visiting, sourceFile, devNames) { + if (!entry) { + return createDependency({ + name: fallbackName, + version: "", + ecosystem: "npm", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: devNames.has(fallbackName), + }); + } + + const key = `${entry.name.toLowerCase()}@${entry.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: devNames.has(entry.name), + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(entry.name); + const transitives = []; + + for (const dependencyName of Object.keys(entry.dependencies || {})) { + const versionHint = entry.dependencies[dependencyName]; + transitives.push(buildYarnDependency( + selectYarnEntry(parsedEntries, dependencyName, versionHint), + dependencyName, + nextParentChain, + parsedEntries, + nextVisiting, + sourceFile, + devNames + )); + } + + return createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: devNames.has(entry.name), + }); +} + +function parseYarnEntries(content) { + const entries = new Map(); + const entriesByName = new Map(); + const selectorIndex = new Map(); + const lines = String(content || "").split(/\r?\n/); + let currentEntry = null; + let inDependencies = false; + + const flushCurrent = () => { + if (currentEntry && currentEntry.name && currentEntry.version) { + const key = `${currentEntry.name.toLowerCase()}@${currentEntry.version.toLowerCase()}`; + const existing = entries.get(key); + if (existing) { + Object.assign(existing.dependencies, currentEntry.dependencies); + for (const selector of currentEntry.selectors || []) { + if (!existing.selectors.includes(selector)) { + existing.selectors.push(selector); + } + selectorIndex.set(selector, key); + } + } else { + currentEntry.key = key; + entries.set(key, currentEntry); + if (!entriesByName.has(currentEntry.name)) { + entriesByName.set(currentEntry.name, []); + } + entriesByName.get(currentEntry.name).push(currentEntry); + for (const selector of currentEntry.selectors || []) { + selectorIndex.set(selector, key); + } + } + } + currentEntry = null; + inDependencies = false; + }; + + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, ""); + const trimmed = line.trim(); + if (!trimmed) { + flushCurrent(); + continue; + } + if (trimmed === "__metadata:") { + flushCurrent(); + continue; + } + + if (!line.startsWith(" ")) { + flushCurrent(); + const header = trimmed.replace(/:$/, ""); + const selectors = header.split(",").map((selector) => selector.trim().replace(/^["']|["']$/g, "")); + const primarySelector = selectors[0] || ""; + const name = parseYarnSelectorName(primarySelector); + if (!name) { + continue; + } + currentEntry = { + name, + version: "", + dependencies: {}, + selectors, + }; + continue; + } + + if (!currentEntry) { + continue; + } + + if (trimmed === "dependencies:") { + inDependencies = true; + continue; + } + + const versionMatch = trimmed.match(/^version\s+"([^"]+)"/); + if (versionMatch) { + currentEntry.version = versionMatch[1]; + inDependencies = false; + continue; + } + + if (inDependencies) { + const dependencyMatch = trimmed.match(/^("?[^"\s]+"?)\s+"([^"]+)"/); + if (!dependencyMatch) { + continue; + } + const dependencyName = dependencyMatch[1].replace(/^["']|["']$/g, ""); + currentEntry.dependencies[dependencyName] = dependencyMatch[2]; + } + } + + flushCurrent(); + return { + entries, + entriesByName, + selectorIndex, + }; +} + +function selectYarnEntry(parsedEntries, dependencyName, versionHint) { + if (!parsedEntries || !dependencyName) { + return null; + } + + const normalizedName = String(dependencyName || "").trim(); + if (!normalizedName) { + return null; + } + + const normalizedHint = String(versionHint || "").trim().replace(/^["']|["']$/g, ""); + if (normalizedHint) { + const exactSelectorKey = `${normalizedName}@${normalizedHint}`; + const selectedKey = parsedEntries.selectorIndex.get(exactSelectorKey); + if (selectedKey && parsedEntries.entries.has(selectedKey)) { + return parsedEntries.entries.get(selectedKey); + } + } + + const candidates = parsedEntries.entriesByName.get(normalizedName) || []; + if (candidates.length === 0) { + return null; + } + + if (normalizedHint) { + const exactVersionMatch = candidates.find((entry) => entry.version === normalizedHint); + if (exactVersionMatch) { + return exactVersionMatch; + } + } + + return candidates[0]; +} + +function parseYarnSelectorName(selector) { + const normalizedSelector = selector.trim().replace(/^["']|["']$/g, ""); + if (!normalizedSelector) { + return ""; + } + + if (normalizedSelector.startsWith("@")) { + const secondAt = normalizedSelector.indexOf("@", 1); + return secondAt === -1 ? normalizedSelector : normalizedSelector.slice(0, secondAt); + } + + const atIndex = normalizedSelector.indexOf("@"); + return atIndex === -1 ? normalizedSelector : normalizedSelector.slice(0, atIndex); +} + +async function parsePnpmLock(lockfilePath, manifest, workspaceFolder, options) { + const parsed = parsePnpmEntries(await readUtf8(lockfilePath, workspaceFolder)); + if (parsed.packageEntries.size === 0) { + throw new Error("Malformed pnpm-lock.yaml: no package entries found"); + } + + const sourceFile = getSourceFileName(lockfilePath); + const directNames = manifest.directNames.size > 0 || manifest.devNames.size > 0 + ? new Set([...manifest.directNames, ...manifest.devNames]) + : new Set(parsed.directVersions.keys()); + const directRoots = []; + + for (const directName of directNames) { + const entry = selectPnpmEntry(parsed.packageEntries, directName, parsed.directVersions.get(directName)); + directRoots.push(buildPnpmDependency( + entry, + directName, + [], + parsed.packageEntries, + new Set(), + sourceFile, + manifest.devNames + )); + } + + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + for (const entry of parsed.packageEntries.values()) { + const key = `${entry.name.toLowerCase()}@${entry.version.toLowerCase()}`; + if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) { + continue; + } + dependencies.push(createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: false, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: manifest.devNames.has(entry.name), + })); + } + + const warnings = []; + if (options.maxDependenciesToScan && dependencies.length > options.maxDependenciesToScan) { + warnings.push( + `Large npm dependency tree (${dependencies.length} unique packages). ` + + `Display is capped at ${options.maxDependenciesToScan} dependencies.` + ); + } + + return buildTree("npm", sourceFile, dependencies, warnings); +} + +function buildPnpmDependency(entry, fallbackName, parentChain, packageEntries, visiting, sourceFile, devNames) { + if (!entry) { + return createDependency({ + name: fallbackName, + version: "", + ecosystem: "npm", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: devNames.has(fallbackName), + }); + } + + const key = `${entry.name.toLowerCase()}@${entry.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: devNames.has(entry.name), + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(entry.name); + const transitives = []; + + for (const [dependencyName, versionHint] of Object.entries(entry.dependencies || {})) { + transitives.push(buildPnpmDependency( + selectPnpmEntry(packageEntries, dependencyName, versionHint), + dependencyName, + nextParentChain, + packageEntries, + nextVisiting, + sourceFile, + devNames + )); + } + + return createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: devNames.has(entry.name), + }); +} + +function selectPnpmEntry(packageEntries, dependencyName, versionHint) { + const normalizedHint = normalizeVersion(versionHint).split("(")[0].trim(); + const entries = [...packageEntries.values()].filter((entry) => entry.name === dependencyName); + if (entries.length === 0) { + return null; + } + if (normalizedHint) { + const exactMatch = entries.find((entry) => entry.version === normalizedHint); + if (exactMatch) { + return exactMatch; + } + } + return entries[0]; +} + +function parsePnpmEntries(content) { + const packageEntries = new Map(); + const directVersions = new Map(); + const lines = String(content || "").split(/\r?\n/); + let section = ""; + let currentPackage = null; + let currentPackageSubsection = ""; + let inImporter = false; + let importerSection = ""; + let currentImporterPackage = ""; + + const flushPackage = () => { + if (!currentPackage || !currentPackage.name || !currentPackage.version) { + currentPackage = null; + currentPackageSubsection = ""; + return; + } + packageEntries.set(`${currentPackage.name.toLowerCase()}@${currentPackage.version.toLowerCase()}`, currentPackage); + currentPackage = null; + currentPackageSubsection = ""; + }; + + for (const rawLine of lines) { + const lineWithoutComment = stripYamlComment(rawLine); + const line = lineWithoutComment.trimEnd(); + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const indent = rawLine.search(/\S/); + if (indent === 0 && trimmed === "importers:") { + flushPackage(); + section = "importers"; + inImporter = false; + continue; + } + if (indent === 0 && trimmed === "packages:") { + flushPackage(); + section = "packages"; + continue; + } + if (indent === 0 && trimmed.endsWith(":") && !["importers:", "packages:"].includes(trimmed)) { + flushPackage(); + section = ""; + continue; + } + + if (section === "importers") { + if (indent === 2 && trimmed.endsWith(":")) { + inImporter = true; + importerSection = ""; + currentImporterPackage = ""; + continue; + } + if (!inImporter) { + continue; + } + if (indent === 4 && trimmed.endsWith(":")) { + importerSection = trimmed.slice(0, -1); + currentImporterPackage = ""; + continue; + } + if (!["dependencies", "devDependencies", "optionalDependencies"].includes(importerSection)) { + continue; + } + if (indent === 6 && trimmed.endsWith(":")) { + currentImporterPackage = trimmed.slice(0, -1).replace(/^["']|["']$/g, ""); + continue; + } + if (indent === 8 && trimmed.startsWith("version:") && currentImporterPackage) { + directVersions.set( + currentImporterPackage, + normalizeVersion(trimmed.slice("version:".length).trim()).split("(")[0].trim() + ); + } + continue; + } + + if (section === "packages") { + if (indent === 2 && trimmed.endsWith(":")) { + flushPackage(); + const parsedKey = parsePnpmPackageKey(trimmed.slice(0, -1)); + if (!parsedKey) { + continue; + } + currentPackage = { + ...parsedKey, + dependencies: {}, + }; + continue; + } + if (!currentPackage) { + continue; + } + if (indent === 4 && trimmed.endsWith(":")) { + currentPackageSubsection = trimmed.slice(0, -1); + continue; + } + if (!["dependencies", "optionalDependencies"].includes(currentPackageSubsection)) { + continue; + } + if (indent === 6 && trimmed.includes(":")) { + const parts = trimmed.split(":", 2); + currentPackage.dependencies[parts[0].trim()] = normalizeVersion(parts[1].trim()).split("(")[0].trim(); + } + } + } + + flushPackage(); + return { + packageEntries, + directVersions, + }; +} + +function parsePnpmPackageKey(rawKey) { + const cleaned = rawKey.replace(/^\/+/, "").trim().replace(/^["']|["']$/g, ""); + if (!cleaned) { + return null; + } + + const withoutPeerSuffix = cleaned.split("(")[0]; + const atIndex = withoutPeerSuffix.lastIndexOf("@"); + if (atIndex <= 0) { + return null; + } + + return { + name: withoutPeerSuffix.slice(0, atIndex), + version: withoutPeerSuffix.slice(atIndex + 1), + }; +} + +module.exports = npmParser; diff --git a/util/lockfileParsers/nugetParser.js b/util/lockfileParsers/nugetParser.js new file mode 100644 index 0000000..95f8c7c --- /dev/null +++ b/util/lockfileParsers/nugetParser.js @@ -0,0 +1,190 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const fs = require("fs"); +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + pathExists, + readJson, + readUtf8, + resolveWorkspaceFilePath, +} = require("./shared"); +const { parseCsprojManifest } = require("./manifestHelpers"); + +const nugetParser = { + name: "nugetParser", + ecosystem: "nuget", + + async canResolve(workspaceFolder) { + const matches = await this.detect(workspaceFolder); + return matches.length > 0; + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const safeRootPath = await resolveWorkspaceFilePath(rootPath, workspaceFolder); + if (!safeRootPath) { + return []; + } + const entries = await fs.promises.readdir(safeRootPath); + const csprojPath = entries.find((entry) => entry.toLowerCase().endsWith(".csproj")); + const lockfilePath = await pathExists(path.join(safeRootPath, "packages.lock.json"), workspaceFolder) + ? path.join(safeRootPath, "packages.lock.json") + : null; + const manifestPath = csprojPath ? path.join(safeRootPath, csprojPath) : null; + + if (!lockfilePath && !manifestPath) { + return []; + } + + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + const manifestDependencies = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? parseCsprojManifest(await readUtf8(manifestPath, workspaceFolder)) + : []; + const directNames = new Set(manifestDependencies.map((dependency) => dependency.name.toLowerCase())); + + if (!lockfilePath) { + return buildTree("nuget", sourceFile, manifestDependencies.map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "nuget", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + }))); + } + + const root = await readJson(lockfilePath, workspaceFolder); + const dependencyRoot = root && root.dependencies && typeof root.dependencies === "object" + ? root.dependencies + : null; + if (!dependencyRoot) { + throw new Error("Malformed packages.lock.json: missing dependencies object"); + } + + const recordsByName = new Map(); + for (const frameworkDependencies of Object.values(dependencyRoot)) { + if (!frameworkDependencies || typeof frameworkDependencies !== "object") { + continue; + } + for (const [name, details] of Object.entries(frameworkDependencies)) { + const dependencies = details && details.dependencies && typeof details.dependencies === "object" + ? Object.keys(details.dependencies) + : []; + const existing = recordsByName.get(name.toLowerCase()); + recordsByName.set(name.toLowerCase(), { + name, + version: details.resolved || "", + dependencies: deduplicateStringValues([...(existing ? existing.dependencies : []), ...dependencies]), + isDirect: Boolean(existing && existing.isDirect) || String(details.type || "").toLowerCase() === "direct", + }); + } + } + + const rootRecords = manifestDependencies.length > 0 + ? manifestDependencies.map((dependency) => recordsByName.get(dependency.name.toLowerCase())).filter(Boolean) + : [...recordsByName.values()].filter((record) => record.isDirect); + + const directRoots = deduplicateDeps(rootRecords.map((record) => buildNugetDependency( + record, + [], + recordsByName, + new Set(), + sourceFile, + directNames + ))); + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + + for (const record of recordsByName.values()) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) { + continue; + } + dependencies.push(createDependency({ + name: record.name, + version: record.version, + ecosystem: "nuget", + isDirect: directNames.has(record.name.toLowerCase()) || record.isDirect, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return buildTree("nuget", sourceFile, dependencies); + }, +}; + +function buildNugetDependency(record, parentChain, recordsByName, visiting, sourceFile, directNames) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "nuget", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()) || record.isDirect, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(record.name); + const transitives = []; + + for (const dependencyName of record.dependencies) { + const childRecord = recordsByName.get(dependencyName.toLowerCase()); + if (!childRecord) { + continue; + } + transitives.push(buildNugetDependency( + childRecord, + nextParentChain, + recordsByName, + nextVisiting, + sourceFile, + directNames + )); + } + + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "nuget", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()) || record.isDirect, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: false, + }); +} + +function deduplicateStringValues(values) { + return [...new Set(values.filter(Boolean))]; +} + +module.exports = nugetParser; diff --git a/util/lockfileParsers/pythonParser.js b/util/lockfileParsers/pythonParser.js new file mode 100644 index 0000000..80decf9 --- /dev/null +++ b/util/lockfileParsers/pythonParser.js @@ -0,0 +1,383 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + normalizeVersion, + parseInlineTomlValue, + parseKeyValueLine, + readJson, + parseQuotedArray, + pathExists, + readUtf8, + stripTomlComment, +} = require("./shared"); +const { + parsePyprojectManifest, + parseRequirementSpec, +} = require("./manifestHelpers"); +const { normalizePackageName } = require("../packageNameNormalizer"); + +const SOURCE_PRIORITY = ["uv.lock", "poetry.lock", "Pipfile.lock", "requirements.txt"]; + +const pythonParser = { + name: "pythonParser", + ecosystem: "python", + + async canResolve(workspaceFolder) { + const matches = await this.detect(workspaceFolder); + return matches.length > 0; + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + for (const fileName of SOURCE_PRIORITY) { + const lockfilePath = path.join(rootPath, fileName); + if (await pathExists(lockfilePath, workspaceFolder)) { + const pyprojectPath = path.join(rootPath, "pyproject.toml"); + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath: await pathExists(pyprojectPath, workspaceFolder) ? pyprojectPath : null, + sourceFile: fileName, + }]; + } + } + return []; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath); + const pyproject = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? parsePyprojectManifest(await readUtf8(manifestPath, workspaceFolder)) + : { projectName: "", dependencies: [], directNames: new Set(), devNames: new Set() }; + + if (sourceFile === "requirements.txt") { + return parseRequirements(lockfilePath, workspaceFolder); + } + if (sourceFile === "Pipfile.lock") { + return parsePipfile(lockfilePath, workspaceFolder); + } + if (sourceFile === "poetry.lock" || sourceFile === "uv.lock") { + return parseTomlLock(lockfilePath, pyproject, workspaceFolder, sourceFile === "uv.lock"); + } + + throw new Error(`Unsupported Python dependency source: ${sourceFile}`); + }, +}; + +async function parseRequirements(lockfilePath, workspaceFolder) { + const dependencies = []; + + for (const rawLine of String(await readUtf8(lockfilePath, workspaceFolder)).split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#") || line.startsWith("-")) { + continue; + } + const parsed = parseRequirementSpec(line); + if (!parsed) { + throw new Error(`Malformed requirements.txt entry: ${line}`); + } + dependencies.push(createDependency({ + name: parsed.name, + version: parsed.version, + ecosystem: "python", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile: getSourceFileName(lockfilePath), + isDevelopmentDependency: false, + })); + } + + return buildTree("python", getSourceFileName(lockfilePath), dependencies, [ + "requirements.txt does not encode transitive dependencies. Showing direct requirements only.", + ]); +} + +async function parsePipfile(lockfilePath, workspaceFolder) { + const root = await readJson(lockfilePath, workspaceFolder); + const dependencies = []; + + for (const [name, details] of Object.entries(root.default || {})) { + dependencies.push(createDependency({ + name, + version: normalizeVersion(details && details.version), + ecosystem: "python", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile: getSourceFileName(lockfilePath), + isDevelopmentDependency: false, + })); + } + + for (const [name, details] of Object.entries(root.develop || {})) { + dependencies.push(createDependency({ + name, + version: normalizeVersion(details && details.version), + ecosystem: "python", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile: getSourceFileName(lockfilePath), + isDevelopmentDependency: true, + })); + } + + return buildTree("python", getSourceFileName(lockfilePath), deduplicateDeps(dependencies)); +} + +async function parseTomlLock(lockfilePath, pyproject, workspaceFolder, skipEditableRoot) { + const records = parsePythonPackageRecords( + await readUtf8(lockfilePath, workspaceFolder), + skipEditableRoot + ); + if (records.length === 0) { + throw new Error(`Malformed ${getSourceFileName(lockfilePath)}: no package entries found`); + } + + const sourceFile = getSourceFileName(lockfilePath); + const normalizedDirectNames = pyproject.directNames.size > 0 || pyproject.devNames.size > 0 + ? new Set( + [...pyproject.directNames, ...pyproject.devNames] + .map((name) => normalizePackageName(name, "python")) + ) + : new Set(records.filter((record) => record.isRootDependency).map((record) => record.normalizedName)); + + const recordsByName = new Map(); + const incomingCounts = new Map(); + for (const record of records) { + if (!recordsByName.has(record.normalizedName)) { + recordsByName.set(record.normalizedName, []); + } + recordsByName.get(record.normalizedName).push(record); + for (const dependencyName of record.dependencies) { + const normalizedDependencyName = normalizePackageName(dependencyName, "python"); + incomingCounts.set( + normalizedDependencyName, + (incomingCounts.get(normalizedDependencyName) || 0) + 1 + ); + } + } + + const rootRecords = normalizedDirectNames.size > 0 + ? [...normalizedDirectNames].map((name) => (recordsByName.get(name) || [])[0]).filter(Boolean) + : records.filter((record) => !incomingCounts.get(record.normalizedName)); + + const directRoots = deduplicateDeps(rootRecords.map((record) => buildPythonDependency( + record, + [], + recordsByName, + new Set(), + sourceFile, + new Set([...pyproject.devNames].map((name) => normalizePackageName(name, "python"))) + ))); + + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + for (const record of records) { + const key = `${record.normalizedName}@${record.version.toLowerCase()}`; + if (dependencies.some((dependency) => ( + `${normalizePackageName(dependency.name, "python")}@${dependency.version.toLowerCase()}` === key + ))) { + continue; + } + + dependencies.push(createDependency({ + name: record.name, + version: record.version, + ecosystem: "python", + isDirect: normalizedDirectNames.has(record.normalizedName), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return buildTree("python", sourceFile, dependencies); +} + +function buildPythonDependency(record, parentChain, recordsByName, visiting, sourceFile, normalizedDevNames) { + const key = `${record.normalizedName}@${record.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "python", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: normalizedDevNames.has(record.normalizedName), + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(record.name); + const transitives = []; + + for (const dependencyName of record.dependencies) { + const normalizedDependencyName = normalizePackageName(dependencyName, "python"); + const childRecord = (recordsByName.get(normalizedDependencyName) || [])[0]; + if (!childRecord) { + continue; + } + transitives.push(buildPythonDependency( + childRecord, + nextParentChain, + recordsByName, + nextVisiting, + sourceFile, + normalizedDevNames + )); + } + + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "python", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: normalizedDevNames.has(record.normalizedName), + }); +} + +function parsePythonPackageRecords(content, skipEditableRoot) { + const records = []; + let current = null; + let section = ""; + let metadataDirectNames = []; + + const flushCurrent = () => { + if (!current || !current.name || !current.version) { + current = null; + return; + } + const isEditableRoot = skipEditableRoot && current.sourceEditable === "."; + if (!isEditableRoot) { + records.push({ + ...current, + normalizedName: normalizePackageName(current.name, "python"), + isRootDependency: metadataDirectNames.includes(normalizePackageName(current.name, "python")), + }); + } + current = null; + }; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const line = stripTomlComment(rawLine).trim(); + if (!line || line.startsWith("#")) { + continue; + } + + if (line === "[[package]]") { + flushCurrent(); + current = { + name: "", + version: "", + dependencies: [], + sourceEditable: "", + }; + section = "package"; + continue; + } + + if (line === "[package.dependencies]") { + section = "package.dependencies"; + continue; + } + + if (line === "[metadata]") { + flushCurrent(); + section = "metadata"; + continue; + } + + if (line.startsWith("[") && line.endsWith("]")) { + section = ""; + continue; + } + + if (section === "package" && current) { + if (line.startsWith("name =")) { + current.name = parseKeyValueLine(line).value.replace(/^["']|["']$/g, ""); + continue; + } + if (line.startsWith("version =")) { + current.version = parseKeyValueLine(line).value.replace(/^["']|["']$/g, ""); + continue; + } + if (line.startsWith("source =")) { + current.sourceEditable = parseInlineTomlValue(parseKeyValueLine(line).value, "editable"); + continue; + } + if (line.startsWith("dependencies =")) { + const value = parseKeyValueLine(line).value; + if (value.startsWith("[")) { + current.dependencies.push(...parsePythonDependencyArray(value)); + } else if (value.startsWith("{")) { + current.dependencies.push(...parsePythonDependencyInlineObjects(value)); + } + } + continue; + } + + if (section === "package.dependencies" && current) { + const parts = parseKeyValueLine(line); + if (parts && parts.key) { + current.dependencies.push(parts.key.replace(/^["']|["']$/g, "")); + } + continue; + } + + if (section === "metadata") { + if (line.startsWith("direct-dependencies =") || line.startsWith("root-dependencies =")) { + metadataDirectNames = parseQuotedArray(parseKeyValueLine(line).value) + .map((name) => normalizePackageName(name, "python")); + } + } + } + + flushCurrent(); + return records; +} + +function parsePythonDependencyArray(value) { + const names = []; + for (const item of parseQuotedArray(value)) { + const parsed = parseRequirementSpec(item); + if (parsed) { + names.push(parsed.name); + } + } + if (names.length > 0) { + return names; + } + return parsePythonDependencyInlineObjects(value); +} + +function parsePythonDependencyInlineObjects(value) { + const names = []; + const pattern = /name\s*=\s*"([^"]+)"/g; + for (const match of String(value || "").matchAll(pattern)) { + names.push(match[1]); + } + return names; +} + +module.exports = pythonParser; diff --git a/util/lockfileParsers/rubyParser.js b/util/lockfileParsers/rubyParser.js new file mode 100644 index 0000000..c673653 --- /dev/null +++ b/util/lockfileParsers/rubyParser.js @@ -0,0 +1,217 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + countIndent, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, +} = require("./shared"); +const { parseGemfileManifest } = require("./manifestHelpers"); + +const rubyParser = { + name: "rubyParser", + ecosystem: "ruby", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "Gemfile.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "Gemfile"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "Gemfile.lock"), workspaceFolder) + ? path.join(rootPath, "Gemfile.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "Gemfile"), workspaceFolder) + ? path.join(rootPath, "Gemfile") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + if (!lockfilePath) { + const dependencies = parseGemfileManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "ruby", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + })); + return buildTree("ruby", sourceFile, dependencies); + } + + const directNames = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? new Set(parseGemfileManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => dependency.name.toLowerCase())) + : null; + const records = parseGemfileLock(await readUtf8(lockfilePath, workspaceFolder)); + const recordsByName = new Map(); + const incomingCounts = new Map(); + + for (const record of records) { + recordsByName.set(record.name.toLowerCase(), record); + for (const dependencyName of record.dependencies) { + incomingCounts.set(dependencyName.toLowerCase(), (incomingCounts.get(dependencyName.toLowerCase()) || 0) + 1); + } + } + + const rootRecords = directNames && directNames.size > 0 + ? [...directNames].map((name) => recordsByName.get(name)).filter(Boolean) + : records.filter((record) => !incomingCounts.get(record.name.toLowerCase())); + + const directRoots = deduplicateDeps(rootRecords.map((record) => buildRubyDependency( + record, + [], + recordsByName, + new Set(), + sourceFile, + directNames || new Set() + ))); + + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + for (const record of records) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) { + continue; + } + dependencies.push(createDependency({ + name: record.name, + version: record.version, + ecosystem: "ruby", + isDirect: directNames ? directNames.has(record.name.toLowerCase()) : false, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return buildTree("ruby", sourceFile, dependencies); + }, +}; + +function parseGemfileLock(content) { + const records = []; + let section = ""; + let inSpecs = false; + let current = null; + + const flushCurrent = () => { + if (current && current.name && current.version) { + records.push(current); + } + current = null; + }; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const trimmed = rawLine.trimEnd(); + if (!trimmed) { + continue; + } + const indent = countIndent(rawLine); + const line = trimmed.trim(); + + if (indent === 0 && /^[A-Z][A-Z0-9_ ]+$/.test(line)) { + flushCurrent(); + section = line; + inSpecs = false; + continue; + } + if (section === "GEM" && indent === 2 && line === "specs:") { + inSpecs = true; + continue; + } + if (!inSpecs) { + continue; + } + if (indent === 4) { + flushCurrent(); + const match = line.match(/^([^\s(]+) \(([^)]+)\)/); + if (!match) { + continue; + } + current = { name: match[1], version: match[2], dependencies: [] }; + continue; + } + if (indent >= 6 && current) { + const dependencyName = line.split(" ", 1)[0].split("(", 1)[0].replace(/!$/, "").trim(); + if (dependencyName) { + current.dependencies.push(dependencyName); + } + } + } + + flushCurrent(); + return records; +} + +function buildRubyDependency(record, parentChain, recordsByName, visiting, sourceFile, directNames) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "ruby", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(record.name); + const transitives = []; + + for (const dependencyName of record.dependencies) { + const childRecord = recordsByName.get(dependencyName.toLowerCase()); + if (!childRecord) { + continue; + } + transitives.push(buildRubyDependency( + childRecord, + nextParentChain, + recordsByName, + nextVisiting, + sourceFile, + directNames + )); + } + + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "ruby", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: false, + }); +} + +module.exports = rubyParser; diff --git a/util/lockfileParsers/shared.js b/util/lockfileParsers/shared.js new file mode 100644 index 0000000..f4be6a1 --- /dev/null +++ b/util/lockfileParsers/shared.js @@ -0,0 +1,412 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const fs = require("fs"); +const path = require("path"); + +const LARGE_FILE_THRESHOLD_BYTES = 50 * 1024 * 1024; +const WORKSPACE_PATH_ERROR = "Refusing to read files outside the workspace folder."; + +function getWorkspacePath(workspaceFolder) { + if (!workspaceFolder) { + return ""; + } + + if (typeof workspaceFolder === "string") { + return workspaceFolder; + } + + if (workspaceFolder.uri && workspaceFolder.uri.fsPath) { + return workspaceFolder.uri.fsPath; + } + + return String(workspaceFolder); +} + +async function pathExists(targetPath, workspaceFolder) { + const safePath = await resolveWorkspaceFilePath(targetPath, workspaceFolder); + if (!safePath) { + return false; + } + + try { + await fs.promises.access(safePath, fs.constants.R_OK); + return true; + } catch { + return false; + } +} + +function getCandidateWorkspaceRoot(targetPath, workspaceFolder) { + const workspacePath = getWorkspacePath(workspaceFolder); + if (workspacePath) { + return workspacePath; + } + + const rawTargetPath = String(targetPath || "").trim(); + if (!rawTargetPath) { + return ""; + } + + return path.dirname(path.resolve(rawTargetPath)); +} + +async function resolveWorkspaceRoot(targetPath, workspaceFolder) { + const candidateRoot = getCandidateWorkspaceRoot(targetPath, workspaceFolder); + if (!candidateRoot) { + return ""; + } + + try { + return await fs.promises.realpath(candidateRoot); + } catch { + return path.resolve(candidateRoot); + } +} + +function isWithinWorkspace(workspaceRoot, targetPath) { + if (!workspaceRoot || !targetPath) { + return false; + } + + const relativePath = path.relative(workspaceRoot, targetPath); + return relativePath === "" + || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +async function resolveWorkspaceFilePath(targetPath, workspaceFolder) { + const rawTargetPath = String(targetPath || "").trim(); + if (!rawTargetPath) { + return null; + } + + const resolvedTargetPath = path.resolve(rawTargetPath); + const workspaceRoot = await resolveWorkspaceRoot(resolvedTargetPath, workspaceFolder); + if (!workspaceRoot) { + return null; + } + + let realTargetPath; + try { + realTargetPath = await fs.promises.realpath(resolvedTargetPath); + } catch { + return null; + } + + return isWithinWorkspace(workspaceRoot, realTargetPath) + ? realTargetPath + : null; +} + +async function readUtf8(targetPath, workspaceFolder) { + const safePath = await resolveWorkspaceFilePath(targetPath, workspaceFolder); + if (!safePath) { + throw new Error(WORKSPACE_PATH_ERROR); + } + + return fs.promises.readFile(safePath, "utf8"); +} + +async function readJson(targetPath, workspaceFolder) { + return JSON.parse(await readUtf8(targetPath, workspaceFolder)); +} + +async function statSafe(targetPath, workspaceFolder) { + const safePath = await resolveWorkspaceFilePath(targetPath, workspaceFolder); + if (!safePath) { + return null; + } + + try { + return await fs.promises.stat(safePath); + } catch { + return null; + } +} + +function getSourceFileName(targetPath) { + return path.basename(targetPath || ""); +} + +function normalizeVersion(version) { + if (version == null) { + return ""; + } + + return String(version) + .trim() + .replace(/^["']|["']$/g, "") + .replace(/^[~^<>=! ]+/, "") + .trim(); +} + +function createDependency({ + name, + version, + ecosystem, + isDirect, + parent, + parentChain, + transitives, + sourceFile, + isDevelopmentDependency, +}) { + return { + name: String(name || "").trim(), + version: String(version || "").trim(), + ecosystem: String(ecosystem || "").trim(), + isDirect: Boolean(isDirect), + parent: parent || null, + parentChain: Array.isArray(parentChain) ? parentChain.slice() : [], + transitives: Array.isArray(transitives) ? transitives.slice() : [], + cloudsmithStatus: null, + cloudsmithPackage: null, + sourceFile: sourceFile || null, + isDevelopmentDependency: Boolean(isDevelopmentDependency), + }; +} + +function flattenDependencies(dependencies) { + const flattened = []; + + for (const dependency of Array.isArray(dependencies) ? dependencies : []) { + flattened.push(dependency); + if (Array.isArray(dependency.transitives) && dependency.transitives.length > 0) { + flattened.push(...flattenDependencies(dependency.transitives)); + } + } + + return flattened; +} + +function dependencyKey(dependency) { + return [ + String(dependency.ecosystem || "").trim().toLowerCase(), + String(dependency.name || "").trim().toLowerCase(), + String(dependency.version || "").trim().toLowerCase(), + ].join(":"); +} + +function deduplicateDeps(dependencies) { + const unique = []; + const seen = new Map(); + + for (const dependency of Array.isArray(dependencies) ? dependencies : []) { + const key = dependencyKey(dependency); + const existing = seen.get(key); + if (!existing) { + seen.set(key, dependency); + unique.push(dependency); + continue; + } + + if (!existing.isDirect && dependency.isDirect) { + const index = unique.indexOf(existing); + if (index !== -1) { + unique[index] = dependency; + } + seen.set(key, dependency); + continue; + } + + if ( + Array.isArray(existing.parentChain) && + existing.parentChain.length === 0 && + Array.isArray(dependency.parentChain) && + dependency.parentChain.length > 0 + ) { + const merged = { + ...existing, + parent: dependency.parent, + parentChain: dependency.parentChain.slice(), + }; + const index = unique.indexOf(existing); + if (index !== -1) { + unique[index] = merged; + } + seen.set(key, merged); + } + } + + return unique; +} + +function buildTree(ecosystem, sourceFile, dependencies, warnings) { + return { + ecosystem, + sourceFile, + dependencies: deduplicateDeps(dependencies), + warnings: Array.isArray(warnings) ? warnings.slice() : [], + }; +} + +function stripTomlComment(line) { + if (typeof line !== "string" || !line.includes("#")) { + return line || ""; + } + + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const previous = index > 0 ? line[index - 1] : ""; + if (char === "'" && !inDoubleQuote && previous !== "\\") { + inSingleQuote = !inSingleQuote; + continue; + } + if (char === "\"" && !inSingleQuote && previous !== "\\") { + inDoubleQuote = !inDoubleQuote; + continue; + } + if (char === "#" && !inSingleQuote && !inDoubleQuote) { + return line.slice(0, index); + } + } + + return line; +} + +function stripYamlComment(line) { + if (typeof line !== "string" || !line.includes("#")) { + return line || ""; + } + + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const previous = index > 0 ? line[index - 1] : ""; + if (char === "'" && !inDoubleQuote && previous !== "\\") { + inSingleQuote = !inSingleQuote; + continue; + } + if (char === "\"" && !inSingleQuote && previous !== "\\") { + inDoubleQuote = !inDoubleQuote; + continue; + } + if (char === "#" && !inSingleQuote && !inDoubleQuote) { + return line.slice(0, index); + } + } + + return line; +} + +function countIndent(line) { + if (typeof line !== "string") { + return 0; + } + + const firstNonWhitespace = line.search(/\S/); + return firstNonWhitespace === -1 ? line.length : firstNonWhitespace; +} + +function parseQuotedArray(rawValue) { + const value = String(rawValue || "").trim(); + if (!value.startsWith("[") || !value.endsWith("]")) { + return []; + } + + const results = []; + let current = ""; + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let index = 1; index < value.length - 1; index += 1) { + const char = value[index]; + const previous = index > 0 ? value[index - 1] : ""; + + if (char === "'" && !inDoubleQuote && previous !== "\\") { + inSingleQuote = !inSingleQuote; + current += char; + continue; + } + if (char === "\"" && !inSingleQuote && previous !== "\\") { + inDoubleQuote = !inDoubleQuote; + current += char; + continue; + } + + if (char === "," && !inSingleQuote && !inDoubleQuote) { + const cleaned = current.trim().replace(/^["']|["']$/g, ""); + if (cleaned) { + results.push(cleaned); + } + current = ""; + continue; + } + + current += char; + } + + const cleaned = current.trim().replace(/^["']|["']$/g, ""); + if (cleaned) { + results.push(cleaned); + } + + return results; +} + +function parseInlineTomlValue(block, key) { + if (typeof block !== "string" || !block.includes("{")) { + return ""; + } + + const expression = new RegExp(`${escapeRegExp(key)}\\s*=\\s*(\"([^\"]*)\"|'([^']*)'|([^,}]+))`); + const match = block.match(expression); + if (!match) { + return ""; + } + + return (match[2] || match[3] || match[4] || "").trim(); +} + +function escapeRegExp(value) { + return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function firstDefined(...values) { + for (const value of values) { + if (value != null && value !== "") { + return value; + } + } + return ""; +} + +function parseKeyValueLine(line) { + if (typeof line !== "string" || !line.includes("=")) { + return null; + } + + const separatorIndex = line.indexOf("="); + return { + key: line.slice(0, separatorIndex).trim(), + value: line.slice(separatorIndex + 1).trim(), + }; +} + +module.exports = { + LARGE_FILE_THRESHOLD_BYTES, + buildTree, + countIndent, + createDependency, + deduplicateDeps, + dependencyKey, + escapeRegExp, + firstDefined, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + normalizeVersion, + parseInlineTomlValue, + readJson, + parseKeyValueLine, + parseQuotedArray, + pathExists, + readUtf8, + resolveWorkspaceFilePath, + statSafe, + stripTomlComment, + stripYamlComment, +}; diff --git a/util/lockfileParsers/swiftParser.js b/util/lockfileParsers/swiftParser.js new file mode 100644 index 0000000..0e21f88 --- /dev/null +++ b/util/lockfileParsers/swiftParser.js @@ -0,0 +1,89 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + getSourceFileName, + getWorkspacePath, + readJson, + pathExists, + readUtf8, +} = require("./shared"); +const { normalizeSwiftIdentity, parsePackageSwiftManifest } = require("./manifestHelpers"); + +const swiftParser = { + name: "swiftParser", + ecosystem: "swift", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "Package.resolved"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "Package.swift"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "Package.resolved"), workspaceFolder) + ? path.join(rootPath, "Package.resolved") + : null; + const manifestPath = await pathExists(path.join(rootPath, "Package.swift"), workspaceFolder) + ? path.join(rootPath, "Package.swift") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + if (!lockfilePath) { + return buildTree("swift", sourceFile, parsePackageSwiftManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "swift", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }))); + } + + const manifestDirectNames = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? new Set(parsePackageSwiftManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => dependency.name)) + : new Set(); + const root = await readJson(lockfilePath); + const pins = Array.isArray(root.pins) + ? root.pins + : (root.object && Array.isArray(root.object.pins) ? root.object.pins : []); + if (pins.length === 0) { + throw new Error("Malformed Package.resolved: missing pins array"); + } + + return buildTree("swift", sourceFile, pins.map((pin) => { + const state = pin.state || {}; + const identity = normalizeSwiftIdentity(pin.identity || pin.package || pin.location || ""); + return createDependency({ + name: identity, + version: state.version || state.revision || state.branch || "", + ecosystem: "swift", + isDirect: manifestDirectNames.size === 0 || manifestDirectNames.has(identity), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }); + })); + }, +}; + +module.exports = swiftParser; diff --git a/util/lockfileResolver.js b/util/lockfileResolver.js new file mode 100644 index 0000000..844a51e --- /dev/null +++ b/util/lockfileResolver.js @@ -0,0 +1,140 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const npmParser = require("./lockfileParsers/npmParser"); +const pythonParser = require("./lockfileParsers/pythonParser"); +const mavenParser = require("./lockfileParsers/mavenParser"); +const gradleParser = require("./lockfileParsers/gradleParser"); +const goParser = require("./lockfileParsers/goParser"); +const cargoParser = require("./lockfileParsers/cargoParser"); +const rubyParser = require("./lockfileParsers/rubyParser"); +const dockerParser = require("./lockfileParsers/dockerParser"); +const nugetParser = require("./lockfileParsers/nugetParser"); +const dartParser = require("./lockfileParsers/dartParser"); +const composerParser = require("./lockfileParsers/composerParser"); +const helmParser = require("./lockfileParsers/helmParser"); +const swiftParser = require("./lockfileParsers/swiftParser"); +const hexParser = require("./lockfileParsers/hexParser"); +const { + getWorkspacePath, + resolveWorkspaceFilePath, +} = require("./lockfileParsers/shared"); + +const REGISTERED_RESOLVERS = [ + npmParser, + pythonParser, + mavenParser, + gradleParser, + goParser, + cargoParser, + rubyParser, + dockerParser, + nugetParser, + dartParser, + composerParser, + helmParser, + swiftParser, + hexParser, +]; + +class LockfileResolver { + static getResolvers() { + return REGISTERED_RESOLVERS.slice(); + } + + static async detectResolvers(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const matches = []; + + for (const resolver of REGISTERED_RESOLVERS) { + if (!resolver || typeof resolver.canResolve !== "function") { + continue; + } + + if (!(await resolver.canResolve(rootPath))) { + continue; + } + + const detections = typeof resolver.detect === "function" + ? await resolver.detect(rootPath) + : [{ + resolverName: resolver.name, + ecosystem: resolver.ecosystem, + workspaceFolder: rootPath, + lockfilePath: null, + manifestPath: null, + }]; + + for (const detection of detections) { + matches.push({ + resolverName: resolver.name, + ecosystem: resolver.ecosystem, + workspaceFolder: rootPath, + lockfilePath: detection.lockfilePath || null, + manifestPath: detection.manifestPath || null, + sourceFile: detection.sourceFile + || path.basename(detection.lockfilePath || detection.manifestPath || ""), + }); + } + } + + return matches; + } + + static async resolve(resolverName, lockfilePath, manifestPath, options = {}) { + const resolver = REGISTERED_RESOLVERS.find((candidate) => candidate.name === resolverName); + if (!resolver) { + throw new Error(`Unknown lockfile resolver: ${resolverName}`); + } + + const workspaceFolder = getWorkspacePath(options.workspaceFolder || path.dirname(lockfilePath || manifestPath || "")); + const safeLockfilePath = lockfilePath + ? await resolveWorkspaceFilePath(lockfilePath, workspaceFolder) + : null; + const safeManifestPath = manifestPath + ? await resolveWorkspaceFilePath(manifestPath, workspaceFolder) + : null; + + if (lockfilePath && !safeLockfilePath) { + throw new Error("Lockfile paths must stay within the workspace folder."); + } + + if (manifestPath && !safeManifestPath) { + throw new Error("Manifest paths must stay within the workspace folder."); + } + + return resolver.resolve({ + workspaceFolder, + lockfilePath: safeLockfilePath, + manifestPath: safeManifestPath, + options, + }); + } + + static async resolveAll(workspaceFolder, options = {}) { + const matches = await LockfileResolver.detectResolvers(workspaceFolder); + const trees = []; + + for (const match of matches) { + const tree = await LockfileResolver.resolve( + match.resolverName, + match.lockfilePath, + match.manifestPath, + { + ...options, + workspaceFolder: match.workspaceFolder, + detection: match, + } + ); + + if (tree) { + trees.push(tree); + } + } + + return trees; + } +} + +module.exports = { + LockfileResolver, +}; diff --git a/util/manifestParser.js b/util/manifestParser.js index 0200f3a..65b450c 100644 --- a/util/manifestParser.js +++ b/util/manifestParser.js @@ -1,9 +1,11 @@ -// Manifest parser - detects and parses project dependency manifests -// Supports: npm (package.json), Python (requirements.txt, pyproject.toml), -// Maven (pom.xml), Go (go.mod), Cargo (Cargo.toml) - -const fs = require("fs"); +// Copyright 2026 Cloudsmith Ltd. All rights reserved. const path = require("path"); +const { parsePyprojectManifest } = require("./lockfileParsers/manifestHelpers"); +const { + getWorkspacePath, + pathExists, + readUtf8, +} = require("./lockfileParsers/shared"); class ManifestParser { /** @@ -15,9 +17,7 @@ class ManifestParser { * @returns {Array<{filePath: string, format: string, parserMethod: string}>} */ static async detectManifests(workspaceFolderOrPath) { - const root = typeof workspaceFolderOrPath === "string" - ? workspaceFolderOrPath - : workspaceFolderOrPath.uri.fsPath; + const root = getWorkspacePath(workspaceFolderOrPath); const manifests = []; const checks = [ @@ -31,15 +31,13 @@ class ManifestParser { for (const check of checks) { const filePath = path.join(root, check.file); - try { - await fs.promises.access(filePath, fs.constants.R_OK); + if (await pathExists(filePath, root)) { manifests.push({ - filePath: filePath, + filePath, format: check.format, parserMethod: check.parserMethod, + workspaceFolder: root, }); - } catch (e) { // eslint-disable-line no-unused-vars - // File doesn't exist or isn't readable, skip } } @@ -54,7 +52,10 @@ class ManifestParser { */ static async parseManifest(manifest) { try { - const content = await fs.promises.readFile(manifest.filePath, "utf8"); + const content = await readUtf8( + manifest.filePath, + manifest.workspaceFolder || path.dirname(manifest.filePath || "") + ); const parser = ManifestParser[manifest.parserMethod]; if (!parser) { return []; @@ -157,88 +158,17 @@ class ManifestParser { } /** - * Parse pyproject.toml — basic line-by-line parsing for - * [project.dependencies] and [tool.poetry.dependencies]. - * Not a full TOML parser; handles the common cases. + * Parse pyproject.toml via the shared lockfile manifest helper so + * Poetry and PEP 621 formats stay consistent with lockfile resolution. */ static parsePyproject(content, format) { - const deps = []; - const lines = content.split("\n"); - let inDepsSection = false; - let inPoetryDeps = false; - - for (const rawLine of lines) { - const line = rawLine.trim(); - - // Detect section headers - if (line.startsWith("[")) { - inDepsSection = line === "[project.dependencies]" || - line === "[project]"; - inPoetryDeps = line === "[tool.poetry.dependencies]"; - continue; - } - - // Handle [project] section with dependencies = [...] array - if (inDepsSection && line.startsWith("dependencies")) { - // dependencies = ["flask>=2.0", "requests"] - const arrayMatch = line.match(/dependencies\s*=\s*\[(.*)\]/); - if (arrayMatch) { - const items = arrayMatch[1].split(","); - for (const item of items) { - const cleaned = item.trim().replace(/['"]/g, ""); - if (!cleaned) { - continue; - } - const depMatch = cleaned.match(/^([a-zA-Z0-9_\-.]+)\s*([><=!~]+)?\s*(.*)?/); - if (depMatch) { - deps.push({ - name: depMatch[1], - version: depMatch[3] ? depMatch[3].trim() : "", - devDependency: false, - format: format || "python", - }); - } - } - inDepsSection = false; - continue; - } - } - - // Handle Poetry-style: name = "^version" or name = {version = "^version"} - if (inPoetryDeps) { - if (!line || line.startsWith("#")) { - continue; - } - // Skip python version requirement - if (line.startsWith("python")) { - continue; - } - - // name = "^1.2.3" - const simpleMatch = line.match(/^([a-zA-Z0-9_\-.]+)\s*=\s*"([^"]*)"/); - if (simpleMatch) { - deps.push({ - name: simpleMatch[1], - version: ManifestParser._stripVersionPrefix(simpleMatch[2]), - devDependency: false, - format: format || "python", - }); - continue; - } - - // name = { version = "^1.2.3", ... } - const complexMatch = line.match(/^([a-zA-Z0-9_\-.]+)\s*=\s*\{.*version\s*=\s*"([^"]*)"/); - if (complexMatch) { - deps.push({ - name: complexMatch[1], - version: ManifestParser._stripVersionPrefix(complexMatch[2]), - devDependency: false, - format: format || "python", - }); - } - } - } - return deps; + const parsed = parsePyprojectManifest(content); + return parsed.dependencies.map((dependency) => ({ + name: dependency.name, + version: dependency.version, + devDependency: dependency.isDevelopmentDependency, + format: format || "python", + })); } /** @@ -414,7 +344,7 @@ class ManifestParser { */ static async findDependencyLocation(filePath, dependencyName, format) { try { - const content = await fs.promises.readFile(filePath, "utf8"); + const content = await readUtf8(filePath, path.dirname(filePath)); const lines = content.split("\n"); switch (format) { diff --git a/util/packageNameNormalizer.js b/util/packageNameNormalizer.js new file mode 100644 index 0000000..060f343 --- /dev/null +++ b/util/packageNameNormalizer.js @@ -0,0 +1,143 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const MAX_PACKAGE_NAME_LENGTH = 4096; + +const ECOSYSTEM_TO_FORMAT = { + npm: "npm", + maven: "maven", + gradle: "maven", + pypi: "python", + python: "python", + go: "go", + cargo: "cargo", + ruby: "ruby", + docker: "docker", + nuget: "nuget", + dart: "dart", + composer: "composer", + helm: "helm", + swift: "swift", + hex: "hex", + conda: "conda", +}; + +function sanitizePackageNameInput(name) { + const normalized = String(name == null ? "" : name) + .replace(/\0/g, "") + .trim(); + + if (!normalized || normalized.length > MAX_PACKAGE_NAME_LENGTH) { + return ""; + } + + return normalized; +} + +function canonicalFormat(ecosystemOrFormat) { + const normalized = sanitizePackageNameInput(ecosystemOrFormat).toLowerCase(); + return ECOSYSTEM_TO_FORMAT[normalized] || normalized; +} + +function normalizePackageName(name, ecosystemOrFormat) { + const format = canonicalFormat(ecosystemOrFormat); + const rawName = sanitizePackageNameInput(name); + if (!rawName) { + return ""; + } + + if (format === "python") { + return rawName.toLowerCase().replace(/[-_.]+/g, "-"); + } + + return rawName.toLowerCase(); +} + +function getPackageLookupKeys(name, ecosystemOrFormat, identifiers) { + const format = canonicalFormat(ecosystemOrFormat); + const rawName = sanitizePackageNameInput(name); + if (!rawName) { + return []; + } + + if (format === "maven") { + const artifactId = rawName.includes(":") ? rawName.split(":").slice(1).join(":") : rawName; + const keys = [normalizePackageName(rawName, format)]; + if (artifactId) { + keys.push(normalizePackageName(artifactId, format)); + } + if (identifiers && identifiers.group_id) { + keys.push(normalizePackageName(`${identifiers.group_id}:${rawName}`, format)); + } + return [...new Set(keys.filter(Boolean))]; + } + + if (format === "docker") { + return buildDockerLookupKeys(rawName); + } + + return [normalizePackageName(rawName, format)]; +} + +function getCloudsmithPackageLookupKeys(pkg, ecosystemOrFormat) { + if (!pkg || typeof pkg !== "object") { + return []; + } + + const format = canonicalFormat(ecosystemOrFormat || pkg.format); + if (format !== "maven") { + return getPackageLookupKeys(pkg.name, format); + } + + const identifiers = pkg.identifiers && typeof pkg.identifiers === "object" ? pkg.identifiers : {}; + const keys = [normalizePackageName(pkg.name, format)]; + if (identifiers.group_id) { + keys.push(normalizePackageName(`${identifiers.group_id}:${pkg.name}`, format)); + } + return [...new Set(keys.filter(Boolean))]; +} + +function buildDockerLookupKeys(name) { + const raw = normalizePackageName(String(name || "").replace(/^\/+/, ""), "docker").replace(/^\/+/, ""); + if (!raw) { + return []; + } + + const segments = raw.split("/").filter(Boolean); + const keys = new Set([raw]); + const firstSegment = segments[0] || ""; + const hasExplicitRegistry = segments.length > 1 && (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost"); + + if (!hasExplicitRegistry) { + if (segments.length === 1) { + keys.add(`library/${segments[0]}`); + keys.add(`docker.io/library/${segments[0]}`); + keys.add(`index.docker.io/library/${segments[0]}`); + } else { + keys.add(`docker.io/${raw}`); + keys.add(`index.docker.io/${raw}`); + if (raw.startsWith("library/")) { + keys.add(raw.slice("library/".length)); + } + } + return [...keys]; + } + + const pathPart = segments.slice(1).join("/"); + if (["docker.io", "index.docker.io", "registry-1.docker.io"].includes(firstSegment) && pathPart) { + keys.add(pathPart); + keys.add(`docker.io/${pathPart}`); + if (pathPart.startsWith("library/")) { + keys.add(pathPart.slice("library/".length)); + } + } + + return [...keys]; +} + +module.exports = { + ECOSYSTEM_TO_FORMAT, + canonicalFormat, + getCloudsmithPackageLookupKeys, + getPackageLookupKeys, + normalizePackageName, + sanitizePackageNameInput, +};