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; }); }