154 lines
7.0 KiB
JavaScript
154 lines
7.0 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import crypto from "node:crypto";
|
|
|
|
const KEY = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
const SKIPPED_DIRECTORIES = new Set(["node_modules", ".git"]);
|
|
const INSTALL_MARKER = ".dev-server-install";
|
|
|
|
export function assertInstanceKey(value) {
|
|
if (typeof value !== "string" || !KEY.test(value)) throw new Error("Invalid execution key");
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* The directory an execution's copy lives in. One path segment, derived from the key and never
|
|
* from anything the caller wrote, so there is no path to traverse out of.
|
|
*/
|
|
export function instanceDirectory(instancesRoot, key) {
|
|
return path.join(instancesRoot, assertInstanceKey(key));
|
|
}
|
|
|
|
/**
|
|
* A HOME of its own for each execution, which is how the caches of every toolchain but npm end up
|
|
* isolated without a line of per-toolchain code: Maven's ~/.m2 and pip's ~/.cache both follow HOME.
|
|
*
|
|
* <p>Maven matters most here. npm's shared cache is safe because `npm ci` verifies every package
|
|
* against the lockfile, so a tampered cache entry fails the install; Maven has no lockfile to
|
|
* verify against, so a shared local repository would be a channel from one execution into the
|
|
* next. It lives outside the copied tree so a refresh does not throw it away.
|
|
*
|
|
* <p>The leading dot is what keeps it from colliding with a copy: an execution key can never start
|
|
* with one.
|
|
*/
|
|
export function homeDirectory(instancesRoot, key) {
|
|
return path.join(instancesRoot, ".homes", assertInstanceKey(key));
|
|
}
|
|
|
|
function sourceDirectory(workspaceRoot, key) {
|
|
const root = fs.realpathSync(workspaceRoot);
|
|
const candidate = path.join(root, assertInstanceKey(key));
|
|
if (!fs.existsSync(candidate)) throw new Error(`No workspace has been written for this execution yet: ${key}`);
|
|
const real = fs.realpathSync(candidate);
|
|
const relative = path.relative(root, real);
|
|
// The execution's own directory is written by the coding agent, so it can be replaced by a
|
|
// symlink to somewhere else entirely. Refusing here is what keeps one execution out of another.
|
|
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Workspace for ${key} escapes the workspace root`);
|
|
if (!fs.statSync(real).isDirectory()) throw new Error(`Workspace for ${key} is not a directory`);
|
|
return real;
|
|
}
|
|
|
|
/**
|
|
* Copies one execution's tree, dropping every symlink and skipping node_modules and .git.
|
|
*
|
|
* <p>Symlinks are dropped rather than followed or preserved: the tree is untrusted input, and a
|
|
* link to another execution's directory would turn the copy - whose whole purpose is isolation -
|
|
* into a window onto it. A project that genuinely needs a symlink will notice its absence, which
|
|
* is a better failure than a silent hole in the boundary.
|
|
*
|
|
* <p>node_modules is skipped because the install recreates it, and copying 333 MB of it would be
|
|
* the slowest part of every start. .git is skipped because a dev server has no use for it.
|
|
*/
|
|
function copyTree(source, target, limits, counters = { entries: 0, bytes: 0 }) {
|
|
fs.mkdirSync(target, { recursive: true, mode: 0o755 });
|
|
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
if (entry.isSymbolicLink()) continue;
|
|
if (entry.isDirectory() && SKIPPED_DIRECTORIES.has(entry.name)) continue;
|
|
if (++counters.entries > limits.maxEntries) throw new Error(`Workspace exceeds ${limits.maxEntries} entries`);
|
|
const from = path.join(source, entry.name);
|
|
const to = path.join(target, entry.name);
|
|
if (entry.isDirectory()) {
|
|
copyTree(from, to, limits, counters);
|
|
} else if (entry.isFile()) {
|
|
const size = fs.statSync(from).size;
|
|
counters.bytes += size;
|
|
if (counters.bytes > limits.maxBytes) throw new Error(`Workspace exceeds ${limits.maxBytes} bytes`);
|
|
fs.copyFileSync(from, to);
|
|
}
|
|
// Sockets, fifos and devices are neither copied nor reported: they cannot be part of a
|
|
// project a dev server serves, and refusing the whole start over one would be worse.
|
|
}
|
|
return counters;
|
|
}
|
|
|
|
/** Everything in the copy except what the install produced, so a refresh keeps the slow part. */
|
|
function pruneProjectFiles(target) {
|
|
for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
|
|
if (entry.name === INSTALL_MARKER || (entry.isDirectory() && entry.name === "node_modules")) continue;
|
|
fs.rmSync(path.join(target, entry.name), { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Makes the execution's copy current with the workspace, and reports whether the dependency
|
|
* install still matches the lockfile it was run for.
|
|
*/
|
|
export function prepareInstanceWorkspace({ workspaceRoot, instancesRoot, key, limits }) {
|
|
const source = sourceDirectory(workspaceRoot, key);
|
|
const target = instanceDirectory(instancesRoot, key);
|
|
if (fs.existsSync(target)) pruneProjectFiles(target);
|
|
const counters = copyTree(source, target, {
|
|
maxEntries: limits?.maxEntries ?? 20000,
|
|
maxBytes: limits?.maxBytes ?? 512 * 1024 * 1024
|
|
});
|
|
const home = homeDirectory(instancesRoot, key);
|
|
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
return { source, target, home, ...counters, installIsCurrent: installIsCurrent(target) };
|
|
}
|
|
|
|
/**
|
|
* The files that decide what an install produces, across the toolchains the image carries.
|
|
*
|
|
* <p>A fingerprint of these is what makes a repeated start cheap. It deliberately does not look
|
|
* for node_modules as evidence of an install: a Maven or Python project never has one, and
|
|
* treating its absence as "not installed yet" would re-run every install forever.
|
|
*/
|
|
const DEPENDENCY_MANIFESTS = [
|
|
"package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml",
|
|
"pom.xml", "requirements.txt", "poetry.lock", "pyproject.toml", "Pipfile.lock"
|
|
];
|
|
|
|
function lockfileFingerprint(target) {
|
|
const hash = crypto.createHash("sha256");
|
|
let found = false;
|
|
for (const name of DEPENDENCY_MANIFESTS) {
|
|
const file = path.join(target, name);
|
|
if (fs.existsSync(file)) {
|
|
hash.update(name).update(fs.readFileSync(file));
|
|
found = true;
|
|
}
|
|
}
|
|
return found ? hash.digest("hex") : null;
|
|
}
|
|
|
|
function installIsCurrent(target) {
|
|
const marker = path.join(target, INSTALL_MARKER);
|
|
if (!fs.existsSync(marker)) return false;
|
|
const fingerprint = lockfileFingerprint(target);
|
|
// With no manifest there is nothing to compare, so the install can never be declared current:
|
|
// re-running it is slow, while serving stale dependencies is wrong.
|
|
return fingerprint !== null && fs.readFileSync(marker, "utf8").trim() === fingerprint;
|
|
}
|
|
|
|
export function recordInstall(target) {
|
|
const fingerprint = lockfileFingerprint(target);
|
|
if (fingerprint) fs.writeFileSync(path.join(target, INSTALL_MARKER), fingerprint);
|
|
}
|
|
|
|
export function removeInstanceWorkspace(instancesRoot, key) {
|
|
// Both, or the disk is never really reclaimed: a Maven repository outlives the tree that pulled
|
|
// it and is the larger half of what an execution leaves behind.
|
|
fs.rmSync(instanceDirectory(instancesRoot, key), { recursive: true, force: true });
|
|
fs.rmSync(homeDirectory(instancesRoot, key), { recursive: true, force: true });
|
|
}
|