diff --git a/.quixos-subtree-source.json b/.quixos-subtree-source.json index 6ba40e1..e7a97f3 100644 --- a/.quixos-subtree-source.json +++ b/.quixos-subtree-source.json @@ -1,8 +1,8 @@ { "version": 1, "sourceRepo": "https://gitea.internal.quixos.org/quixos/quixos.git", - "sourceCommit": "e4393946ba63cfef75580f6aa6ba840035e7d43c", + "sourceCommit": "99248f90aba2ef64f089244a909159d432efb8af", "sourcePath": "quixos-instance/quixos-nix-helpers", "exportName": "quixos-nix-helpers", - "mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git" + "mirrorRemote": "https://repos.quixos.org/quixos-toolchain/quixos-nix-helpers.git" } diff --git a/PORTABLE_ARTIFACTS.md b/PORTABLE_ARTIFACTS.md index 7348087..2e194bf 100644 --- a/PORTABLE_ARTIFACTS.md +++ b/PORTABLE_ARTIFACTS.md @@ -69,6 +69,12 @@ describes its install-state file as an optimization cache in its This helper output is a dependency directory for a build, not a standalone Package or a runnable Yarn project. Its `devShell` supplies freestanding Yarn for editable projects; do not run Yarn against the readonly dependency output. +Installation discards its disposable archive cache and streams dependencies into +the output while retiring staged files, including across separate build/store +mounts. It preserves package layout, native results, hidden data and executable +links without holding two complete dependency trees. The maintained +`camino-yarn-dependencies` check covers transfer and runtime layout; consuming +Studio and Package-builder checks retain real locked-installation coverage. Use the shared `installDependencies` assembly for consumers: authored libraries are copied into the consumer's `node_modules`, where Node can find their diff --git a/restricted-execution/default.nix b/restricted-execution/default.nix index c77c52d..a909585 100644 --- a/restricted-execution/default.nix +++ b/restricted-execution/default.nix @@ -41,6 +41,8 @@ let '' mkdir -p "$out/lib" "$out/bin" cp ${./service.mjs} "$out/lib/service.mjs" + cp ${../socket-address.mjs} "$out/lib/socket-address.mjs" + substituteInPlace "$out/lib/service.mjs" --replace-fail '../socket-address.mjs' './socket-address.mjs' cp ${./worker.mjs} "$out/lib/worker.mjs" makeWrapper ${pkgs.nodejs_24}/bin/node "$out/bin/quixos-restricted-registry" \ --add-flags "$out/lib/service.mjs" \ @@ -48,6 +50,13 @@ let --set QUIXOS_RESTRICTED_SANDBOX ${sandbox}/bin/quixos-restricted-sandbox \ --set QUIXOS_RESTRICTED_WORKER "$out/lib/worker.mjs" ''; + qualificationSource = pkgs.lib.fileset.toSource { + root = ../.; + fileset = pkgs.lib.fileset.unions [ + (pkgs.lib.fileset.fileFilter (file: file.hasExt "mjs") ./.) + ../socket-address.mjs + ]; + }; qualification = pkgs.runCommand "quixos-restricted-execution-qualification" { @@ -56,7 +65,7 @@ let QUIXOS_TEST_SDK = "${sdk}/core.mjs"; } '' - node --test ${./.}/service.test.mjs + node --test ${qualificationSource}/restricted-execution/service.test.mjs touch "$out" ''; in @@ -69,6 +78,7 @@ in nativeProbeSandbox sdk runtime + qualificationSource qualification ; } diff --git a/restricted-execution/identity-launcher.nix b/restricted-execution/identity-launcher.nix index 7cfdca1..f277cfc 100644 --- a/restricted-execution/identity-launcher.nix +++ b/restricted-execution/identity-launcher.nix @@ -12,17 +12,31 @@ assert builtins.match "[a-z][a-z0-9_-]{0,63}" authorityUser != null; assert builtins.match "/run/[a-zA-Z0-9_/-]+" engineDirectory != null; assert !(pkgs.lib.hasInfix ".." engineDirectory); assert !(pkgs.lib.hasSuffix "/" engineDirectory); -assert builtins.stringLength engineDirectory < 80; assert builtins.isInt firstUid && builtins.isInt identityCount; assert firstUid >= 610000 && firstUid + identityCount <= 2147483647; assert identityCount >= 1 && identityCount <= 64; -pkgs.runCommand "quixos-restricted-identity-launcher" { nativeBuildInputs = [ pkgs.stdenv.cc ]; } '' - mkdir -p "$out/bin" - $CC -O2 -Wall -Wextra -Werror ${./identity-launcher.c} \ - -DSANDBOX_EXECUTABLE='"${sandbox}/bin/quixos-restricted-sandbox"' \ - -DAUTHORITY_USER='"${authorityUser}"' \ - -DENGINE_DIRECTORY='"${engineDirectory}"' \ - -DENGINE_FIRST_UID=${toString firstUid} \ - -DENGINE_IDENTITY_COUNT=${toString identityCount} \ - -o "$out/bin/quixos-restricted-engine" -'' +pkgs.runCommand "quixos-restricted-identity-launcher" + { + nativeBuildInputs = [ + pkgs.stdenv.cc + pkgs.nodejs_24 + ]; + QUIXOS_RESTRICTED_ENGINE_DIRECTORY = engineDirectory; + } + '' + # Validate the complete generated filename with the shared UTF-8 policy, + # before compiling or installing a launcher for this configured /run root. + node --input-type=module <<'JS' + import { checkedSocketPath } from '${../socket-address.mjs}'; + for (const filename of ["broker.sock", "engine.sock"]) + checkedSocketPath(process.env.QUIXOS_RESTRICTED_ENGINE_DIRECTORY + "/qx-engine-XXXXXX/" + filename); + JS + mkdir -p "$out/bin" + $CC -O2 -Wall -Wextra -Werror ${./identity-launcher.c} \ + -DSANDBOX_EXECUTABLE='"${sandbox}/bin/quixos-restricted-sandbox"' \ + -DAUTHORITY_USER='"${authorityUser}"' \ + -DENGINE_DIRECTORY='"${engineDirectory}"' \ + -DENGINE_FIRST_UID=${toString firstUid} \ + -DENGINE_IDENTITY_COUNT=${toString identityCount} \ + -o "$out/bin/quixos-restricted-engine" + '' diff --git a/restricted-execution/service.mjs b/restricted-execution/service.mjs index 091b79e..bde930c 100644 --- a/restricted-execution/service.mjs +++ b/restricted-execution/service.mjs @@ -1,11 +1,18 @@ import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { createServer, request as httpRequest } from "node:http"; -import { mkdtemp, readFile, writeFile, rm, open } from "node:fs/promises"; +import { mkdtemp, readFile, writeFile, rm, open, lstat, readdir } from "node:fs/promises"; import { constants } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; +import { + checkedSocketPath, + socketDirectory, + prepareSocketDirectory, + removeEmptySocketDirectory, +} from "../socket-address.mjs"; + const abi = "quixos-transaction-ports-v1"; /** Reserved supervisor faults are not authored domain rejections. A graph * transaction must roll back without recording a permanent rejected outcome @@ -327,15 +334,57 @@ export async function serveRestrictedRegistry(config, io = process) { } await engine.exited; } + const sameDirectory = async (directory, owner) => { + const current = await lstat(directory); + if ( + !owner || + !current.isDirectory() || + current.dev !== owner.dev || + current.ino !== owner.ino || + current.uid !== owner.uid || + current.mode & 0o077 + ) + throw fixedError("RESTRICTED_START_FAILED"); + }; + // A bounded ordinary-engine parent is private to this admitted lifetime. + // Never close/unlink through a replacement directory or release its lease + // while the original namespace's ownership remains uncertain. + if (engine.socketParent) { + await sameDirectory(engine.socketParent, engine.socketParentOwner); + if (engine.directory) await sameDirectory(engine.directory, engine.directoryOwner); + if (engine.broker) { + const current = await lstat(engine.broker).catch((error) => { + if (error.code !== "ENOENT") throw error; + return null; + }); + if ( + current && + (!engine.brokerOwner || + !current.isSocket() || + current.dev !== engine.brokerOwner.dev || + current.ino !== engine.brokerOwner.ino) + ) + throw fixedError("RESTRICTED_START_FAILED"); + } + } engine.server?.closeAllConnections(); if (engine.server) await new Promise((resolve) => engine.server.close(resolve)); - try { - if (engine.directory) await rm(engine.directory, { recursive: true, force: true }); - } finally { - engines.delete(engine); - await engine.release?.(); - changed(); + if (engine.socketParent) { + await sameDirectory(engine.socketParent, engine.socketParentOwner); + if (engine.directory) await sameDirectory(engine.directory, engine.directoryOwner); } + if (engine.directory) await rm(engine.directory, { recursive: true, force: true }); + if (engine.socketParent) { + await removeEmptySocketDirectory(engine.socketIdentity); + const remaining = await lstat(engine.socketParent).catch((error) => { + if (error.code !== "ENOENT") throw error; + return null; + }); + if (remaining) throw fixedError("RESTRICTED_START_FAILED"); + } + engines.delete(engine); + await engine.release?.(); + changed(); })(); return engine.destroying; }; @@ -359,10 +408,27 @@ export async function serveRestrictedRegistry(config, io = process) { () => destroyEngine(engine), ); if (!call.open) return; - engine.directory = await mkdtemp(join(tmpdir(), "qx-engine-")); + let directoryRoot; + if (config.sandboxIdentity) { + // The privileged helper owns a fixed explicit /run root and requires + // this exact basename. Keep its cross-UID ancestry and identity rules. + directoryRoot = tmpdir(); + } else { + engine.socketIdentity = { kind: "restricted-engine", process: process.pid, lifetime: randomUUID() }; + directoryRoot = socketDirectory(engine.socketIdentity); + } + for (const filename of ["broker.sock", "engine.sock"]) + checkedSocketPath(directoryRoot + "/qx-engine-XXXXXX/" + filename); + if (!config.sandboxIdentity) { + engine.socketParent = await prepareSocketDirectory(engine.socketIdentity); + engine.socketParentOwner = await lstat(engine.socketParent); + if ((await readdir(engine.socketParent)).length) throw fixedError("RESTRICTED_START_FAILED"); + } + engine.directory = await mkdtemp(join(directoryRoot, "qx-engine-")); + if (engine.socketParent) engine.directoryOwner = await lstat(engine.directory); if (!call.open) return; - const broker = join(engine.directory, "broker.sock"); - engine.socket = join(engine.directory, "engine.sock"); + const broker = checkedSocketPath(join(engine.directory, "broker.sock")); + engine.socket = checkedSocketPath(join(engine.directory, "engine.sock")); engine.server = createServer(async (request, response) => { const call = engine.current; const message = call?.message; @@ -408,10 +474,14 @@ export async function serveRestrictedRegistry(config, io = process) { } }); + if (engine.socketParent) engine.broker = broker; await new Promise((resolve, reject) => { engine.server.once("error", reject); engine.server.listen(broker, resolve); }); + if (engine.socketParent) { + engine.brokerOwner = await lstat(broker); + } const modules = { "entry.mjs": { js: workerSource }, "package.mjs": { js: packageSource } }; for (const module of sdk) modules[module.name] = { js: module.source }; const setting = { registryExport: config.registryExport, exports: config.exports, maxBytes: limits.maxBytes }; diff --git a/restricted-execution/service.test.mjs b/restricted-execution/service.test.mjs index 5b6dc26..7ff2425 100644 --- a/restricted-execution/service.test.mjs +++ b/restricted-execution/service.test.mjs @@ -1,11 +1,12 @@ import test from "node:test"; import assert from "node:assert/strict"; import { PassThrough } from "node:stream"; -import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { mkdtemp, writeFile, rm, mkdir, readdir, lstat } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { serveRestrictedRegistry, RestrictedEngineBudget } from "./service.mjs"; +import { socketDirectory, checkedSocketPath } from "../socket-address.mjs"; const workerd = process.env.QUIXOS_TEST_WORKERD; if (!workerd) throw Error("Set QUIXOS_TEST_WORKERD to the pinned engine"); @@ -95,6 +96,50 @@ async function fixture(source, options = {}) { } const registry = (code) => `export const registry={exports:['test'],async invoke(request,channel){${code}}};`; +test("real engine sockets and retirement are independent of deep Unicode TMPDIR", async () => { + const root = await mkdtemp(join(tmpdir(), "restricted-deep-")); + const deep = join(root, "保留された状態".repeat(12)); + await mkdir(deep); + const oldTmp = process.env.TMPDIR; + const socketRoot = dirname(socketDirectory({ kind: "qualification-probe" })); + const names = async () => + readdir(socketRoot).catch((error) => { + if (error.code !== "ENOENT") throw error; + return []; + }); + const before = new Set(await names()); + let f, namespace; + process.env.TMPDIR = deep; + try { + assert.ok(Buffer.byteLength(join(deep, "qx-engine-XXXXXX", "engine.sock")) > 100); + f = await fixture(registry("return request.input;")); + f.start("deep runtime"); + const result = await f.receive(); + assert.equal(result.status, "returned"); + assert.equal(result.output, "deep runtime"); + assert.equal(f.stats().starts, 1); + const created = (await names()).filter((name) => !before.has(name)); + assert.equal(created.length, 1); + namespace = join(socketRoot, created[0]); + const engines = await readdir(namespace); + assert.equal(engines.length, 1); + for (const name of ["broker.sock", "engine.sock"]) { + const endpoint = checkedSocketPath(join(namespace, engines[0], name)); + assert.ok((await lstat(endpoint)).isSocket()); + } + } finally { + try { + await f?.close(); + } finally { + if (oldTmp === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = oldTmp; + } + } + await assert.rejects(lstat(namespace), { code: "ENOENT" }); + assert.ok((await lstat(deep)).isDirectory()); + await rm(root, { recursive: true, force: true }); +}); + test("uncertain OS cleanup cannot release engine capacity", async () => { const budget = new RestrictedEngineBudget(1, async () => ({ async release() { diff --git a/socket-address-cli.mjs b/socket-address-cli.mjs new file mode 100644 index 0000000..781935a --- /dev/null +++ b/socket-address-cli.mjs @@ -0,0 +1,44 @@ +// Language bridge to the shared socket policy; identities contain no credentials. +import { + checkedSocketPath, + prepareSocketDirectory, + removeEmptySocketDirectory, + socketAddress, +} from "./socket-address.mjs"; + +const [operation, role = "bootstrap"] = process.argv.slice(2); +if ( + !["check-path", "prepare", "prepare-directory", "address", "cleanup"].includes(operation) || + process.argv.length > 4 || + (process.argv.length === 4 && !["prepare", "address"].includes(operation)) +) + throw Error("Select a socket operation and an optional address role"); +const chunks = []; +let length = 0; +for await (const chunk of process.stdin) { + length += chunk.length; + if (length > 1024 * 1024) throw Error("Socket command input exceeds its bound"); + chunks.push(chunk); +} +const identity = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks))); +switch (operation) { + case "check-path": + console.log(checkedSocketPath(identity)); + break; + case "prepare": { + // Validate the role before creating any directories. + const address = socketAddress(identity, role); + await prepareSocketDirectory(identity); + console.log(address); + break; + } + case "prepare-directory": + console.log(await prepareSocketDirectory(identity)); + break; + case "address": + console.log(socketAddress(identity, role)); + break; + case "cleanup": + await removeEmptySocketDirectory(identity); + break; +} diff --git a/socket-address-cli.nix b/socket-address-cli.nix new file mode 100644 index 0000000..8371a88 --- /dev/null +++ b/socket-address-cli.nix @@ -0,0 +1,14 @@ +{ pkgs }: +let + code = pkgs.runCommand "quixos-socket-address-code" { } '' + mkdir "$out" + cp ${./socket-address-cli.mjs} "$out/socket-address-cli.mjs" + cp ${./socket-address.mjs} "$out/socket-address.mjs" + ''; +in +pkgs.writeShellApplication { + name = "quixos-socket-address"; + text = '' + exec ${pkgs.nodejs_24}/bin/node --max-old-space-size=64 ${code}/socket-address-cli.mjs "$@" + ''; +} diff --git a/socket-address.d.mts b/socket-address.d.mts new file mode 100644 index 0000000..34e5573 --- /dev/null +++ b/socket-address.d.mts @@ -0,0 +1,8 @@ +export type SocketOptions = { uid?: number; root?: string }; +export const socketPathBytes: number; +export function checkedSocketPath(value: unknown): string; +export function socketDirectory(identity: unknown, options?: SocketOptions): string; +export function socketAddress(identity: unknown, role: string, options?: SocketOptions): string; +export function prepareSocketDirectory(identity: unknown, options?: SocketOptions): Promise; +export function prepareSocketParent(socket: string): Promise; +export function removeEmptySocketDirectory(identity: unknown, options?: SocketOptions): Promise; diff --git a/socket-address.mjs b/socket-address.mjs new file mode 100644 index 0000000..fc1eb54 --- /dev/null +++ b/socket-address.mjs @@ -0,0 +1,150 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +export const socketPathBytes = 100; + +function canonical(value, ancestors = new Set()) { + if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); + if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value); + if (!value || typeof value !== "object" || ancestors.has(value) || ancestors.size >= 16) + throw Error("Socket identity must be finite, acyclic JSON"); + if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) + throw Error("Socket identity must use plain JSON objects"); + ancestors.add(value); + try { + if (Array.isArray(value)) { + if ( + Object.keys(value).length !== value.length || + !Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean) + ) + throw Error("Socket identity cannot contain sparse arrays"); + return `[${value.map((entry) => canonical(entry, ancestors)).join(",")}]`; + } + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonical(value[key], ancestors)}`) + .join(",")}}`; + } finally { + ancestors.delete(value); + } +} + +export function checkedSocketPath(value) { + if ( + typeof value !== "string" || + !path.isAbsolute(value) || + value.includes("\0") || + path.normalize(value) !== value || + Buffer.byteLength(value) > socketPathBytes + ) + throw Error(`Unix socket address must be absolute and at most ${socketPathBytes} bytes`); + return value; +} + +function settings(options = {}) { + const uid = options.uid ?? process.getuid?.(); + if (!Number.isInteger(uid) || uid < 0 || uid > 4294967295) + throw Error("Socket namespace requires an explicit Unix UID"); + const root = options.root ?? `/tmp/qx-${uid}`; + if ( + typeof root !== "string" || + !path.isAbsolute(root) || + path.normalize(root) !== root || + root.includes("\0") || + Buffer.byteLength(root) > 18 + ) + throw Error("Socket runtime root must be an explicit absolute path of at most18 bytes"); + return { uid, root }; +} + +/** Public deterministic locator, never a capability or ownership claim. The + * caller supplies its admitted owner/lifetime; guest input grants no authority. */ +export function socketDirectory(identity, options) { + const { uid, root } = settings(options); + const input = canonical(["quixos-unix-socket-v1", uid, identity]); + if (Buffer.byteLength(input) > 1024 * 1024) throw Error("Socket identity exceeds its bound"); + // Preserve all 256 bits while leaving room for protocol-added socket suffixes. + return path.join(root, createHash("sha256").update(input).digest("base64url")); +} + +export function socketAddress(identity, role, options) { + if (typeof role !== "string" || role !== role.trim() || !/^[a-z][a-z0-9-]{0,10}$/.test(role)) + throw Error("Invalid bounded socket role"); + return checkedSocketPath(path.join(socketDirectory(identity, options), role + ".sock")); +} + +async function privateDirectory(directory, uid) { + await fs.mkdir(directory, { mode: 0o700 }).catch((error) => { + if (error.code !== "EEXIST") throw error; + }); + const stat = await fs.lstat(directory); + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== uid || stat.mode & 0o077) + throw Error("Socket runtime directory must already be owned and private"); +} + +/** Validate ancestors before creating the private endpoint directory. A + * predictable name is safe only inside a filesystem namespace we actually own. */ +export async function prepareSocketParent(socket) { + const directory = path.dirname(checkedSocketPath(socket)), + uid = process.getuid?.(); + if (!Number.isInteger(uid)) throw Error("Socket namespace requires a Unix principal"); + let current = "/"; + const components = directory.split("/").filter(Boolean); + for (let index = 0; index < components.length; index++) { + current = path.join(current, components[index]); + await fs.mkdir(current, { mode: 0o700 }).catch((error) => { + if (error.code !== "EEXIST") throw error; + }); + let stat = await fs.lstat(current); + if (current === "/tmp" && stat.isSymbolicLink() && stat.uid === 0) stat = await fs.stat(current); // macOS's OS-owned /tmp alias. + const final = index === components.length - 1; + // The OS-provided sticky /tmp, like / itself, is a bootstrap trust anchor. + // Nix user namespaces can report its owner as an unmapped UID. This does + // not admit arbitrary foreign sticky directories or relax child ownership. + const systemTemporary = current === "/tmp" && Boolean(stat.mode & 0o1000); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + (!systemTemporary && ![0, uid].includes(stat.uid)) || + (stat.mode & 0o022 && !(stat.mode & 0o1000)) || + (final && (stat.uid !== uid || stat.mode & 0o077)) + ) + throw Error("Socket directory ancestry must be owned and protected"); + } + return directory; +} + +/** Materialize ordinary IPC directories only. No registry, claim, lock or + * socket unlink occurs here; existing transport ownership still controls bind. */ +export async function prepareSocketDirectory(identity, options) { + const { uid, root } = settings(options); + if (uid !== process.getuid?.()) throw Error("Cannot prepare another Unix principal's socket namespace"); + const directory = socketDirectory(identity, options); + await prepareSocketParent(path.join(root, "probe.sock")); + await privateDirectory(directory, uid); + return directory; +} + +export async function removeEmptySocketDirectory(identity, options) { + const { uid, root } = settings(options), + directory = socketDirectory(identity, options); + if (uid !== process.getuid?.()) throw Error("Cannot clean another Unix principal's socket namespace"); + const parent = await fs.lstat(root).catch((error) => { + if (error.code !== "ENOENT") throw error; + return null; + }); + if (!parent) return; + if (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== uid || parent.mode & 0o077) + throw Error("Socket cleanup parent ownership differs"); + const stat = await fs.lstat(directory).catch((error) => { + if (error.code !== "ENOENT") throw error; + return null; + }); + if (!stat) return; + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== uid || stat.mode & 0o077) + throw Error("Socket cleanup directory ownership differs"); + await fs.rmdir(directory).catch((error) => { + if (!["ENOTEMPTY", "ENOENT"].includes(error.code)) throw error; + }); +} diff --git a/test/yarn-dependencies/yarn-project.nix b/test/yarn-dependencies/yarn-project.nix new file mode 100644 index 0000000..da0c784 --- /dev/null +++ b/test/yarn-dependencies/yarn-project.nix @@ -0,0 +1,31 @@ +# Tiny generated-project interface fixture. Real offline Yarn installation +# remains covered by the consuming Studio and Package-builder checks. +{ stdenvNoCC, nodejs }: +{ src, overrideAttrs ? null }: +let + project = stdenvNoCC.mkDerivation { + name = "yarn-dependencies-transfer-fixture"; + inherit src; + configurePhase = '' + mkdir -p node_modules/fixture node_modules/.bin .yarn/cache + printf '%s\n' '{"main":"index.cjs"}' > node_modules/fixture/package.json + printf '%s\n' 'module.exports = {value: "retained dependency"};' > node_modules/fixture/index.cjs + ln node_modules/fixture/index.cjs node_modules/fixture/twin.cjs + ln -s fixture node_modules/alias + printf '%s\n' '#!${nodejs}/bin/node' 'console.log("linked executable");' > node_modules/fixture/cli.cjs + chmod +x node_modules/fixture/cli.cjs + ln -s ../fixture/cli.cjs node_modules/.bin/fixture + printf '%s\n' 'hidden dependency data' > node_modules/.fixture-data + printf '%s\n' 'disposable linker state' > node_modules/.yarn-state.yml + printf '%s\n' 'disposable archive' > .yarn/cache/fixture.zip + ''; + postInstall = '' + test ! -e node_modules + test ! -e .yarn/cache + test ! -e "$out/.yarn-state.yml" + test ! -e "$out/node_modules" + test "$(stat -c %i "$out/fixture/index.cjs")" = "$(stat -c %i "$out/fixture/twin.cjs")" + ''; + }; +in +if overrideAttrs == null then project else project.overrideAttrs overrideAttrs diff --git a/yarn-dependencies.nix b/yarn-dependencies.nix index 6ea4567..f3a389b 100644 --- a/yarn-dependencies.nix +++ b/yarn-dependencies.nix @@ -9,10 +9,18 @@ let overrideAttrs = _: { buildPhase = "runHook preBuild; runHook postBuild"; installPhase = '' + set -o pipefail runHook preInstall test -d node_modules + test ! -L node_modules mkdir -p "$out" - cp -a node_modules/. "$out/" + # Installation is finished: retire its disposable archive cache, then + # stream dependencies across the build/store mount boundary without + # retaining a second complete tree. This is the same installation + # policy used by Central's dependency derivations. + rm -rf .yarn/cache + ${pkgs.gnutar}/bin/tar --create --file=- --remove-files node_modules \ + | ${pkgs.gnutar}/bin/tar --extract --file=- --strip-components=1 --directory="$out" # Node resolves installed packages directly. This generated index is for # Yarn's linker, not a runtime input; do not export build-session state. rm -f "$out/.yarn-state.yml" diff --git a/yarn-dependencies.test.nix b/yarn-dependencies.test.nix new file mode 100644 index 0000000..8c84d4a --- /dev/null +++ b/yarn-dependencies.test.nix @@ -0,0 +1,24 @@ +{ pkgs }: +let + dependencies = import ./yarn-dependencies.nix { + inherit pkgs; + src = ./test/yarn-dependencies; + }; +in +pkgs.runCommand "quixos-yarn-dependencies-check" { nativeBuildInputs = [ pkgs.nodejs_24 ]; } '' + node - ${dependencies} <<'JS' + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + const path = require('node:path'); + const {execFileSync} = require('node:child_process'); + const root = process.argv[2]; + assert.deepEqual(require(path.join(root, 'fixture')), {value: 'retained dependency'}); + assert.deepEqual(require(path.join(root, 'alias')), {value: 'retained dependency'}); + assert.equal(fs.readlinkSync(path.join(root, 'alias')), 'fixture'); + assert.equal(fs.readlinkSync(path.join(root, '.bin/fixture')), '../fixture/cli.cjs'); + assert.equal(execFileSync(path.join(root, '.bin/fixture'), {encoding: 'utf8'}), 'linked executable\n'); + assert.equal(fs.readFileSync(path.join(root, '.fixture-data'), 'utf8'), 'hidden dependency data\n'); + assert.equal(fs.existsSync(path.join(root, '.yarn-state.yml')), false); + JS + touch "$out" +''