2026-10-28 21:29:20 +00:00
|
|
|
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");
|
|
|
|
|
return path.join(root, createHash("sha256").update(input).digest("hex"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function socketAddress(identity, role, options) {
|
2026-10-28 21:38:33 +00:00
|
|
|
if (typeof role !== "string" || role !== role.trim() || !/^[a-z][a-z0-9-]{0,10}$/.test(role))
|
|
|
|
|
throw Error("Invalid bounded socket role");
|
2026-10-28 21:29:20 +00:00
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
}
|