UNPKG

nx

Version:

The core Nx plugin contains the core functionality of Nx like the project graph, nx commands and task orchestration.

363 lines (362 loc) 15.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.DirectoryRefusedError = void 0; exports.describeRefusal = describeRefusal; exports.remedyFor = remedyFor; exports.canonicalDir = canonicalDir; exports.isSafeSharedRoot = isSafeSharedRoot; exports.isPeerWritable = isPeerWritable; exports.ensureSafeSharedRoot = ensureSafeSharedRoot; exports.isOwnedRealDirectory = isOwnedRealDirectory; exports.getUserSegment = getUserSegment; exports.ensureOwnedPrivateDir = ensureOwnedPrivateDir; const node_fs_1 = require("node:fs"); const node_os_1 = require("node:os"); const node_path_1 = require("node:path"); // A string discriminant, not a boolean `ok`: this repo compiles with // `strict: false`, where TypeScript does not narrow a union on a boolean literal. const allow = (path) => ({ status: 'ok', path }); const deny = (refusal) => ({ status: 'refused', refusal, }); const notADirectory = (dir, stats) => stats.isSymbolicLink() ? { kind: 'not-a-directory', dir, symlink: true } : { kind: 'not-a-directory', dir }; // Four octal digits, so a sticky container reads `1777` and a plain directory // `0755` — the notation `chmod` and `ls` use. Prefixing a literal `0` instead // renders sticky modes as `01777`, which is not a form anyone writes. const asMode = (mode) => (mode & 0o7777).toString(8).padStart(4, '0'); /** The user-facing sentence for a refusal. The only place wording is decided. */ function describeRefusal(r) { switch (r.kind) { case 'not-created': return `${r.dir} could not be created${r.code ? ` (${r.code})` : ''}`; case 'not-inspectable': return `${r.dir} could not be inspected${r.code ? ` (${r.code})` : ''}`; case 'not-a-directory': return r.symlink ? `${r.dir} is a symlink, not a real directory — something replaced the path Nx expected to create` : `${r.dir} exists and is not a directory`; case 'foreign-owner': return `${r.dir} is owned by uid ${r.uid}, not by you`; case 'foreign-shared-container': return `${r.dir} belongs to another user (uid ${r.uid}) rather than to you or to root`; case 'not-tightenable': return `${r.dir} is reachable by other users (mode ${asMode(r.mode)}) and could not be tightened to 0700`; case 'peer-writable-not-sticky': return `${r.dir} is writable by other users but not sticky (mode ${asMode(r.mode)}), so a peer could replace directories inside it`; default: { // This repo sets `strict: false` and leaves `noImplicitReturns` unset, so // a new DirRefusal member would otherwise compile here and render as // `undefined` inside the aggregate message. Assignability to `never` still // holds under these settings, so this is a real compile-time guard. const unhandled = r; throw new Error(`Unhandled directory refusal: ${unhandled.kind}`); } } } /** Single-quoted for a shell, so a path with a space or quote survives a paste. */ const shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`; /** What the user can do about a refusal, or `undefined` when there is nothing. */ function remedyFor(r) { switch (r.kind) { case 'not-a-directory': return r.symlink ? `${r.dir} is a symlink where Nx expects a directory. If you did not create it, treat it as hostile: remove the link itself (not what it points at) and run the command again.` : undefined; case 'foreign-owner': return `${r.dir} belongs to another user on this machine, so Nx cannot keep its own directory there. Set NX_SOCKET_DIR to a short directory your user owns, or move it aside — which you can do yourself if you own the directory it sits in, and otherwise needs an administrator.`; case 'not-tightenable': // Both producers reach here having already established that the directory // is ours, so `chmod` is the user's to run. Names no cause: one producer // is a mount that discards the mode, the other is any `chmod` failure. return `${r.dir} is reachable by other users (mode ${asMode(r.mode)}) and Nx could not restrict it. Run \`chmod 0700 ${shellQuote(r.dir)}\` and try again; if the mode does not stick, set NX_SOCKET_DIR to a short directory on a filesystem that keeps POSIX permissions.`; case 'foreign-shared-container': // Unreachable today: `isSafeSharedRoot` denies with this kind only when // `stats.uid !== 0`, so a root-owned container never reaches here. if (r.uid === 0) { return undefined; } const q = shellQuote(r.dir); return `${r.dir} belongs to another user on this machine, so Nx cannot keep a private directory beneath it. Ask an administrator to hand it to root with \`sudo chown root ${q} && sudo chmod 1777 ${q}\`; every user can then keep their own directory under it.`; case 'not-created': case 'not-inspectable': case 'peer-writable-not-sticky': return undefined; default: { // Matches `describeRefusal`'s arm, so a new kind cannot ship with a // sentence and no advice — which is how `not-tightenable` went unadvised. const unhandled = r; return undefined; } } } /** One refusal as an `Error`, so several can travel in an `AggregateError`. */ class DirectoryRefusedError extends Error { constructor(refusal) { super(describeRefusal(refusal)); this.refusal = refusal; this.name = 'DirectoryRefusedError'; } } exports.DirectoryRefusedError = DirectoryRefusedError; /** * chmod a path only if it is a real directory, never following a symlink at its * final component — `chmodSync` follows them, retargeting the mode change. The * directory check is on the descriptor, not the errno: a deny-list fails *open* * on codes it does not know. `O_NONBLOCK` stops a planted FIFO blocking `openSync`. */ function chmodRealDirectory(path, mode) { let fd; try { fd = (0, node_fs_1.openSync)(path, node_fs_1.constants.O_RDONLY | node_fs_1.constants.O_NOFOLLOW | node_fs_1.constants.O_NONBLOCK); } catch { return false; } try { if (!(0, node_fs_1.fstatSync)(fd).isDirectory()) { return false; } (0, node_fs_1.fchmodSync)(fd, mode); return true; } catch { return false; } finally { (0, node_fs_1.closeSync)(fd); } } /** * The spelling to compare a directory by. `resolve` does not dereference * symlinks, and on macOS `/tmp` is a symlink to `/private/tmp`, so an * exact-match list would wave through an alias of a root it means to refuse. * * Resolves the longest ancestor that exists and re-appends the rest: Nx's own * roots are absent before its first run, and canonicalizing whole paths only * would degrade this to a string match on exactly a fresh machine. * * Only `ENOENT` walks up — any other errno means the path exists and cannot be * read through, which `ensureOwnedPrivateDir` cannot establish either. */ function canonicalDir(dir) { const resolved = (0, node_path_1.resolve)(dir); const missing = []; let candidate = resolved; // Terminates at the filesystem root, where `dirname` is a fixed point. while ((0, node_path_1.dirname)(candidate) !== candidate) { try { return (0, node_path_1.join)((0, node_fs_1.realpathSync)(candidate), ...missing); } catch (e) { if (e?.code !== 'ENOENT') { return resolved; } missing.unshift((0, node_path_1.basename)(candidate)); candidate = (0, node_path_1.dirname)(candidate); } } // The root itself: resolve it directly rather than walking past it. try { return (0, node_path_1.join)((0, node_fs_1.realpathSync)(candidate), ...missing); } catch { return resolved; } } /** * Sticky. Restricts rename and unlink in a writable directory to the owner of * each entry — plus the directory's own owner, which is why the ownership check * below is not redundant with this one. */ const S_ISVTX = 0o1000; /** * Whether a shared container is safe to keep an owner-only directory under. * * On POSIX, a container writable by other users must be sticky, and it must be * owned by either root or the current user. Windows short-circuits after the * directory test — the OS temp root is already scoped to one account, so there * is no shared level whose ownership could matter. * * Sticky directories still let the directory's own owner rename entries, so a * container owned by another unprivileged user could replace a previously * verified private directory beneath it. */ function isSafeSharedRoot(dir) { try { const stats = (0, node_fs_1.lstatSync)(dir); if (!stats.isDirectory()) { return deny(notADirectory(dir, stats)); } if (process.platform === 'win32') { // The OS temp root is already scoped to the current Windows user. return allow(dir); } if (typeof process.getuid === 'function' && stats.uid !== process.getuid() && stats.uid !== 0) { return deny({ kind: 'foreign-shared-container', dir, uid: stats.uid }); } return !(stats.mode & 0o022) || !!(stats.mode & S_ISVTX) ? allow(dir) : deny({ kind: 'peer-writable-not-sticky', dir, mode: stats.mode }); } catch (e) { return deny({ kind: 'not-inspectable', dir, code: e?.code }); } } /** * Whether other users on this machine can write into `dir`. Gates whether a * refusal message may cite other users, so it must not over-report: `false` on * Windows, where libuv synthesizes `st_mode` from the READONLY attribute and * reports an ordinary per-account directory as `0666`, and `false` on a path * that cannot be inspected. * * `statSync`, not `lstatSync`: the question is about the directory that will be * used, not the link pointing at it. Link modes mislead in both directions — * Linux creates symlinks `0777`, macOS applies the umask. Nothing here decides * whether a path is accepted, so following the link costs nothing the guards * do not already re-check. */ function isPeerWritable(dir) { if (process.platform === 'win32') { return false; } try { return !!((0, node_fs_1.statSync)(dir).mode & 0o022); } catch { return false; } } /** * Create a shared container as sticky + world-writable if it does not exist, * and report whether the resulting path is safe for the current user. * * **A container that already exists is never modified — only judged.** Do not * chmod before deciding trust: `CAP_FOWNER` (root's default in Docker and most * CI images) can chmod a directory it does not own, so a peer-owned root would * be widened to `1777` and then refused, and an operator who deliberately * tightened this root would have it re-widened on every `nx` process. */ function ensureSafeSharedRoot(dir) { if (process.platform === 'win32') { try { (0, node_fs_1.mkdirSync)(dir, { recursive: true }); return allow(dir); } catch (e) { return deny({ kind: 'not-created', dir, code: e?.code }); } } try { (0, node_fs_1.mkdirSync)(dir, { mode: 0o1777 }); // Ours, created a statement ago. Load-bearing on macOS: XNU strips S_ISVTX // at mkdir, so the sticky bit exists solely because of this call. chmodRealDirectory(dir, 0o1777); } catch (e) { if (e?.code !== 'EEXIST') { return deny({ kind: 'not-created', dir, code: e?.code }); } } // One verdict for both branches: the chmod above can fail and leave a // peer-writable non-sticky container, which trusting the creation would brand // safe. const verdict = isSafeSharedRoot(dir); return verdict.status === 'ok' ? allow(dir) : deny(verdict.refusal); } /** * Whether `dir` is an existing real directory owned by us. Unlike * `ensureOwnedPrivateDir` it creates nothing and repairs nothing — for callers * that only want to know whether a path is safe to act on, such as deleting. */ function isOwnedRealDirectory(dir) { try { const stats = (0, node_fs_1.lstatSync)(dir); if (!stats.isDirectory()) { return null; } return typeof process.getuid !== 'function' || stats.uid === process.getuid() ? dir : null; } catch { return null; } } /** The path segment separating one user's Nx runtime state from another's. */ function getUserSegment() { try { if (typeof process.getuid === 'function') { return String(process.getuid()); } } catch { } try { const { username } = (0, node_os_1.userInfo)(); if (username) { return username; } } catch { } return 'unknown'; } /** * Ensure `dir` exists, is a real directory owned by us, and carries no group or * other bits at all — read and search alone reach a socket inside it, so 0755 is * re-locked rather than accepted. A `refused` status carries which check said * no — usually `foreign-owner` for a directory another user planted, but also * `not-created`/`not-inspectable` for a filesystem error, `not-a-directory`, or * `not-tightenable` when the re-lock did not land. * * Node builtins only: reached from the native binding loader. */ function ensureOwnedPrivateDir(dir) { try { (0, node_fs_1.mkdirSync)(dir, { mode: 0o700 }); } catch (e) { if (e?.code !== 'EEXIST') { return deny({ kind: 'not-created', dir, code: e?.code }); } } // One verdict for both branches, as in `ensureSafeSharedRoot`: mounts that // ignore the mode argument can land a directory Nx asked for at 0700 on 0777, // so the creation path is judged like any other. try { const stats = (0, node_fs_1.lstatSync)(dir); // Before the Windows short-circuit: "is a real directory" holds on every // platform. if (!stats.isDirectory()) { return deny(notADirectory(dir, stats)); } if (typeof process.getuid !== 'function') { // Windows: the roots there are per-user OS temp dirs, not a shared /tmp. return allow(dir); } if (stats.uid !== process.getuid()) { return deny({ kind: 'foreign-owner', dir, uid: stats.uid }); } if (stats.mode & 0o077) { if (!chmodRealDirectory(dir, 0o700)) { return deny({ kind: 'not-tightenable', dir, mode: stats.mode }); } // Read the mode back rather than trusting the chmod's return: mounts that // ignore modes report success and change nothing. const after = (0, node_fs_1.lstatSync)(dir); if (after.mode & 0o077) { return deny({ kind: 'not-tightenable', dir, mode: after.mode }); } } return allow(dir); } catch (e) { return deny({ kind: 'not-inspectable', dir, code: e?.code }); } }