Compare commits
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos",
|
||||
"sourceCommit": "98cd7bc237027368fd2c77f294f59281ef3df160",
|
||||
"sourceRepo": "https://gitea.internal.quixos.org/quixos/quixos.git",
|
||||
"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"
|
||||
}
|
||||
|
||||
+40
-1
@@ -1,5 +1,11 @@
|
||||
# Checked capability and component artifacts
|
||||
|
||||
This is the **current helper/output format**, not the core package abstraction.
|
||||
See [the architecture](../../docs/ARCHITECTURE.md) and
|
||||
[current gaps](../../docs/ARCHITECTURE_STATUS.md). In particular, the universal
|
||||
artifact inventory and component-only omission of a server are implementation
|
||||
gaps to resolve, not requirements for arbitrary package servers.
|
||||
|
||||
`mkCaminoArtifacts` is the fresh package builder. It regenerates candidate
|
||||
bindings, typechecks the entry's dependency closure, bundles declared targets,
|
||||
generates a process service when requested, and emits content manifests and
|
||||
@@ -55,6 +61,31 @@ with their own versions. Authored source symlinks and non-bundled portable impor
|
||||
other than the SDK and server Node builtins are rejected. This is build closure
|
||||
checking, not a purity proof or security sandbox.
|
||||
|
||||
`mkCaminoYarnDependencies` installs the locked dependency recipe and exports its
|
||||
installed Node dependency tree, including native package results and executable
|
||||
links. Generated Yarn install/linker state stays outside runtime outputs. Yarn
|
||||
describes its install-state file as an optimization cache in its
|
||||
[configuration reference](https://yarnpkg.com/configuration/yarnrc#installStatePath).
|
||||
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
|
||||
transitive dependencies, while selected SDK links retain their canonical type
|
||||
identity. Reserved SDK substitutions fail checks. Web Studio uses this same
|
||||
assembly rather than retaining a full Yarn project just to resolve UI libraries.
|
||||
Its dependency inputs contain manifests, lock and Yarn configuration/plugin
|
||||
recipe; ordinary Studio source edits do not enter that dependency derivation.
|
||||
Recipes with local/workspace dependencies must still include their needed source.
|
||||
This is language-specific build machinery; it adds no core Package file contract.
|
||||
|
||||
The core/Automerge bundle and Replicache bundle are built once. Browser modules
|
||||
import the core through a content-addressed gateway URL, independent of package
|
||||
identity. Each manifest covers the shared bytes it uses. The gateway retains its
|
||||
@@ -105,7 +136,7 @@ and supplies an explicit SDK for value codecs, identities and declared graph por
|
||||
service = {
|
||||
restricted = true;
|
||||
limits = {
|
||||
deadlineMs = 4000;
|
||||
deadlineMs = 10000;
|
||||
maxBytes = 1048576;
|
||||
maxPortCalls = 1000;
|
||||
maxQueued = 8;
|
||||
@@ -122,6 +153,14 @@ invocation identity; authored code cannot choose another frame or workspace.
|
||||
Native graph grants and transaction commit remain enforced by Camino outside
|
||||
the guest. Initialization and synchronous loops are subject to the same deadline.
|
||||
|
||||
The default root/Package Invocation wall deadline is 10 seconds. The separate
|
||||
root CPU allowance remains at most 4 CPU seconds; extra wall time accommodates
|
||||
queueing and graph-port waits. Ordinary graph database transactions and idle
|
||||
transactions allow 30 seconds, and ordinary client HTTP requests allow 45 seconds
|
||||
so server failures can be returned. Nested calls inherit the remaining root
|
||||
budget; explicit smaller limits still apply. Restore/activation jobs retain their
|
||||
separate budgets. These defaults are starting limits, not latency targets.
|
||||
|
||||
Direct network access, native filesystem imports, ambient clock and entropy,
|
||||
parent environment credentials, and unlisted modules are denied. Explicit dates,
|
||||
`Intl.Segmenter`, deterministic hashes and normal JavaScript computation remain
|
||||
|
||||
@@ -20,17 +20,9 @@ let
|
||||
runtime = nativeProbe;
|
||||
executable = "${nativeProbe}/bin/probe";
|
||||
};
|
||||
identityLauncher =
|
||||
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"' \
|
||||
-o "$out/bin/quixos-restricted-engine"
|
||||
'';
|
||||
mkIdentityLauncher =
|
||||
settings: import ./identity-launcher.nix ({ inherit pkgs sandbox; } // settings);
|
||||
identityLauncher = mkIdentityLauncher { };
|
||||
# Transaction-port/value adapters do not need the browser's CRDT/Wasm engine.
|
||||
# Keep this surface explicit; it is bundled into checked restricted handlers.
|
||||
sdk = pkgs.runCommand "quixos-restricted-sdk" { nativeBuildInputs = [ pkgs.esbuild ]; } ''
|
||||
@@ -49,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" \
|
||||
@@ -56,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"
|
||||
{
|
||||
@@ -64,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
|
||||
@@ -73,9 +74,11 @@ in
|
||||
workerd
|
||||
sandbox
|
||||
identityLauncher
|
||||
mkIdentityLauncher
|
||||
nativeProbeSandbox
|
||||
sdk
|
||||
runtime
|
||||
qualificationSource
|
||||
qualification
|
||||
;
|
||||
}
|
||||
|
||||
@@ -20,17 +20,17 @@ static void fail(const char *message) {
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const char *root = "/run/quixos-restricted-engines/";
|
||||
const char *root = ENGINE_DIRECTORY "/";
|
||||
uid_t caller = getuid();
|
||||
gid_t group = getgid();
|
||||
struct passwd *owner = getpwnam("quixos-graph");
|
||||
struct passwd *owner = getpwnam(AUTHORITY_USER);
|
||||
if (geteuid() != 0) fail("privileged wrapper unavailable");
|
||||
if (!owner) fail("authority identity unavailable");
|
||||
if (caller != owner->pw_uid) fail("caller identity mismatch");
|
||||
if (argc != 3) fail("invalid arguments");
|
||||
char *end;
|
||||
unsigned long number = strtoul(argv[2], &end, 10);
|
||||
if (!*argv[2] || *end || number < 610000 || number >= 610064)
|
||||
if (!*argv[2] || *end || number < ENGINE_FIRST_UID || number >= ENGINE_FIRST_UID + ENGINE_IDENTITY_COUNT)
|
||||
fail("invalid engine identity");
|
||||
uid_t identity = (uid_t)number;
|
||||
if (strlen(argv[1]) != strlen(root) + 16 || strncmp(argv[1], root, strlen(root)))
|
||||
@@ -39,7 +39,7 @@ int main(int argc, char **argv) {
|
||||
if (strncmp(name, "qx-engine-", 10) ||
|
||||
strlen(name) != 16 || strspn(name + 10, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") != 6)
|
||||
fail("invalid engine directory");
|
||||
int parent = open("/run/quixos-restricted-engines", O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
|
||||
int parent = open(ENGINE_DIRECTORY, O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
|
||||
struct stat info;
|
||||
if (parent < 0 || fstat(parent, &info) || info.st_uid != caller || (info.st_mode & 066))
|
||||
fail("invalid engine root");
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Trusted host construction only. Authored Workers cannot choose the caller,
|
||||
# reserved identities, engine directory or sandbox executable.
|
||||
{
|
||||
pkgs,
|
||||
sandbox,
|
||||
authorityUser ? "quixos-graph",
|
||||
engineDirectory ? "/run/quixos-restricted-engines",
|
||||
firstUid ? 610000,
|
||||
identityCount ? 64,
|
||||
}:
|
||||
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.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
|
||||
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"
|
||||
''
|
||||
@@ -2,23 +2,45 @@
|
||||
executor,
|
||||
enable,
|
||||
controlGroup,
|
||||
authorityUser ? "quixos-graph",
|
||||
engineDirectory ? "/run/quixos-restricted-engines",
|
||||
firstUid ? 610000,
|
||||
identityCount ? 64,
|
||||
wrapperName ? "quixos-restricted-engine",
|
||||
}:
|
||||
{ config, lib, ... }:
|
||||
{
|
||||
config = lib.mkIf enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = lib.all (user: user.uid == null || user.uid < 610000 || user.uid >= 610064) (
|
||||
builtins.attrValues config.users.users
|
||||
);
|
||||
message = "UIDs 610000–610063 are reserved for restricted engine identities.";
|
||||
assertion = lib.all (
|
||||
user: user.uid == null || user.uid < firstUid || user.uid >= firstUid + identityCount
|
||||
) (builtins.attrValues config.users.users);
|
||||
message = "UIDs ${toString firstUid}–${
|
||||
toString (firstUid + identityCount - 1)
|
||||
} are reserved for restricted engine identities.";
|
||||
}
|
||||
{
|
||||
assertion = lib.all (
|
||||
group: group.gid == null || group.gid < firstUid || group.gid >= firstUid + identityCount
|
||||
) (builtins.attrValues config.users.groups);
|
||||
message = "Restricted engine GIDs must not overlap host groups.";
|
||||
}
|
||||
];
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /run/quixos-restricted-engines 0711 quixos-graph ${controlGroup} -"
|
||||
"d ${engineDirectory} 0711 ${authorityUser} ${controlGroup} -"
|
||||
];
|
||||
security.wrappers.quixos-restricted-engine = {
|
||||
source = "${executor.identityLauncher}/bin/quixos-restricted-engine";
|
||||
security.wrappers.${wrapperName} = {
|
||||
source = "${
|
||||
executor.mkIdentityLauncher {
|
||||
inherit
|
||||
authorityUser
|
||||
engineDirectory
|
||||
firstUid
|
||||
identityCount
|
||||
;
|
||||
}
|
||||
}/bin/quixos-restricted-engine";
|
||||
owner = "root";
|
||||
group = controlGroup;
|
||||
permissions = "u+rx,g+rx,o-rwx";
|
||||
|
||||
@@ -1,12 +1,45 @@
|
||||
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
|
||||
* when its executor dies, exceeds its limits or cannot start. */
|
||||
export const restrictedExecutionFailureCodes = Object.freeze([
|
||||
"RESTRICTED_ABI_MISMATCH",
|
||||
"RESTRICTED_BACKPRESSURE",
|
||||
"RESTRICTED_CPU_ACCOUNTING",
|
||||
"RESTRICTED_DEADLINE",
|
||||
"RESTRICTED_DIAGNOSTIC_LIMIT",
|
||||
"RESTRICTED_DUPLICATE_INVOCATION",
|
||||
"RESTRICTED_ENGINE_EXITED",
|
||||
"RESTRICTED_ENGINE_LIMIT",
|
||||
"RESTRICTED_ENTRY_LIMIT",
|
||||
"RESTRICTED_IDENTITY_UNAVAILABLE",
|
||||
"RESTRICTED_MESSAGE_LIMIT",
|
||||
"RESTRICTED_PACKAGE_FAILED",
|
||||
"RESTRICTED_PORT_FAILED",
|
||||
"RESTRICTED_PORT_LIMIT",
|
||||
"RESTRICTED_PROCESS_LIMIT",
|
||||
"RESTRICTED_QUEUE_FULL",
|
||||
"RESTRICTED_SDK_LIMIT",
|
||||
"RESTRICTED_SOURCE_LIMIT",
|
||||
"RESTRICTED_START_FAILED",
|
||||
"RESTRICTED_UNKNOWN_INVOCATION",
|
||||
"RESTRICTED_UNKNOWN_PORT",
|
||||
]);
|
||||
const fixedError = (code) => Object.assign(Error(code), { code });
|
||||
const object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
||||
const absolute = (value) =>
|
||||
@@ -108,7 +141,7 @@ export async function serveRestrictedRegistry(config, io = process) {
|
||||
const limits = {
|
||||
maxBytes: config.maxBytes ?? 1024 * 1024,
|
||||
maxPortCalls: config.maxPortCalls ?? 1000,
|
||||
deadlineMs: config.deadlineMs ?? 4000,
|
||||
deadlineMs: config.deadlineMs ?? 10000,
|
||||
maxQueued: config.maxQueued ?? 8,
|
||||
maxActive: config.maxActive ?? 2,
|
||||
maxNested: config.maxNested ?? 4,
|
||||
@@ -301,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;
|
||||
};
|
||||
@@ -333,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;
|
||||
@@ -382,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
|
||||
+12
-1
@@ -9,10 +9,21 @@ 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"
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -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