diff --git a/build/main.js b/build/main.js index 817b8e9..8874355 100644 --- a/build/main.js +++ b/build/main.js @@ -23935,11 +23935,12 @@ function processPackages(input) { optionalDependencies: [] }; } - const root = packageMap[""]; for (const [pkgKey, pkg] of Object.entries(input)) { const parsedPkg = packageMap[pkgKey]; processDependencyMap(pkg, parsedPkg, packageMap, pkgKey); } + const root = packageMap[""]; + delete packageMap[""]; return { packages: Object.values(packageMap), root }; } function processDependencyMap(pkg, parsed, packageMap, parentKey) { @@ -23978,7 +23979,7 @@ function* createLineReader(input) { yield line; } } -var yamlPairPattern = /^(? *)(['"](?[^"']+)["']|(?[^:]+)):( (["'](?[^"']+)["']|(?.+)))?$/; +var yamlPairPattern = /^(? *)(['"](?[^"']+)["']|(?[^:]+)):( (["'](?[^"']+)["']|(?.+)))?$/; var spacePattern = /^(? *)[^ ]/; function* createYamlPairReader(input) { const lineReader = createLineReader(input); @@ -23991,7 +23992,9 @@ function* createYamlPairReader(input) { } const pairMatch = line.match(yamlPairPattern); if (pairMatch && pairMatch.groups) { - const { indent, key, value } = pairMatch.groups; + const { indent, key: unquotedKey, value: unquotedValue, quotedKey, quotedValue } = pairMatch.groups; + const key = quotedKey ?? unquotedKey; + const value = quotedValue ?? unquotedValue; const indentSize = indent.length; adjustPath(indentSize, lastIndent, lastKey, path2); yield { @@ -24026,8 +24029,113 @@ function adjustPath(indentSize, lastIndent, lastKey, path2) { // node_modules/lockparse/lib/parsers/yarn.js async function parseYarn(input, packageJson) { + const isV1 = input.includes("yarn lockfile v1"); const packageMap = {}; + if (isV1) { + processYarnV1(input, packageMap); + } else { + processYarn(input, packageMap); + } + const root = { + name: "root", + version: "", + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + }; + if (packageJson) { + root.version = packageJson.version ?? ""; + processRootDependencies(packageJson, root, packageMap); + } + return { + type: "yarn", + packages: Object.values(packageMap), + root + }; +} +var indentPattern = /^( *)/; +var quotePattern = /^['"]|['"]$/g; +function processYarnV1(input, packageMap) { + const lineReader = createLineReader(input); + let currentPackage = null; + let currentDepType = null; + for (const line of lineReader) { + if (line === "") { + continue; + } + const indentMatch = line.match(indentPattern); + const indentSize = indentMatch ? indentMatch[1].length : 0; + if (indentSize === 0 && line.endsWith(":")) { + const pkgKeys = line.slice(0, -1).split(", "); + currentPackage = null; + for (const pkgKeyRaw of pkgKeys) { + const pkgKey = pkgKeyRaw.replace(quotePattern, ""); + if (!currentPackage) { + let pkg = packageMap[pkgKey]; + if (!pkg) { + pkg = { + name: "", + version: "", + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + }; + packageMap[pkgKey] = pkg; + } + currentPackage = pkg; + if (!pkg.name) { + const separatorIndex = pkgKey.indexOf("@", 1); + const name = pkgKey.slice(0, separatorIndex); + pkg.name = name; + } + } else { + packageMap[pkgKey] = currentPackage; + } + } + continue; + } + if (indentSize === 2) { + if (line.endsWith(":")) { + const key = line.slice(indentSize, -1); + if (dependencyTypes.includes(key)) { + currentDepType = key; + } + } else { + const separatorIndex = line.indexOf(" ", indentSize); + const key = line.slice(indentSize, separatorIndex); + const value = line.slice(separatorIndex + 1); + if (key === "version" && currentPackage) { + currentPackage.version = value.replace(quotePattern, ""); + } + } + continue; + } + if (indentSize === 4 && currentDepType && currentPackage) { + const separatorIndex = line.indexOf(" ", indentSize); + const depName = line.slice(indentSize, separatorIndex).replace(quotePattern, ""); + const depSemver = line.slice(separatorIndex + 1).replace(quotePattern, ""); + const depPkgKey = `${depName}@${depSemver}`; + let depPkg = packageMap[depPkgKey]; + if (!depPkg) { + depPkg = { + name: depName, + version: "", + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + }; + packageMap[depPkgKey] = depPkg; + } + currentPackage[currentDepType].push(depPkg); + } + } +} +function processYarn(input, packageMap) { const pairReader = createYamlPairReader(input); + const optionalDependencies = []; for (const pair of pairReader) { if (pair.path.length == 0 && !pair.value && pair.key.includes("@npm:")) { const pkgKeys = pair.key.split(", "); @@ -24085,24 +24193,22 @@ async function parseYarn(input, packageJson) { if (pkg) { pkg[depType].push(depPkg); } + } else if (pair.path.length === 3 && pair.value === "true" && pair.key === "optional" && pair.path[1] === "dependenciesMeta") { + const pkgKey = pair.path[0]; + optionalDependencies.push([pkgKey, pair.path[2]]); } } - const root = { - name: "root", - version: "", - dependencies: [], - devDependencies: [], - optionalDependencies: [], - peerDependencies: [] - }; - if (packageJson) { - processRootDependencies(packageJson, root, packageMap); + for (const [pkgKey, depName] of optionalDependencies) { + const pkg = packageMap[pkgKey]; + if (pkg) { + const deps = pkg.dependencies; + const index = deps.findIndex((d) => d.name === depName); + if (index !== -1) { + const [dep] = deps.splice(index, 1); + pkg.optionalDependencies.push(dep); + } + } } - return { - type: "yarn", - packages: Object.values(packageMap), - root - }; } function processRootDependencies(packageJson, root, packageMap) { for (const depType of dependencyTypes) { @@ -24149,7 +24255,7 @@ async function parsePnpm(input) { const depVersionKey = pair.value; const depName = pair.key; const depMapKey = `${depName}@${depVersionKey}`; - const depPackage = getOrCreatePackage(packageMap, depMapKey, depName, depVersionKey); + const depPackage = getOrCreatePackage(packageMap, depMapKey, depName, depVersionKey ?? void 0); tryAddDependency(currentPackage, dependencyType, depPackage); } } @@ -24297,7 +24403,7 @@ function parse(input, typeOrFileName, packageJson) { } // src/lockfile.ts -import { existsSync, readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; import { join } from "node:path"; var supportedLockfiles = [ "pnpm-lock.yaml", @@ -24974,13 +25080,21 @@ async function run() { let parsedCurrentLock; let parsedBaseLock; try { - parsedCurrentLock = await parse(currentPackageLock, lockfilePath, currentPackageJson ?? void 0); + parsedCurrentLock = await parse( + currentPackageLock, + lockfilePath, + currentPackageJson ?? void 0 + ); } catch (err) { core7.setFailed(`Failed to parse current lockfile: ${err}`); return; } try { - parsedBaseLock = await parse(basePackageLock, lockfilePath, basePackageJson ?? void 0); + parsedBaseLock = await parse( + basePackageLock, + lockfilePath, + basePackageJson ?? void 0 + ); } catch (err) { core7.setFailed(`Failed to parse base lockfile: ${err}`); return; diff --git a/package-lock.json b/package-lock.json index aa6a128..1df92c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@types/node": "^24.7.0", "esbuild": "^0.25.10", "eslint": "^9.37.0", - "lockparse": "^0.2.1", + "lockparse": "^0.3.0", "module-replacements": "^2.9.0", "pkg-types": "^2.3.0", "prettier": "^3.6.2", @@ -2518,9 +2518,9 @@ } }, "node_modules/lockparse": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/lockparse/-/lockparse-0.2.2.tgz", - "integrity": "sha512-NErtlgznd1hcKCmbb7gHLx5bCuJE5ow3UlErZPZap67LYK5uulMNmy6cq18oWyFiU7coADeET+1qhSJ/iD8iDg==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/lockparse/-/lockparse-0.3.0.tgz", + "integrity": "sha512-k4wqfH56tmYzHsoWy0FPN2dKRrZJ+9XE2YMaAw6sffv+6Z1huxCWBsHw1UQkwzw9Y23NeXTILGmgMGRIh/1STA==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 9ff906f..0c59f3e 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@types/node": "^24.7.0", "esbuild": "^0.25.10", "eslint": "^9.37.0", - "lockparse": "^0.2.1", + "lockparse": "^0.3.0", "module-replacements": "^2.9.0", "pkg-types": "^2.3.0", "prettier": "^3.6.2", diff --git a/src/lockfile.ts b/src/lockfile.ts index 4dd924c..2bc3c3f 100644 --- a/src/lockfile.ts +++ b/src/lockfile.ts @@ -1,5 +1,5 @@ import type {ParsedLockFile} from 'lockparse'; -import {existsSync, readFileSync} from 'node:fs'; +import {existsSync} from 'node:fs'; import {join} from 'node:path'; export type VersionsSet = Map>; @@ -31,10 +31,6 @@ export function detectLockfile(workspacePath: string): string | undefined { return undefined; } -export function readTextFile(path: string): string { - return readFileSync(path, 'utf8'); -} - function addVersion(map: VersionsSet, name: string, version: string): void { let set = map.get(name); if (!set) { diff --git a/test/git_test.ts b/test/git_test.ts index 415c064..3210666 100644 --- a/test/git_test.ts +++ b/test/git_test.ts @@ -1,4 +1,4 @@ -import {describe, it, expect, beforeEach, vi} from 'vitest'; +import {describe, it, expect} from 'vitest'; import * as git from '../src/git.js'; import * as github from '@actions/github'; import * as process from 'process'; @@ -60,17 +60,6 @@ describe('getBaseRef', () => { }); describe('getFileFromRef', () => { - beforeEach(() => { - vi.mock(import('@actions/core'), async (importModule) => { - const mod = await importModule(); - return { - ...mod, - info: vi.fn(), - error: vi.fn() - }; - }); - }); - it('should return file content from a given ref', () => { const content = git.getFileFromRef('HEAD', 'package.json', rootDir); expect(content).toBeDefined(); @@ -82,3 +71,21 @@ describe('getFileFromRef', () => { expect(content).toBeNull(); }); }); + +describe('tryGetJSONFromRef', () => { + it('returns null for non-existent file', () => { + const result = git.tryGetJSONFromRef('HEAD', 'nonexistent.json', rootDir); + expect(result).toBeNull(); + }); + + it('returns null for invalid JSON content', () => { + const result = git.tryGetJSONFromRef('HEAD', 'README.md', rootDir); + expect(result).toBeNull(); + }); + + it('returns parsed JSON object for valid JSON content', () => { + const result = git.tryGetJSONFromRef('HEAD', 'package.json', rootDir); + expect(result).toBeDefined(); + expect(result).toHaveProperty('name'); + }); +}); diff --git a/test/lockfile_test.ts b/test/lockfile_test.ts new file mode 100644 index 0000000..5bdef3b --- /dev/null +++ b/test/lockfile_test.ts @@ -0,0 +1,143 @@ +import type {ParsedLockFile} from 'lockparse'; +import {describe, it, expect} from 'vitest'; +import { + computeDependencyVersions, + diffDependencySets +} from '../src/lockfile.js'; + +const mockLockFile: ParsedLockFile = { + type: 'npm', + packages: [], + root: { + name: 'root', + version: '1.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + } +}; + +describe('computeDependencyVersions', () => { + it('should return an empty map for an empty lock file', () => { + const lockFile: ParsedLockFile = {...mockLockFile}; + const result = computeDependencyVersions(lockFile); + expect(result.size).toBe(0); + }); + + it('should correctly compute versions for a simple lock file', () => { + const lockFile: ParsedLockFile = { + ...mockLockFile, + packages: [ + { + name: 'foo', + version: '1.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + }, + { + name: 'bar', + version: '2.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + }, + { + name: 'foo', + version: '1.1.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + } + ] + }; + const result = computeDependencyVersions(lockFile); + expect(result.size).toBe(2); + expect(result.get('foo')).toEqual(new Set(['1.0.0', '1.1.0'])); + expect(result.get('bar')).toEqual(new Set(['2.0.0'])); + }); + + it('should ignore packages without name or version', () => { + const lockFile: ParsedLockFile = { + ...mockLockFile, + packages: [ + { + name: 'foo', + version: '1.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + }, + { + name: '', + version: '2.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + }, + { + name: 'bar', + version: '', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + } + ] + }; + const result = computeDependencyVersions(lockFile); + expect(result.size).toBe(1); + expect(result.get('foo')).toEqual(new Set(['1.0.0'])); + }); +}); + +describe('diffDependencySets', () => { + it('should return an empty array for identical sets', () => { + const setA = new Map>([ + ['foo', new Set(['1.0.0'])], + ['bar', new Set(['2.0.0'])] + ]); + const setB = new Map>([ + ['foo', new Set(['1.0.0'])], + ['bar', new Set(['2.0.0'])] + ]); + const result = diffDependencySets(setA, setB); + expect(result.length).toBe(0); + }); + + it('should detect added dependencies', () => { + const setA = new Map>([['foo', new Set(['1.0.0'])]]); + const setB = new Map>([ + ['foo', new Set(['1.0.0'])], + ['bar', new Set(['2.0.0'])] + ]); + const result = diffDependencySets(setA, setB); + expect(result.length).toBe(1); + expect(result[0]).toEqual({ + name: 'bar', + previous: new Set(), + current: new Set(['2.0.0']) + }); + }); + + it('should detect removed dependencies', () => { + const setA = new Map>([ + ['foo', new Set(['1.0.0'])], + ['bar', new Set(['2.0.0'])] + ]); + const setB = new Map>([['foo', new Set(['1.0.0'])]]); + const result = diffDependencySets(setA, setB); + expect(result.length).toBe(1); + expect(result[0]).toEqual({ + name: 'bar', + previous: new Set(['2.0.0']), + current: new Set() + }); + }); +}); diff --git a/test/npm_test.ts b/test/npm_test.ts index 5b20471..e1678dc 100644 --- a/test/npm_test.ts +++ b/test/npm_test.ts @@ -8,7 +8,17 @@ import { type MockInstance, expect } from 'vitest'; -import {fetchPackageMetadata, metaCache} from '../src/npm.js'; +import { + fetchPackageMetadata, + metaCache, + getProvenance, + getTrustLevel, + getProvenanceForPackageVersions, + getMinTrustLevel, + getDependenciesFromPackageJson, + type ProvenanceStatus, + type PackageMetadata +} from '../src/npm.js'; describe('fetchPackageMetadata', () => { let fetchMock: MockInstance; @@ -59,3 +69,228 @@ describe('fetchPackageMetadata', () => { ); }); }); + +describe('getProvenance', () => { + it('returns trusted-with-provenance for trusted publisher', () => { + const meta: PackageMetadata = { + name: 'foo', + version: '1.0.0', + _npmUser: { + name: 'bob', + email: 'bob@bill.com', + trustedPublisher: {} + } + }; + expect(getProvenance(meta)).toBe('trusted-with-provenance'); + }); + + it('returns provenance if attestations with provenance exist', () => { + const meta: PackageMetadata = { + name: 'foo', + version: '1.0.0', + _npmUser: { + name: 'bob', + email: 'bob@bill.com' + }, + dist: { + attestations: { + url: 'https://example.com', + provenance: {} + } + } + }; + expect(getProvenance(meta)).toBe('provenance'); + }); + + it('returns none if no provenance information is available', () => { + const meta: PackageMetadata = { + name: 'foo', + version: '1.0.0', + _npmUser: { + name: 'bob', + email: 'bob@bill.com' + } + }; + expect(getProvenance(meta)).toBe('none'); + }); +}); + +describe('getTrustLevel', () => { + it('returns 2 for trusted-with-provenance', () => { + expect(getTrustLevel('trusted-with-provenance')).toBe(2); + }); + + it('returns 1 for provenance', () => { + expect(getTrustLevel('provenance')).toBe(1); + }); + + it('returns 0 for none', () => { + expect(getTrustLevel('none')).toBe(0); + }); + + it('returns 0 for unknown status', () => { + expect(getTrustLevel('unknown' as never)).toBe(0); + }); +}); + +describe('getProvenanceForPackageVersions', () => { + let fetchMock: MockInstance; + + beforeEach(() => { + fetchMock = vi.spyOn(globalThis, 'fetch'); + vi.mock(import('@actions/core'), async (importModule) => { + const mod = await importModule(); + return { + ...mod, + info: vi.fn(), + error: vi.fn() + }; + }); + }); + + afterEach(() => { + fetchMock.mockRestore(); + vi.clearAllMocks(); + metaCache.clear(); + }); + + it('fetches provenance statuses for multiple versions', async () => { + const mockMetadatas: Record = { + '1.0.0': { + name: 'some-package', + version: '1.0.0', + _npmUser: { + name: 'alice', + email: 'alice@example.com' + }, + dist: { + attestations: { + url: 'https://example.com/attestation-1.0.0', + provenance: {} + } + } + }, + '2.0.0': { + name: 'some-package', + version: '2.0.0', + _npmUser: { + name: 'bob', + email: 'bob@example.com' + } + }, + '3.0.0': { + name: 'some-package', + version: '3.0.0', + _npmUser: { + name: 'jg', + email: 'jg@example.com', + trustedPublisher: {} + } + } + }; + + fetchMock.mockImplementation((url) => { + if (url === 'https://registry.npmjs.org/some-package/1.0.0') { + return Promise.resolve( + new Response(JSON.stringify(mockMetadatas['1.0.0']), {status: 200}) + ); + } else if (url === 'https://registry.npmjs.org/some-package/2.0.0') { + return Promise.resolve( + new Response(JSON.stringify(mockMetadatas['2.0.0']), {status: 200}) + ); + } else if (url === 'https://registry.npmjs.org/some-package/3.0.0') { + return Promise.resolve( + new Response(JSON.stringify(mockMetadatas['3.0.0']), {status: 200}) + ); + } + throw new Error('Unexpected URL'); + }); + + const versions = new Set(['1.0.0', '2.0.0', '3.0.0']); + const result = await getProvenanceForPackageVersions( + 'some-package', + versions + ); + + expect(result.get('1.0.0')).toBe('provenance'); + expect(result.get('2.0.0')).toBe('none'); + expect(result.get('3.0.0')).toBe('trusted-with-provenance'); + }); +}); + +describe('getMinTrustLevel', () => { + it('returns the minimum trust level and corresponding status', () => { + const statuses: ProvenanceStatus[] = [ + 'trusted-with-provenance', + 'provenance', + 'none', + 'provenance' + ]; + const result = getMinTrustLevel(statuses); + expect(result).toEqual({level: 0, status: 'none'}); + }); + + it('returns level 0 and none for empty input', () => { + const statuses: ProvenanceStatus[] = []; + const result = getMinTrustLevel(statuses); + expect(result).toEqual({level: 0, status: 'none'}); + }); +}); + +describe('getDependenciesFromPackageJson', () => { + it('extracts valid dependencies from package.json', () => { + const packageJson = { + dependencies: { + 'valid-package': '^1.0.0', + 'another-package': '~2.3.4' + }, + devDependencies: { + 'dev-package': '3.0.0' + } + }; + const deps = getDependenciesFromPackageJson(packageJson, ['prod']); + const devDeps = getDependenciesFromPackageJson(packageJson, ['dev']); + const allDeps = getDependenciesFromPackageJson(packageJson, [ + 'prod', + 'dev' + ]); + expect(deps).toEqual( + new Map([ + ['valid-package', '^1.0.0'], + ['another-package', '~2.3.4'] + ]) + ); + expect(devDeps).toEqual(new Map([['dev-package', '3.0.0']])); + expect(allDeps).toEqual( + new Map([ + ['valid-package', '^1.0.0'], + ['another-package', '~2.3.4'], + ['dev-package', '3.0.0'] + ]) + ); + }); + + it('ignores invalid dependencies', () => { + const packageJson = { + dependencies: { + 'valid-package': '^1.0.0', + 'invalid-package': 12345, + 'another-invalid': null + }, + devDependencies: { + 'dev-package': '3.0.0', + 'bad-dev-package': {} + } + }; + const deps = getDependenciesFromPackageJson(packageJson as never, [ + 'prod', + 'dev' + ]); + expect(deps).toEqual( + new Map([ + ['valid-package', '^1.0.0'], + ['dev-package', '3.0.0'] + ]) + ); + }); +});