Publish quixos-nix-helpers from 99248f90aba2ef64f089244a909159d432efb8af

This commit is contained in:
Quixos Subtree Publisher
2026-10-29 12:30:56 +00:00
13 changed files with 451 additions and 27 deletions
+2 -2
View File
@@ -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"
}
+6
View File
@@ -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
+11 -1
View File
@@ -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
;
}
+25 -11
View File
@@ -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"
''
+80 -10
View File
@@ -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 };
+47 -2
View File
@@ -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() {
+44
View File
@@ -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;
}
+14
View File
@@ -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 "$@"
'';
}
+8
View File
@@ -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<string>;
export function prepareSocketParent(socket: string): Promise<string>;
export function removeEmptySocketDirectory(identity: unknown, options?: SocketOptions): Promise<void>;
+150
View File
@@ -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;
});
}
+31
View File
@@ -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
+9 -1
View File
@@ -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"
+24
View File
@@ -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"
''