Publish quixos-nix-helpers from 99248f90aba2ef64f089244a909159d432efb8af
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
''
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 "$@"
|
||||
'';
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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"
|
||||
''
|
||||
Reference in New Issue
Block a user