Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { afterAll, beforeAll, describe, expect, it } from "vitest";

import { lockedPackagesForEcosystem } from "../../src/security/multi-eco-lockfiles";

const ITERATIONS = 300;
const RNG_SEED = 1337;

// Deterministic Lehmer generator (MINSTD). Products stay below 2^53, so the sequence keeps its full period.
// A failing iteration replays from RNG_SEED and its index.
const createRandom = (seed: number): (() => number) => {
let state = seed;

return (): number => {
state = (state * 48_271) % 2_147_483_647;

return state / 2_147_483_647;
};
};

/* eslint-disable @stylistic/quotes -- sample lockfiles embed double quotes; prettier picks single quotes to avoid escapes */
const SAMPLES: Record<string, { ecosystem: string; sample: string }> = {
"Cargo.lock": {
ecosystem: "cargo",
sample: '[[package]]\nname = "serde"\nversion = "1.0.0"\n\n[[package]]\nname = "tokio"\nversion = "1.38.0"\n',
},
"composer.lock": {
ecosystem: "composer",
sample: '{"packages":[{"name":"a/b","version":"1.0.0"}],"packages-dev":[{"name":"c/d","version":"2.0.0"}]}',
},
"Gemfile.lock": {
ecosystem: "rubygems",
sample: "GEM\n remote: https://rubygems.org/\n specs:\n rake (13.0.1)\n\nPLATFORMS\n ruby\n",
},
"go.sum": {
ecosystem: "go",
sample: "github.com/a/b v1.2.3 h1:abc=\ngithub.com/a/b v1.2.3/go.mod h1:def=\n",
},
"gradle.lockfile": {
ecosystem: "maven",
sample: "# Generated by Gradle\norg.example:lib:1.2.3=compileClasspath,runtimeClasspath\nempty=annotationProcessor\n",
},
"mix.lock": {
ecosystem: "hex",
sample: '%{\n "jason": {:hex, :jason, "1.4.0", "hash", [:mix], [], "hexpm", "x"},\n}\n',
},
"Package.resolved": {
ecosystem: "swift",
sample: '{"pins":[{"identity":"a","location":"https://example.com/a.git","state":{"version":"1.0.0"}}],"version":2}',
},
"packages.lock.json": {
ecosystem: "nuget",
sample: '{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}',
},
"Pipfile.lock": {
ecosystem: "pypi",
sample: '{"default":{"requests":{"version":"==2.31.0"}},"develop":{"pytest":{"version":"==8.0.0"}}}',
},
"pom.xml": {
ecosystem: "maven",
sample: "<project><modelVersion>4.0.0</modelVersion><groupId>g</groupId><artifactId>a</artifactId><version>1</version><dependencies><dependency><groupId>org.x</groupId><artifactId>lib</artifactId><version>1.2.3</version></dependency></dependencies></project>",
},
"pubspec.lock": {
ecosystem: "pub",
sample: 'packages:\n path:\n dependency: "direct main"\n source: hosted\n version: "1.8.0"\nsdks:\n dart: ">=3.0.0 <4.0.0"\n',
},
"uv.lock": {
ecosystem: "pypi",
sample: '[[package]]\nname = "requests"\nversion = "2.31.0"\n',
},
};

/* eslint-enable @stylistic/quotes */

const ALPHABET = `{}[]()"'\\:,=#%\n\r\t -./0123456789abcdefghijklmnopqrstuvwxyz\u0000\uFFFD`;

// Mutates a valid sample: a few deletes and inserts with a rare truncation, or pure noise about one time in ten.
// Truncation stays rare because a truncated JSON sample fails at parse time and never reaches the post-parse code.
const mutate = (sample: string, random: () => number): string => {
if (random() < 0.1) {
return Array.from({ length: Math.floor(random() * 256) }, () => String.fromCodePoint(Math.floor(random() * 2048))).join("");
}

let out = sample;
const ops = 1 + Math.floor(random() * 4);

for (let i = 0; i < ops; i++) {
const position = Math.floor(random() * (out.length + 1));
const kind = random();

if (kind < 0.475) {
out = out.slice(0, position) + out.slice(position + 1);
} else if (kind < 0.95) {
out = out.slice(0, position) + ALPHABET.charAt(Math.floor(random() * ALPHABET.length)) + out.slice(position);
} else {
out = out.slice(0, position);
}
}

return out;
};

describe(lockedPackagesForEcosystem, () => {
let root: string;

beforeAll(() => {
root = mkdtempSync(join(tmpdir(), "vis-lockfile-fuzz-"));
});

afterAll(() => {
rmSync(root, { force: true, recursive: true });
});

it.each(Object.entries(SAMPLES))("parses the %s sample and survives mutated inputs without throwing", (file, { ecosystem, sample }) => {
expect.assertions(2);

const dir = mkdtempSync(join(root, "case-"));

// Guard against a sample that silently stops parsing, which would make the fuzz loop vacuous.
writeFileSync(join(dir, file), sample);

expect(lockedPackagesForEcosystem(dir, ecosystem).length).toBeGreaterThan(0);

const random = createRandom(RNG_SEED);
const failures: string[] = [];

for (let i = 0; i < ITERATIONS; i++) {
const input = mutate(sample, random);

writeFileSync(join(dir, file), input);

try {
const packages = lockedPackagesForEcosystem(dir, ecosystem);

if (
!packages.every((pkg) => typeof pkg.name === "string" && typeof pkg.version === "string" && pkg.name.length > 0 && pkg.version.length > 0)
) {
failures.push(`iteration ${i}: malformed entry for input ${JSON.stringify(input)}`);
}
} catch (error) {
failures.push(`iteration ${i}: ${String(error)} for input ${JSON.stringify(input)}`);
}
}

expect({ count: failures.length, first: failures.slice(0, 5) }).toStrictEqual({ count: 0, first: [] });
});
});
Loading