Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions src/detect.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,25 +61,35 @@ function tryParseYarnClassic(content) {

const result = parseYarnClassic(content);
// Must parse successfully and NOT have __metadata (that's berry)
// Must have at least one package entry (not empty object)
const isValidResult = result.type === 'success' || result.type === 'merge';
const hasEntries = result.object && Object.keys(result.object).length > 0;
const notBerry = !('__metadata' in result.object);
const hasObject = result.object && typeof result.object === 'object';
const notBerry = hasObject && !('__metadata' in result.object);
// Must have entries OR start with the yarn lockfile header comment
const hasEntries = hasObject && Object.keys(result.object).length > 0;
const firstLines = content.split('\n', 5).join('\n');
const hasYarnHeader = /^# yarn lockfile v1/m.test(firstLines);

return isValidResult && hasEntries && notBerry;
return isValidResult && hasObject && notBerry && (hasEntries || hasYarnHeader);
} catch {
return false;
}
}

/**
* Try to parse content as pnpm lockfile
* Try to parse content as pnpm lockfile.
*
* Only parses the YAML header (first 20 lines) to check for lockfileVersion.
* This avoids failures on truncated lockfiles where the body is incomplete
* but the header is valid.
*
* @param {string} content
* @returns {boolean}
*/
function tryParsePnpm(content) {
try {
const parsed = yaml.load(content);
// Parse only the header to tolerate truncated lockfiles
const header = content.split('\n', 20).join('\n');
const parsed = yaml.load(header);
// Must have lockfileVersion at root and NOT have __metadata
// biome-ignore format: preserve multiline logical expression
return !!(parsed
Expand Down
3 changes: 2 additions & 1 deletion src/parsers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export {
buildWorkspacePackages as buildPnpmWorkspacePackages,
extractWorkspacePaths as extractPnpmWorkspacePaths,
fromPnpmLock,
parseLockfileKey as parsePnpmKey
parseLockfileKey as parsePnpmKey,
parsePnpmYaml
} from './pnpm.js';
export {
buildWorkspacePackages as buildYarnBerryWorkspacePackages,
Expand Down
36 changes: 33 additions & 3 deletions src/parsers/pnpm/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,36 @@ export function parseLockfileKey(key) {
return parseSpec(key).name;
}

/**
* Parse pnpm YAML content, tolerating truncated files.
*
* pnpm lockfiles use inline flow collections like `{integrity: sha512-...}`
* which cause js-yaml to throw if the file is truncated mid-entry. When that
* happens, we progressively trim trailing lines until parsing succeeds.
*
* @param {string} content - YAML content
* @returns {Record<string, any>} Parsed lockfile object
*/
export function parsePnpmYaml(content) {
try {
return /** @type {Record<string, any>} */ (yaml.load(content));
} catch {
// Truncated file — trim lines from the end until yaml.load succeeds.
// Most truncations break an incomplete flow collection near the end,
// so we only need to trim a handful of lines.
const lines = content.split('\n');
for (let trim = 1; trim < Math.min(20, lines.length); trim++) {
try {
return /** @type {Record<string, any>} */ (yaml.load(lines.slice(0, -trim).join('\n')));
} catch {
// keep trimming
}
}
// If trimming didn't help, re-throw the original error
throw yaml.load(content);
}
}

/**
* Parse pnpm lockfile (shrinkwrap.yaml, pnpm-lock.yaml v5.x, v6, v9)
*
Expand All @@ -211,7 +241,7 @@ export function parseLockfileKey(key) {
*/
export function* fromPnpmLock(input, _options = {}) {
const lockfile = /** @type {Record<string, any>} */ (
typeof input === 'string' ? yaml.load(input) : input
typeof input === 'string' ? parsePnpmYaml(input) : input
);

// Detect version to determine where to look for packages
Expand Down Expand Up @@ -250,7 +280,7 @@ export function* fromPnpmLock(input, _options = {}) {
if (seen.has(key)) continue;
seen.add(key);

const resolution = pkg.resolution || {};
const resolution = pkg?.resolution || {};
const integrity = resolution.integrity;
const resolved = resolution.tarball;
const link = spec.startsWith('link:') || resolution.type === 'directory';
Expand Down Expand Up @@ -315,7 +345,7 @@ export function* fromPnpmLock(input, _options = {}) {
*/
export function extractWorkspacePaths(input) {
const lockfile = /** @type {Record<string, any>} */ (
typeof input === 'string' ? yaml.load(input) : input
typeof input === 'string' ? parsePnpmYaml(input) : input
);

const importers = lockfile.importers || {};
Expand Down
4 changes: 2 additions & 2 deletions src/set.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { readFile } from 'node:fs/promises';
import { parseSyml } from '@yarnpkg/parsers';
import yaml from 'js-yaml';
import { detectType, Type } from './detect.js';
import {
buildNpmWorkspacePackages,
Expand All @@ -13,6 +12,7 @@ import {
fromPnpmLock,
fromYarnBerryLock,
fromYarnClassicLock,
parsePnpmYaml,
parseYarnBerryKey,
parseYarnClassic,
parseYarnClassicKey
Expand Down Expand Up @@ -195,7 +195,7 @@ export class FlatlockSet {
}
case Type.PNPM: {
/** @type {any} */
const lockfile = yaml.load(content);
const lockfile = parsePnpmYaml(content);
packages = lockfile.packages || {};
importers = lockfile.importers || null;
snapshots = lockfile.snapshots || null;
Expand Down
97 changes: 97 additions & 0 deletions test/lockfile.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -635,4 +635,101 @@ packages:
assert.ok(deps.length > 0);
});
});

describe('Edge cases: truncated and empty lockfiles', () => {
test('detects and parses a truncated pnpm v6 lockfile', () => {
// Synthetic pnpm v6 lockfile truncated mid-flow-collection.
// The last entry's {integrity: ...} is cut off without a closing brace,
// which causes js-yaml to throw "unexpected end of the stream within
// a flow collection" on the full content.
const content = [
"lockfileVersion: '6.0'",
'',
'settings:',
' autoInstallPeers: true',
' excludeLinksFromLockfile: false',
'',
'dependencies:',
' express:',
' specifier: ^4.18.0',
' version: 4.21.2',
'',
'packages:',
'',
' /accepts@1.3.8:',
' resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}',
' dependencies:',
' mime-types: 2.1.35',
' negotiator: 0.6.4',
'',
' /body-parser@1.20.3:',
' resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==}',
' dependencies:',
' bytes: 3.1.2',
' depd: 2.0.0',
' destroy: 1.2.0',
' http-errors: 2.0.0',
' iconv-lite: 0.4.24',
' on-finished: 2.4.1',
' qs: 6.13.0',
' raw-body: 2.5.2',
' type-is: 1.6.18',
' unpipe: 1.0.0',
'',
' /content-disposition@0.5.4:',
' resolution: {integrity: sha512-FKmjBYHLd5aDqhbG',
// truncated here — no closing brace
''
].join('\n');

// Detection should succeed
const type = flatlock.detectType({ content });
assert.equal(type, flatlock.Type.PNPM);

// Parsing should succeed, recovering the complete entries
const deps = [...flatlock.fromString(content)];
assert.ok(deps.length >= 2, `Expected at least 2 deps, got ${deps.length}`);

const names = deps.map(d => d.name);
assert.ok(names.includes('accepts'), 'Should include accepts');
assert.ok(names.includes('body-parser'), 'Should include body-parser');

// The truncated entry (content-disposition) may or may not appear
// depending on how many lines get trimmed — but we should not throw
});

test('detects and parses an empty yarn.lock (header only)', () => {
const content = [
'# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.',
'# yarn lockfile v1',
'',
''
].join('\n');

// Detection should succeed
const type = flatlock.detectType({ content });
assert.equal(type, flatlock.Type.YARN_CLASSIC);

// Parsing should succeed with zero dependencies
const deps = [...flatlock.fromString(content)];
assert.equal(deps.length, 0);
});

test('detects empty yarn.lock with only the version header', () => {
const content = '# yarn lockfile v1\n\n';

const type = flatlock.detectType({ content });
assert.equal(type, flatlock.Type.YARN_CLASSIC);

const deps = [...flatlock.fromString(content)];
assert.equal(deps.length, 0);
});

test('rejects empty file that is not a yarn.lock', () => {
// An empty string or whitespace-only content should still throw
assert.throws(() => {
flatlock.detectType({ content: '\n\n' });
}, /Unable to detect lockfile type/);
});
});
});
Loading