From 97ce77d3dec517da2a96ee1e83301086d78a1b71 Mon Sep 17 00:00:00 2001 From: Quixos Development Agent 01f26965-84fa-4a79-8069-725682c13065 <01f26965-84fa-4a79-8069-725682c13065@agents.gitea.internal.quixos.org> Date: Wed, 28 Oct 2026 21:29:20 +0000 Subject: [PATCH] Share the canonical socket implementation with public Nix helpers (#503) --- socket-address.d.mts | 8 +++ socket-address.mjs | 148 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 socket-address.d.mts create mode 100644 socket-address.mjs diff --git a/socket-address.d.mts b/socket-address.d.mts new file mode 100644 index 0000000..34e5573 --- /dev/null +++ b/socket-address.d.mts @@ -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; +export function prepareSocketParent(socket: string): Promise; +export function removeEmptySocketDirectory(identity: unknown, options?: SocketOptions): Promise; diff --git a/socket-address.mjs b/socket-address.mjs new file mode 100644 index 0000000..e75695b --- /dev/null +++ b/socket-address.mjs @@ -0,0 +1,148 @@ +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) { + if (typeof role !== "string" || !/^[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; + }); +}