204 lines
7.6 KiB
JavaScript
204 lines
7.6 KiB
JavaScript
// vsix-sandbox.mjs — Spawn vsix-fetch-worker.mjs under an OS sandbox so any
|
|
// filesystem writes the ZIP extractor performs are restricted to a single
|
|
// temp directory.
|
|
//
|
|
// Reuses the same sandbox primitives proven by `git-clone.mjs`:
|
|
// - macOS: sandbox-exec with a deny-file-write profile + subpath allow
|
|
// - Linux: bwrap with --ro-bind / --bind / --unshare-all
|
|
// - Windows / fallback: spawn directly + WARN to stderr (no OS sandbox)
|
|
//
|
|
// Defense-in-depth: even if our own zip-extract.mjs has a zip-slip / symlink
|
|
// bypass we did not foresee, the OS will refuse the write. The in-process
|
|
// validation in zip-extract.mjs remains the first line of defense.
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { realpathSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, resolve as resolvePath } from 'node:path';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const DEFAULT_VSIX_WORKER_PATH = resolvePath(__dirname, 'vsix-fetch-worker.mjs');
|
|
const DEFAULT_JETBRAINS_WORKER_PATH = resolvePath(__dirname, 'jetbrains-fetch-worker.mjs');
|
|
// Backward-compat alias — older internal refs may still import `WORKER_PATH`.
|
|
const WORKER_PATH = DEFAULT_VSIX_WORKER_PATH;
|
|
const WORKER_TIMEOUT_MS = 35_000; // fetch is 30s, give worker 5s of slack
|
|
const MAX_OUTPUT_BYTES = 1024 * 1024; // 1MB JSON cap (output is tiny in practice)
|
|
|
|
/**
|
|
* Build the sandbox-exec profile that allows writes only to `allowedWritePath`.
|
|
* Mirrors `buildSandboxProfile` in git-clone.mjs.
|
|
* @returns {string|null} null if not on macOS or sandbox-exec missing
|
|
*/
|
|
export function buildSandboxProfile(allowedWritePath) {
|
|
if (process.platform !== 'darwin') return null;
|
|
const check = spawnSync('which', ['sandbox-exec'], { encoding: 'utf8' });
|
|
if (check.status !== 0) return null;
|
|
const realPath = realpathSync(allowedWritePath);
|
|
return [
|
|
'(version 1)',
|
|
'(allow default)',
|
|
'(deny file-write*)',
|
|
`(allow file-write* (subpath "${realPath}"))`,
|
|
'(allow file-write* (literal "/dev/null"))',
|
|
'(allow file-write* (literal "/dev/tty"))',
|
|
].join('');
|
|
}
|
|
|
|
/**
|
|
* Build bwrap arguments that allow writes only to `allowedWritePath`.
|
|
* Mirrors `buildBwrapArgs` in git-clone.mjs.
|
|
* @returns {string[]|null} null if not on Linux or bwrap unusable
|
|
*/
|
|
export function buildBwrapArgs(allowedWritePath, innerArgs) {
|
|
if (process.platform !== 'linux') return null;
|
|
const check = spawnSync('which', ['bwrap'], { encoding: 'utf8' });
|
|
if (check.status !== 0) return null;
|
|
// Probe — bwrap is shipped on Ubuntu 24.04+ but may need admin AppArmor config.
|
|
const probe = spawnSync(
|
|
'bwrap',
|
|
['--ro-bind', '/', '/', '--dev', '/dev', '/bin/true'],
|
|
{ stdio: 'ignore', timeout: 5000 },
|
|
);
|
|
if (probe.status !== 0) return null;
|
|
return [
|
|
'--ro-bind', '/', '/',
|
|
'--bind', allowedWritePath, allowedWritePath,
|
|
'--dev', '/dev',
|
|
'--unshare-all',
|
|
'--new-session',
|
|
'--die-with-parent',
|
|
...innerArgs,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Resolve the spawn command for a worker invocation. Returns the OS sandbox
|
|
* if available; otherwise plain `node` with a `sandbox: null` flag so the
|
|
* caller can WARN. Identical pattern to `buildSandboxedClone`.
|
|
*
|
|
* @param {string} tmpDir writable temp dir for the worker
|
|
* @param {string[]} workerArgs argv for the worker (after `node <worker>`)
|
|
* @param {string} [workerPath=DEFAULT_VSIX_WORKER_PATH] absolute path to the
|
|
* worker module. Defaults to the VSIX worker for backward compat; Step 12
|
|
* passes `DEFAULT_JETBRAINS_WORKER_PATH` for JetBrains plugin fetches.
|
|
* @returns {{cmd:string, args:string[], sandbox: 'sandbox-exec'|'bwrap'|null}}
|
|
*/
|
|
export function buildSandboxedWorker(tmpDir, workerArgs, workerPath = DEFAULT_VSIX_WORKER_PATH) {
|
|
const innerArgs = ['node', workerPath, ...workerArgs];
|
|
|
|
const profile = buildSandboxProfile(tmpDir);
|
|
if (profile) {
|
|
return { cmd: 'sandbox-exec', args: ['-p', profile, ...innerArgs], sandbox: 'sandbox-exec' };
|
|
}
|
|
|
|
const bwrapArgs = buildBwrapArgs(tmpDir, innerArgs);
|
|
if (bwrapArgs) {
|
|
return { cmd: 'bwrap', args: bwrapArgs, sandbox: 'bwrap' };
|
|
}
|
|
|
|
// Fallback — Windows or sandbox tools missing. Worker still runs, just not boxed.
|
|
return { cmd: innerArgs[0], args: innerArgs.slice(1), sandbox: null };
|
|
}
|
|
|
|
/**
|
|
* Spawn the VSIX worker and parse its single JSON line of output.
|
|
*
|
|
* @param {string} url URL to fetch (passed via argv to worker)
|
|
* @param {string} tmpDir writable directory for extracted files
|
|
* @param {object} [opts]
|
|
* @param {boolean} [opts.allowFallback=true] if false, throw when no OS sandbox
|
|
* @returns {Promise<{ok:boolean, sandbox:'sandbox-exec'|'bwrap'|null, payload:object}>}
|
|
*/
|
|
export function runVsixWorker(url, tmpDir, opts = {}) {
|
|
return runPluginWorker(
|
|
DEFAULT_VSIX_WORKER_PATH,
|
|
['--url', url, '--tmpdir', tmpDir],
|
|
tmpDir,
|
|
opts,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Generalized sandboxed-worker runner. Spawns any worker module, captures a
|
|
* single JSON line of stdout, enforces the same timeout / output cap as
|
|
* `runVsixWorker`. Step 12 uses this for the JetBrains worker — it must NOT
|
|
* duplicate spawn/parse logic.
|
|
*
|
|
* @param {string} workerPath absolute path to the worker .mjs file
|
|
* @param {string[]} workerArgs argv for the worker
|
|
* @param {string} tmpDir writable temp dir for the worker
|
|
* @param {{allowFallback?: boolean}} [opts]
|
|
* @returns {Promise<{ok:boolean, sandbox:'sandbox-exec'|'bwrap'|null, payload:object}>}
|
|
*/
|
|
export function runPluginWorker(workerPath, workerArgs, tmpDir, opts = {}) {
|
|
const { allowFallback = true } = opts;
|
|
const { cmd, args, sandbox } = buildSandboxedWorker(tmpDir, workerArgs, workerPath);
|
|
|
|
if (!sandbox && !allowFallback) {
|
|
return Promise.reject(new Error('no OS sandbox available and fallback disabled'));
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(cmd, args, {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: WORKER_TIMEOUT_MS,
|
|
env: { ...process.env },
|
|
});
|
|
|
|
let stdout = '';
|
|
let stdoutBytes = 0;
|
|
let truncated = false;
|
|
child.stdout.on('data', (chunk) => {
|
|
stdoutBytes += chunk.length;
|
|
if (stdoutBytes > MAX_OUTPUT_BYTES) {
|
|
truncated = true;
|
|
try { child.kill('SIGKILL'); } catch {}
|
|
return;
|
|
}
|
|
stdout += chunk.toString('utf8');
|
|
});
|
|
|
|
let stderr = '';
|
|
child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
|
|
|
|
child.on('error', (err) => reject(new Error(`worker spawn failed: ${err.message}`)));
|
|
|
|
child.on('close', (code, signal) => {
|
|
if (truncated) {
|
|
return reject(new Error('worker output exceeded 1MB cap'));
|
|
}
|
|
if (signal === 'SIGTERM' && code === null) {
|
|
return reject(new Error(`worker timed out after ${WORKER_TIMEOUT_MS}ms`));
|
|
}
|
|
// Parse the last non-empty line as JSON (worker writes one line on success/failure).
|
|
const lines = stdout.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
const last = lines[lines.length - 1];
|
|
if (!last) {
|
|
const tail = stderr.trim().slice(0, 200);
|
|
return reject(new Error(`worker produced no output${tail ? ` (stderr: ${tail})` : ''}`));
|
|
}
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(last);
|
|
} catch {
|
|
return reject(new Error(`worker emitted non-JSON: ${last.slice(0, 120)}`));
|
|
}
|
|
resolve({ ok: payload.ok === true, sandbox, payload });
|
|
});
|
|
});
|
|
}
|
|
|
|
export {
|
|
DEFAULT_VSIX_WORKER_PATH,
|
|
DEFAULT_JETBRAINS_WORKER_PATH,
|
|
};
|
|
|
|
export const __testing = {
|
|
WORKER_PATH,
|
|
DEFAULT_VSIX_WORKER_PATH,
|
|
DEFAULT_JETBRAINS_WORKER_PATH,
|
|
WORKER_TIMEOUT_MS,
|
|
MAX_OUTPUT_BYTES,
|
|
resolveWorkerPath: (name) => resolvePath(__dirname, name),
|
|
};
|