From 86353c0eb9f560d5de0f9cd82d1c378dcf14057f Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:58:38 -0400 Subject: [PATCH 1/2] Give each error class one identity across subpaths and accept piped input in Rust (0.8.0) - The build splits shared modules into chunks, so ControlSocketError, ProtectedInputError and the custody errors have one class each; an error thrown through one subpath passes instanceof against another. The packed smoke asserts it. - Rust read_protected_descriptor accepts pipes and sockets like the TypeScript reader, so piped login --stdin works in Rust CLIs. Regular files keep the owner and 0600 checks; other kinds are still refused. - The sidecar refuses descriptors 0-2 and read_protected_stdin with kind, because its stdio carries the protocol. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 2 +- README.md | 12 +- dist/atomic-publish.js | 279 +------------- dist/chunk-605s349d.js | 147 +++++++ dist/chunk-8gyk2127.js | 51 +++ dist/chunk-k2mgjyx4.js | 309 +++++++++++++++ dist/chunk-np3mgd4s.js | 33 ++ dist/chunk-xrgz1k0h.js | 137 +++++++ dist/control-socket.js | 443 +-------------------- dist/custody-rust.js | 707 ++-------------------------------- dist/index.js | 652 ++----------------------------- dist/private-paths.js | 143 +------ dist/protected-input.js | 56 +-- dist/rust-fallback.js | 34 +- package.json | 2 +- portfolio-inventory.json | 2 +- rust/Cargo.toml | 2 +- rust/src/lib.rs | 39 +- rust/src/main.rs | 22 +- rust/tests/protected_input.rs | 41 ++ scripts/build.ts | 4 + scripts/package-smoke.ts | 17 + spec/custody.md | 7 +- spec/vectors.json | 7 +- src/custody-rust.ts | 4 +- 25 files changed, 900 insertions(+), 2252 deletions(-) create mode 100644 dist/chunk-605s349d.js create mode 100644 dist/chunk-8gyk2127.js create mode 100644 dist/chunk-k2mgjyx4.js create mode 100644 dist/chunk-np3mgd4s.js create mode 100644 dist/chunk-xrgz1k0h.js diff --git a/Cargo.lock b/Cargo.lock index 363511b..27263e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,7 +61,7 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "local-custody" -version = "0.7.0" +version = "0.8.0" dependencies = [ "libc", "serde", diff --git a/README.md b/README.md index 55317c7..febe907 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,16 @@ deleting files. For terminal input, pass `inputExample` (such as Behavior changes in 0.7.0: a request to a socket that doesn't exist fails with `ControlSocketError` code `control-unavailable` (the original `ENOENT` is its `cause`), and reading protected input from a terminal says to pipe or redirect -the value in. Each subpath is bundled separately, so check `error.code` or -`error.name` rather than `instanceof` across entrypoints. +the value in. + +Changes in 0.8.0: each error class has one identity across entrypoints, so an +error thrown through `@hraness/local-custody/control-socket` passes +`instanceof` against the class imported from the package root (the build +shares modules as chunks instead of copying them into each subpath). The Rust +crate's `read_protected_descriptor` accepts a pipe or socket, like the +TypeScript reader, so `pbpaste | login --stdin` works in Rust CLIs; a +regular file must still be owned by you and private, and other descriptor +kinds are still refused. The Rust crate has the same copy through `describe_error(code, &DescribeOptions { .. })` and `CustodyError::describe`; diff --git a/dist/atomic-publish.js b/dist/atomic-publish.js index 087000e..8134df6 100644 --- a/dist/atomic-publish.js +++ b/dist/atomic-publish.js @@ -1,278 +1,9 @@ -// src/private-paths.ts import { - closeSync, - constants, - fstatSync, - lstatSync, - openSync, - readSync, - realpathSync -} from "node:fs"; -import { lstat, mkdir, open, realpath } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -var PRIVATE_DIRECTORY_MODE = 448; -var PRIVATE_FILE_MODE = 384; -var ownerUid = () => typeof process.getuid === "function" ? process.getuid() : undefined; -var kindMatches = (metadata, kind) => kind === "file" ? metadata.isFile() : kind === "directory" ? metadata.isDirectory() : metadata.isSocket(); -async function assertOwnedPath(path, expectation) { - const metadata = await lstat(path, { bigint: true }); - const uid = ownerUid(); - const expectedLinks = expectation.links ?? (expectation.kind === "directory" ? undefined : 1n); - if (!kindMatches(metadata, expectation.kind) || metadata.isSymbolicLink() || expectedLinks !== undefined && metadata.nlink !== BigInt(expectedLinks) || uid !== undefined && metadata.uid !== BigInt(uid) || expectation.exactMode !== undefined && (metadata.mode & 0o777n) !== BigInt(expectation.exactMode) || expectation.ownerOnly === true && (metadata.mode & 0o077n) !== 0n || expectation.canonical === true && await realpath(path) !== path || expectation.minimumBytes !== undefined && metadata.size < expectation.minimumBytes || expectation.maximumBytes !== undefined && metadata.size > expectation.maximumBytes) { - throw new Error(`Unsafe local ${expectation.kind}.`); - } - return { - dev: Number(metadata.dev), - ino: Number(metadata.ino), - size: Number(metadata.size) - }; -} -function assertOwnedPathSync(path, expectation) { - const metadata = lstatSync(path, { bigint: true }); - const uid = ownerUid(); - const expectedLinks = expectation.links ?? (expectation.kind === "directory" ? undefined : 1n); - if (!kindMatches(metadata, expectation.kind) || metadata.isSymbolicLink() || expectedLinks !== undefined && metadata.nlink !== BigInt(expectedLinks) || uid !== undefined && metadata.uid !== BigInt(uid) || expectation.exactMode !== undefined && (metadata.mode & 0o777n) !== BigInt(expectation.exactMode) || expectation.ownerOnly === true && (metadata.mode & 0o077n) !== 0n || expectation.canonical === true && realpathSync(path) !== path || expectation.minimumBytes !== undefined && metadata.size < expectation.minimumBytes || expectation.maximumBytes !== undefined && metadata.size > expectation.maximumBytes) { - throw new Error(`Unsafe local ${expectation.kind}.`); - } - return { - dev: Number(metadata.dev), - ino: Number(metadata.ino), - size: Number(metadata.size) - }; -} -async function ensurePrivateDirectory(path) { - const absolute = resolve(path); - const parent = dirname(absolute); - if (await realpath(parent) !== parent) { - throw new Error("Directory parent must be physical."); - } - try { - await mkdir(absolute, { mode: PRIVATE_DIRECTORY_MODE }); - } catch (error) { - if (error.code !== "EEXIST") - throw error; - } - const metadata = await lstat(absolute); - if (await realpath(absolute) !== absolute || !metadata.isDirectory() || metadata.isSymbolicLink() || ownerUid() !== undefined && metadata.uid !== ownerUid() || (metadata.mode & 63) !== 0) { - throw new Error("Directory must be physical, owned, and private."); - } - return absolute; -} -var checkStableCandidate = (before, maximumBytes, expectation) => { - const uid = ownerUid(); - const links = BigInt(expectation.links ?? 1); - if (!before.isFile() || before.nlink !== links || uid !== undefined && before.uid !== BigInt(uid) || (expectation.exactMode !== undefined ? (before.mode & 0o777n) !== BigInt(expectation.exactMode) : expectation.ownerOnly !== false && (before.mode & 0o077n) !== 0n) || expectation.minimumBytes !== undefined && before.size < expectation.minimumBytes || before.size > BigInt(maximumBytes)) { - throw new Error("Unsafe private file."); - } -}; -var checkStableResult = (before, after) => { - if (after.isSymbolicLink() || !after.isFile() || after.dev !== before.dev || after.ino !== before.ino || after.nlink !== before.nlink || after.mode !== before.mode || after.uid !== before.uid || after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { - throw new Error("Private file changed during the read."); - } -}; -async function readOwnedFileStable(path, maximumBytes, expectation = {}) { - const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - try { - const before = await handle.stat({ bigint: true }); - checkStableCandidate(before, maximumBytes, expectation); - const buffer = Buffer.alloc(Number(before.size)); - let offset = 0; - while (offset < buffer.byteLength) { - const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, null); - if (bytesRead === 0) - break; - offset += bytesRead; - } - const after = await lstat(path, { bigint: true }); - checkStableResult(before, after); - if (offset !== buffer.byteLength) { - throw new Error("Private file changed during the read."); - } - return { bytes: buffer, dev: Number(before.dev), ino: Number(before.ino) }; - } finally { - await handle.close(); - } -} -function readOwnedFileStableSync(path, maximumBytes, expectation = {}) { - const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - try { - const before = fstatSync(descriptor, { bigint: true }); - checkStableCandidate(before, maximumBytes, expectation); - const buffer = Buffer.alloc(Number(before.size)); - let offset = 0; - while (offset < buffer.byteLength) { - const count = readSync(descriptor, buffer, offset, buffer.byteLength - offset, null); - if (count === 0) - break; - offset += count; - } - const after = lstatSync(path, { bigint: true }); - checkStableResult(before, after); - if (offset !== buffer.byteLength) { - throw new Error("Private file changed during the read."); - } - return { bytes: buffer, dev: Number(before.dev), ino: Number(before.ino) }; - } finally { - closeSync(descriptor); - } -} -async function readPrivateFile(path, maximumBytes) { - const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - try { - const metadata = await handle.stat(); - const uid = ownerUid(); - if (!metadata.isFile() || metadata.nlink !== 1 || uid !== undefined && metadata.uid !== uid || (metadata.mode & 63) !== 0 || metadata.size > maximumBytes) { - throw new Error("Unsafe private file."); - } - const buffer = Buffer.alloc(maximumBytes + 1); - const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); - if (bytesRead > maximumBytes) - throw new Error("Private file exceeds its size bound."); - return buffer.subarray(0, bytesRead); - } finally { - await handle.close(); - } -} - -// src/atomic-publish.ts -import { randomUUID } from "node:crypto"; -import { - closeSync as closeSync2, - constants as constants2, - fchmodSync, - fsyncSync, - linkSync, - openSync as openSync2, - unlinkSync, - writeSync -} from "node:fs"; -import { link, open as open2, rename, unlink } from "node:fs/promises"; -import { join } from "node:path"; -var safeFileName = /^[A-Za-z0-9][A-Za-z0-9._-]{0,126}$/u; -var assertSafeName = (name) => { - if (!safeFileName.test(name) || Buffer.byteLength(name) > 128) { - throw new Error("Unsafe publish name."); - } -}; -var syncDirectory = async (directory) => { - const handle = await open2(directory, constants2.O_RDONLY); - try { - await handle.sync(); - } finally { - await handle.close(); - } -}; -var writeStaged = async (staged, content) => { - const handle = await open2(staged, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY | constants2.O_NOFOLLOW, PRIVATE_FILE_MODE); - try { - await handle.chmod(PRIVATE_FILE_MODE); - await handle.writeFile(content); - await handle.sync(); - } finally { - await handle.close(); - } -}; -async function publishPrivateFile(directory, name, content, options = {}) { - assertSafeName(name); - const target = join(directory, name); - const temporary = join(directory, `.${name}.${randomUUID()}.tmp`); - try { - await writeStaged(temporary, content); - await options.beforeCommit?.(target); - await rename(temporary, target); - await syncDirectory(directory); - await assertOwnedPath(target, { - kind: "file", - exactMode: PRIVATE_FILE_MODE, - links: 1 - }); - } catch (error) { - await unlink(temporary).catch(() => { - return; - }); - throw error; - } -} -async function createPrivateFileOnce(directory, name, content) { - assertSafeName(name); - const target = join(directory, name); - const temporary = join(directory, `.${name}.${randomUUID()}.tmp`); - try { - await writeStaged(temporary, content); - try { - await link(temporary, target); - } catch (error) { - if (error.code === "EEXIST") - return "existing"; - throw error; - } - await syncDirectory(directory); - await assertOwnedPath(target, { - kind: "file", - exactMode: PRIVATE_FILE_MODE, - links: 2 - }); - return "created"; - } finally { - await unlink(temporary).catch(() => { - return; - }); - await syncDirectory(directory).catch(() => { - return; - }); - } -} -var writeStagedSync = (staged, content) => { - const descriptor = openSync2(staged, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY | constants2.O_NOFOLLOW, PRIVATE_FILE_MODE); - try { - fchmodSync(descriptor, PRIVATE_FILE_MODE); - const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"); - let offset = 0; - while (offset < bytes.byteLength) { - offset += writeSync(descriptor, bytes, offset, bytes.byteLength - offset); - } - fsyncSync(descriptor); - } finally { - closeSync2(descriptor); - } -}; -var syncDirectorySync = (directory) => { - const descriptor = openSync2(directory, constants2.O_RDONLY); - try { - fsyncSync(descriptor); - } finally { - closeSync2(descriptor); - } -}; -function createPrivateFileOnceSync(directory, name, content) { - assertSafeName(name); - const target = join(directory, name); - const temporary = join(directory, `.${name}.${randomUUID()}.tmp`); - try { - writeStagedSync(temporary, content); - try { - linkSync(temporary, target); - } catch (error) { - if (error.code === "EEXIST") - return "existing"; - throw error; - } - syncDirectorySync(directory); - assertOwnedPathSync(target, { - kind: "file", - exactMode: PRIVATE_FILE_MODE, - links: 2 - }); - return "created"; - } finally { - try { - unlinkSync(temporary); - } catch {} - try { - syncDirectorySync(directory); - } catch {} - } -} + createPrivateFileOnce, + createPrivateFileOnceSync, + publishPrivateFile +} from "./chunk-605s349d.js"; +import"./chunk-xrgz1k0h.js"; export { publishPrivateFile, createPrivateFileOnceSync, diff --git a/dist/chunk-605s349d.js b/dist/chunk-605s349d.js new file mode 100644 index 0000000..817c8c6 --- /dev/null +++ b/dist/chunk-605s349d.js @@ -0,0 +1,147 @@ +import { + PRIVATE_FILE_MODE, + assertOwnedPath, + assertOwnedPathSync +} from "./chunk-xrgz1k0h.js"; + +// src/atomic-publish.ts +import { randomUUID } from "node:crypto"; +import { + closeSync, + constants, + fchmodSync, + fsyncSync, + linkSync, + openSync, + unlinkSync, + writeSync +} from "node:fs"; +import { link, open, rename, unlink } from "node:fs/promises"; +import { join } from "node:path"; +var safeFileName = /^[A-Za-z0-9][A-Za-z0-9._-]{0,126}$/u; +var assertSafeName = (name) => { + if (!safeFileName.test(name) || Buffer.byteLength(name) > 128) { + throw new Error("Unsafe publish name."); + } +}; +var syncDirectory = async (directory) => { + const handle = await open(directory, constants.O_RDONLY); + try { + await handle.sync(); + } finally { + await handle.close(); + } +}; +var writeStaged = async (staged, content) => { + const handle = await open(staged, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, PRIVATE_FILE_MODE); + try { + await handle.chmod(PRIVATE_FILE_MODE); + await handle.writeFile(content); + await handle.sync(); + } finally { + await handle.close(); + } +}; +async function publishPrivateFile(directory, name, content, options = {}) { + assertSafeName(name); + const target = join(directory, name); + const temporary = join(directory, `.${name}.${randomUUID()}.tmp`); + try { + await writeStaged(temporary, content); + await options.beforeCommit?.(target); + await rename(temporary, target); + await syncDirectory(directory); + await assertOwnedPath(target, { + kind: "file", + exactMode: PRIVATE_FILE_MODE, + links: 1 + }); + } catch (error) { + await unlink(temporary).catch(() => { + return; + }); + throw error; + } +} +async function createPrivateFileOnce(directory, name, content) { + assertSafeName(name); + const target = join(directory, name); + const temporary = join(directory, `.${name}.${randomUUID()}.tmp`); + try { + await writeStaged(temporary, content); + try { + await link(temporary, target); + } catch (error) { + if (error.code === "EEXIST") + return "existing"; + throw error; + } + await syncDirectory(directory); + await assertOwnedPath(target, { + kind: "file", + exactMode: PRIVATE_FILE_MODE, + links: 2 + }); + return "created"; + } finally { + await unlink(temporary).catch(() => { + return; + }); + await syncDirectory(directory).catch(() => { + return; + }); + } +} +var writeStagedSync = (staged, content) => { + const descriptor = openSync(staged, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, PRIVATE_FILE_MODE); + try { + fchmodSync(descriptor, PRIVATE_FILE_MODE); + const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"); + let offset = 0; + while (offset < bytes.byteLength) { + offset += writeSync(descriptor, bytes, offset, bytes.byteLength - offset); + } + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } +}; +var syncDirectorySync = (directory) => { + const descriptor = openSync(directory, constants.O_RDONLY); + try { + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } +}; +function createPrivateFileOnceSync(directory, name, content) { + assertSafeName(name); + const target = join(directory, name); + const temporary = join(directory, `.${name}.${randomUUID()}.tmp`); + try { + writeStagedSync(temporary, content); + try { + linkSync(temporary, target); + } catch (error) { + if (error.code === "EEXIST") + return "existing"; + throw error; + } + syncDirectorySync(directory); + assertOwnedPathSync(target, { + kind: "file", + exactMode: PRIVATE_FILE_MODE, + links: 2 + }); + return "created"; + } finally { + try { + unlinkSync(temporary); + } catch {} + try { + syncDirectorySync(directory); + } catch {} + } +} + +export { publishPrivateFile, createPrivateFileOnce, createPrivateFileOnceSync }; diff --git a/dist/chunk-8gyk2127.js b/dist/chunk-8gyk2127.js new file mode 100644 index 0000000..5060a96 --- /dev/null +++ b/dist/chunk-8gyk2127.js @@ -0,0 +1,51 @@ +// src/protected-input.ts +import { fstatSync, readSync } from "node:fs"; +import { isatty } from "node:tty"; +var DEFAULT_PROTECTED_INPUT_MAXIMUM_BYTES = 65536; + +class ProtectedInputError extends Error { + code; + name = "ProtectedInputError"; + constructor(code, message) { + super(message); + this.code = code; + } +} +var PROTECTED_INPUT_TERMINAL_MESSAGE = "Pipe or redirect the value in instead of typing it, so it stays out of your terminal history."; +function readProtectedDescriptor(descriptor, options = {}) { + const maximumBytes = options.maximumBytes ?? DEFAULT_PROTECTED_INPUT_MAXIMUM_BYTES; + if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) { + throw new Error("Protected input bound must be a positive integer."); + } + if (!Number.isSafeInteger(descriptor) || descriptor < 0) { + throw new Error("Protected input requires a valid descriptor."); + } + if (isatty(descriptor)) { + throw new ProtectedInputError("protected-terminal", PROTECTED_INPUT_TERMINAL_MESSAGE); + } + const metadata = fstatSync(descriptor, { bigint: true }); + if (metadata.isFile()) { + const uid = typeof process.getuid === "function" ? process.getuid() : undefined; + if (uid !== undefined && metadata.uid !== BigInt(uid) || (metadata.mode & 0o077n) !== 0n) { + throw new ProtectedInputError("protected-unsafe-file", "Protected input file must be owned and private."); + } + } + const buffer = Buffer.alloc(maximumBytes + 1); + let offset = 0; + while (true) { + const read = readSync(descriptor, buffer, offset, buffer.length - offset, null); + if (read === 0) + break; + offset += read; + if (offset > maximumBytes) + throw new ProtectedInputError("protected-too-large", "Protected input exceeds its size bound."); + if (offset === buffer.length) + throw new ProtectedInputError("protected-too-large", "Protected input exceeds its size bound."); + } + return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset)); +} +function readProtectedStdin(options = {}) { + return readProtectedDescriptor(0, options); +} + +export { DEFAULT_PROTECTED_INPUT_MAXIMUM_BYTES, ProtectedInputError, PROTECTED_INPUT_TERMINAL_MESSAGE, readProtectedDescriptor, readProtectedStdin }; diff --git a/dist/chunk-k2mgjyx4.js b/dist/chunk-k2mgjyx4.js new file mode 100644 index 0000000..13b23b7 --- /dev/null +++ b/dist/chunk-k2mgjyx4.js @@ -0,0 +1,309 @@ +import { + PRIVATE_FILE_MODE, + assertOwnedPath, + ensurePrivateDirectory +} from "./chunk-xrgz1k0h.js"; + +// src/control-socket.ts +import { chmod, unlink } from "node:fs/promises"; +import { createServer, connect } from "node:net"; +import { dirname } from "node:path"; +class ControlSocketError extends Error { + code; + name = "ControlSocketError"; + constructor(code, message, options) { + super(message, options); + this.code = code; + } +} +var MAXIMUM_SOCKET_PATH_BYTES = 100; +var DEFAULT_MAXIMUM_CONNECTIONS = 16; +var DEFAULT_HEADER_TIMEOUT_MS = 5000; +var DEFAULT_IDLE_TIMEOUT_MS = 1e4; +var MAXIMUM_TIMEOUT_MS = 3600000; +var boundedTimeoutMs = (value, fallback) => { + const candidate = value ?? fallback; + if (!Number.isSafeInteger(candidate) || candidate < 1 || candidate > MAXIMUM_TIMEOUT_MS) { + throw new Error("Control socket timeouts must be positive integers within one hour."); + } + return candidate; +}; +var boundedCount = (value, fallback, maximum) => { + const candidate = value ?? fallback; + if (!Number.isSafeInteger(candidate) || candidate < 1 || candidate > maximum) { + throw new Error("Control socket bounds must be positive integers."); + } + return candidate; +}; +var socketIdentity = (path) => assertOwnedPath(path, { kind: "socket", exactMode: PRIVATE_FILE_MODE, links: 1 }); +var sameIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino; +var resolveBounds = (options) => { + const maximumFrameBytes = options.maximumFrameBytes; + if (!Number.isSafeInteger(maximumFrameBytes) || maximumFrameBytes < 1) { + throw new Error("Control frame bound must be a positive integer."); + } + return { + maximumFrameBytes, + maximumResponseBytes: options.maximumResponseBytes ?? maximumFrameBytes, + maximumConnections: boundedCount(options.maximumConnections, DEFAULT_MAXIMUM_CONNECTIONS, 1024), + maximumRequests: boundedCount(options.maximumRequestsPerConnection, 1, 1024), + headerTimeoutMs: boundedTimeoutMs(options.headerTimeoutMs, DEFAULT_HEADER_TIMEOUT_MS), + idleTimeoutMs: boundedTimeoutMs(options.idleTimeoutMs, DEFAULT_IDLE_TIMEOUT_MS) + }; +}; +function attachControlSocket(server, options) { + const bounds = resolveBounds(options); + const failure = options.failureResponse; + const encode = (value, reason) => { + const bytes = Buffer.from(`${JSON.stringify(value)} +`); + if (bytes.length <= bounds.maximumResponseBytes) + return bytes; + const fallback = Buffer.from(`${JSON.stringify(failure(reason))} +`); + if (fallback.length <= bounds.maximumResponseBytes) + return fallback; + return Buffer.alloc(0); + }; + const clients = new Set; + const work = new Set; + let closing = false; + server.on("connection", (socket) => { + if (closing || clients.size >= bounds.maximumConnections) { + const bytes = encode(failure("capacity"), "capacity"); + if (bytes.length === 0) + socket.destroy(); + else + socket.end(bytes); + return; + } + clients.add(socket); + let idleTimer; + socket.once("close", () => { + clients.delete(socket); + if (idleTimer !== undefined) + clearTimeout(idleTimer); + }); + socket.on("error", () => { + return; + }); + const controller = new AbortController; + let received = Buffer.alloc(0); + let requests = 0; + let chain = Promise.resolve(); + const headerTimer = setTimeout(() => socket.destroy(), bounds.headerTimeoutMs); + headerTimer.unref(); + const armIdle = () => { + if (idleTimer !== undefined) + clearTimeout(idleTimer); + if (requests >= bounds.maximumRequests) + return; + idleTimer = setTimeout(() => socket.destroy(), bounds.idleTimeoutMs); + idleTimer.unref(); + }; + socket.on("data", (chunk) => { + if (closing) { + socket.destroy(); + return; + } + received = Buffer.concat([received, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + while (true) { + const newline = received.indexOf(10); + if (newline < 0) { + if (received.byteLength >= bounds.maximumFrameBytes) + socket.destroy(); + return; + } + if (newline === 0 || newline + 1 > bounds.maximumFrameBytes || requests >= bounds.maximumRequests) { + const bytes = encode(failure("limit"), "limit"); + if (bytes.length === 0) + socket.destroy(); + else + socket.end(bytes); + return; + } + const frame = received.subarray(0, newline); + received = received.subarray(newline + 1); + requests += 1; + clearTimeout(headerTimer); + const task = chain.catch(() => { + return; + }).then(async () => { + if (closing || socket.destroyed) + return; + let response; + try { + const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(frame)); + response = await options.onRequest(value, { signal: controller.signal }); + } catch { + response = failure("invalid-request"); + } + if (!socket.destroyed && !socket.writableEnded) { + const bytes = encode(response, "response-limit"); + if (bytes.length === 0 || socket.writableLength + bytes.length > bounds.maximumResponseBytes) { + socket.destroy(); + } else { + socket.write(bytes, () => { + if (requests >= bounds.maximumRequests) + socket.end(); + else + armIdle(); + }); + } + } + }).finally(() => work.delete(task)); + work.add(task); + chain = task; + } + }); + socket.on("end", () => { + if (received.byteLength !== 0) + socket.destroy(); + }); + }); + let closePromise; + return { + close() { + closePromise ??= (async () => { + closing = true; + for (const client of clients) + client.destroy(); + await new Promise((resolve, reject) => server.close((error) => error !== undefined && error.code !== "ERR_SERVER_NOT_RUNNING" ? reject(error) : resolve())); + await Promise.allSettled([...work]); + })(); + return closePromise; + } + }; +} +async function listenControlSocket(options) { + const socketPath = options.socketPath; + if (Buffer.byteLength(socketPath) > MAXIMUM_SOCKET_PATH_BYTES) { + throw new Error("Control socket path exceeds its platform limit."); + } + await ensurePrivateDirectory(dirname(socketPath)); + try { + await socketIdentity(socketPath); + await unlink(socketPath); + } catch (error) { + if (error.code !== "ENOENT") + throw error; + } + const server = createServer(); + const transport = attachControlSocket(server, options); + let published; + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, () => { + server.off("error", reject); + resolve(); + }); + }); + await chmod(socketPath, PRIVATE_FILE_MODE); + published = await socketIdentity(socketPath); + } catch (error) { + await transport.close().catch(() => { + return; + }); + throw error; + } + return { + socketPath, + async close() { + const failures = []; + try { + await transport.close(); + } catch (error) { + failures.push(error); + } + try { + const current = await socketIdentity(socketPath).catch(() => null); + if (current !== null && sameIdentity(current, published)) { + await unlink(socketPath); + } + } catch (error) { + failures.push(error); + } + if (failures.length > 0) { + throw failures.length === 1 ? failures[0] : new AggregateError(failures, "Control socket cleanup requires attention."); + } + } + }; +} +async function requestControlSocket(options) { + const { socketPath, maximumResponseBytes, timeoutMs } = options; + if (!Number.isSafeInteger(maximumResponseBytes) || maximumResponseBytes < 1) { + throw new Error("Control response bound must be a positive integer."); + } + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAXIMUM_TIMEOUT_MS) { + throw new Error("Control request timeout must be a positive integer within one hour."); + } + const maximumRequestBytes = options.maximumRequestBytes ?? maximumResponseBytes; + const frame = Buffer.from(`${JSON.stringify(options.request)} +`); + if (frame.length > maximumRequestBytes) { + throw new Error("Control request exceeds its frame limit."); + } + let before; + try { + await assertOwnedPath(dirname(socketPath), { kind: "directory", canonical: true }); + before = await socketIdentity(socketPath); + } catch (error) { + if (error.code === "ENOENT") { + throw new ControlSocketError("control-unavailable", "The control socket is unavailable.", { cause: error }); + } + throw error; + } + return new Promise((resolvePromise, rejectPromise) => { + const socket = connect(socketPath); + let buffer = Buffer.alloc(0); + let settled = false; + const settle = (error, value) => { + if (settled) + return; + settled = true; + clearTimeout(timer); + socket.destroy(); + if (error !== null) + rejectPromise(error); + else + resolvePromise(value); + }; + const timer = setTimeout(() => settle(new ControlSocketError("control-timeout", "Control request timed out.")), timeoutMs); + socket.once("connect", () => { + socketIdentity(socketPath).then((after) => { + if (!sameIdentity(before, after)) + throw new ControlSocketError("control-identity-changed", "Control socket identity changed."); + if (!settled) + socket.write(frame); + }).catch((error) => settle(error instanceof Error ? error : new Error("Control socket changed."))); + }); + socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + if (buffer.length > maximumResponseBytes) { + settle(new ControlSocketError("control-response-too-large", "Control response exceeds its frame limit.")); + return; + } + const newline = buffer.indexOf(10); + if (newline < 0) + return; + if (newline !== buffer.length - 1) { + settle(new ControlSocketError("control-extra-output", "Unexpected additional control output.")); + return; + } + try { + const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, newline))); + settle(null, options.parseResponse(value)); + } catch { + settle(new ControlSocketError("control-invalid-response", "Invalid control response.")); + } + }); + socket.once("error", () => settle(new ControlSocketError("control-unavailable", "The control socket is unavailable."))); + socket.once("close", () => { + if (!settled) + settle(new ControlSocketError("control-closed", "The control socket closed without a response.")); + }); + }); +} + +export { ControlSocketError, MAXIMUM_SOCKET_PATH_BYTES, attachControlSocket, listenControlSocket, requestControlSocket }; diff --git a/dist/chunk-np3mgd4s.js b/dist/chunk-np3mgd4s.js new file mode 100644 index 0000000..9f9b267 --- /dev/null +++ b/dist/chunk-np3mgd4s.js @@ -0,0 +1,33 @@ +// src/rust-fallback.ts +var MAX_FALLBACK_TAGS = 32; +var MAX_FALLBACK_NOTICES_PER_TAG = 4; +var DIAGNOSTIC_FIELD = /^[A-Za-z0-9._-]{1,64}$/u; +var emittedNotices = new Map; +function boundedField(value) { + return DIAGNOSTIC_FIELD.test(value) ? value : "other"; +} +function emitLocalCustodyFallback(notice) { + const tag = boundedField(notice.tag); + const reason = boundedField(notice.reason); + const inputClass = notice.inputClass === undefined ? undefined : boundedField(notice.inputClass); + const diagnostic = inputClass === undefined ? reason : `${reason}:${inputClass}`; + let seen = emittedNotices.get(tag); + if (seen === undefined) { + if (emittedNotices.size >= MAX_FALLBACK_TAGS) + return; + seen = new Set; + emittedNotices.set(tag, seen); + } + if (seen.has(diagnostic) || seen.size >= MAX_FALLBACK_NOTICES_PER_TAG) + return; + seen.add(diagnostic); + try { + if (typeof process !== "undefined" && typeof process.stderr?.write === "function") { + const detail = inputClass === undefined ? reason : `${reason} input=${inputClass}`; + process.stderr.write(`[${tag}] ${detail} +`); + } + } catch {} +} + +export { emitLocalCustodyFallback }; diff --git a/dist/chunk-xrgz1k0h.js b/dist/chunk-xrgz1k0h.js new file mode 100644 index 0000000..bbea38f --- /dev/null +++ b/dist/chunk-xrgz1k0h.js @@ -0,0 +1,137 @@ +// src/private-paths.ts +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readSync, + realpathSync +} from "node:fs"; +import { lstat, mkdir, open, realpath } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +var PRIVATE_DIRECTORY_MODE = 448; +var PRIVATE_FILE_MODE = 384; +var ownerUid = () => typeof process.getuid === "function" ? process.getuid() : undefined; +var kindMatches = (metadata, kind) => kind === "file" ? metadata.isFile() : kind === "directory" ? metadata.isDirectory() : metadata.isSocket(); +async function assertOwnedPath(path, expectation) { + const metadata = await lstat(path, { bigint: true }); + const uid = ownerUid(); + const expectedLinks = expectation.links ?? (expectation.kind === "directory" ? undefined : 1n); + if (!kindMatches(metadata, expectation.kind) || metadata.isSymbolicLink() || expectedLinks !== undefined && metadata.nlink !== BigInt(expectedLinks) || uid !== undefined && metadata.uid !== BigInt(uid) || expectation.exactMode !== undefined && (metadata.mode & 0o777n) !== BigInt(expectation.exactMode) || expectation.ownerOnly === true && (metadata.mode & 0o077n) !== 0n || expectation.canonical === true && await realpath(path) !== path || expectation.minimumBytes !== undefined && metadata.size < expectation.minimumBytes || expectation.maximumBytes !== undefined && metadata.size > expectation.maximumBytes) { + throw new Error(`Unsafe local ${expectation.kind}.`); + } + return { + dev: Number(metadata.dev), + ino: Number(metadata.ino), + size: Number(metadata.size) + }; +} +function assertOwnedPathSync(path, expectation) { + const metadata = lstatSync(path, { bigint: true }); + const uid = ownerUid(); + const expectedLinks = expectation.links ?? (expectation.kind === "directory" ? undefined : 1n); + if (!kindMatches(metadata, expectation.kind) || metadata.isSymbolicLink() || expectedLinks !== undefined && metadata.nlink !== BigInt(expectedLinks) || uid !== undefined && metadata.uid !== BigInt(uid) || expectation.exactMode !== undefined && (metadata.mode & 0o777n) !== BigInt(expectation.exactMode) || expectation.ownerOnly === true && (metadata.mode & 0o077n) !== 0n || expectation.canonical === true && realpathSync(path) !== path || expectation.minimumBytes !== undefined && metadata.size < expectation.minimumBytes || expectation.maximumBytes !== undefined && metadata.size > expectation.maximumBytes) { + throw new Error(`Unsafe local ${expectation.kind}.`); + } + return { + dev: Number(metadata.dev), + ino: Number(metadata.ino), + size: Number(metadata.size) + }; +} +async function ensurePrivateDirectory(path) { + const absolute = resolve(path); + const parent = dirname(absolute); + if (await realpath(parent) !== parent) { + throw new Error("Directory parent must be physical."); + } + try { + await mkdir(absolute, { mode: PRIVATE_DIRECTORY_MODE }); + } catch (error) { + if (error.code !== "EEXIST") + throw error; + } + const metadata = await lstat(absolute); + if (await realpath(absolute) !== absolute || !metadata.isDirectory() || metadata.isSymbolicLink() || ownerUid() !== undefined && metadata.uid !== ownerUid() || (metadata.mode & 63) !== 0) { + throw new Error("Directory must be physical, owned, and private."); + } + return absolute; +} +var checkStableCandidate = (before, maximumBytes, expectation) => { + const uid = ownerUid(); + const links = BigInt(expectation.links ?? 1); + if (!before.isFile() || before.nlink !== links || uid !== undefined && before.uid !== BigInt(uid) || (expectation.exactMode !== undefined ? (before.mode & 0o777n) !== BigInt(expectation.exactMode) : expectation.ownerOnly !== false && (before.mode & 0o077n) !== 0n) || expectation.minimumBytes !== undefined && before.size < expectation.minimumBytes || before.size > BigInt(maximumBytes)) { + throw new Error("Unsafe private file."); + } +}; +var checkStableResult = (before, after) => { + if (after.isSymbolicLink() || !after.isFile() || after.dev !== before.dev || after.ino !== before.ino || after.nlink !== before.nlink || after.mode !== before.mode || after.uid !== before.uid || after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { + throw new Error("Private file changed during the read."); + } +}; +async function readOwnedFileStable(path, maximumBytes, expectation = {}) { + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const before = await handle.stat({ bigint: true }); + checkStableCandidate(before, maximumBytes, expectation); + const buffer = Buffer.alloc(Number(before.size)); + let offset = 0; + while (offset < buffer.byteLength) { + const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, null); + if (bytesRead === 0) + break; + offset += bytesRead; + } + const after = await lstat(path, { bigint: true }); + checkStableResult(before, after); + if (offset !== buffer.byteLength) { + throw new Error("Private file changed during the read."); + } + return { bytes: buffer, dev: Number(before.dev), ino: Number(before.ino) }; + } finally { + await handle.close(); + } +} +function readOwnedFileStableSync(path, maximumBytes, expectation = {}) { + const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const before = fstatSync(descriptor, { bigint: true }); + checkStableCandidate(before, maximumBytes, expectation); + const buffer = Buffer.alloc(Number(before.size)); + let offset = 0; + while (offset < buffer.byteLength) { + const count = readSync(descriptor, buffer, offset, buffer.byteLength - offset, null); + if (count === 0) + break; + offset += count; + } + const after = lstatSync(path, { bigint: true }); + checkStableResult(before, after); + if (offset !== buffer.byteLength) { + throw new Error("Private file changed during the read."); + } + return { bytes: buffer, dev: Number(before.dev), ino: Number(before.ino) }; + } finally { + closeSync(descriptor); + } +} +async function readPrivateFile(path, maximumBytes) { + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const metadata = await handle.stat(); + const uid = ownerUid(); + if (!metadata.isFile() || metadata.nlink !== 1 || uid !== undefined && metadata.uid !== uid || (metadata.mode & 63) !== 0 || metadata.size > maximumBytes) { + throw new Error("Unsafe private file."); + } + const buffer = Buffer.alloc(maximumBytes + 1); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + if (bytesRead > maximumBytes) + throw new Error("Private file exceeds its size bound."); + return buffer.subarray(0, bytesRead); + } finally { + await handle.close(); + } +} + +export { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE, assertOwnedPath, assertOwnedPathSync, ensurePrivateDirectory, readOwnedFileStable, readOwnedFileStableSync, readPrivateFile }; diff --git a/dist/control-socket.js b/dist/control-socket.js index 15e7691..66952af 100644 --- a/dist/control-socket.js +++ b/dist/control-socket.js @@ -1,440 +1,11 @@ -// src/private-paths.ts import { - closeSync, - constants, - fstatSync, - lstatSync, - openSync, - readSync, - realpathSync -} from "node:fs"; -import { lstat, mkdir, open, realpath } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -var PRIVATE_DIRECTORY_MODE = 448; -var PRIVATE_FILE_MODE = 384; -var ownerUid = () => typeof process.getuid === "function" ? process.getuid() : undefined; -var kindMatches = (metadata, kind) => kind === "file" ? metadata.isFile() : kind === "directory" ? metadata.isDirectory() : metadata.isSocket(); -async function assertOwnedPath(path, expectation) { - const metadata = await lstat(path, { bigint: true }); - const uid = ownerUid(); - const expectedLinks = expectation.links ?? (expectation.kind === "directory" ? undefined : 1n); - if (!kindMatches(metadata, expectation.kind) || metadata.isSymbolicLink() || expectedLinks !== undefined && metadata.nlink !== BigInt(expectedLinks) || uid !== undefined && metadata.uid !== BigInt(uid) || expectation.exactMode !== undefined && (metadata.mode & 0o777n) !== BigInt(expectation.exactMode) || expectation.ownerOnly === true && (metadata.mode & 0o077n) !== 0n || expectation.canonical === true && await realpath(path) !== path || expectation.minimumBytes !== undefined && metadata.size < expectation.minimumBytes || expectation.maximumBytes !== undefined && metadata.size > expectation.maximumBytes) { - throw new Error(`Unsafe local ${expectation.kind}.`); - } - return { - dev: Number(metadata.dev), - ino: Number(metadata.ino), - size: Number(metadata.size) - }; -} -function assertOwnedPathSync(path, expectation) { - const metadata = lstatSync(path, { bigint: true }); - const uid = ownerUid(); - const expectedLinks = expectation.links ?? (expectation.kind === "directory" ? undefined : 1n); - if (!kindMatches(metadata, expectation.kind) || metadata.isSymbolicLink() || expectedLinks !== undefined && metadata.nlink !== BigInt(expectedLinks) || uid !== undefined && metadata.uid !== BigInt(uid) || expectation.exactMode !== undefined && (metadata.mode & 0o777n) !== BigInt(expectation.exactMode) || expectation.ownerOnly === true && (metadata.mode & 0o077n) !== 0n || expectation.canonical === true && realpathSync(path) !== path || expectation.minimumBytes !== undefined && metadata.size < expectation.minimumBytes || expectation.maximumBytes !== undefined && metadata.size > expectation.maximumBytes) { - throw new Error(`Unsafe local ${expectation.kind}.`); - } - return { - dev: Number(metadata.dev), - ino: Number(metadata.ino), - size: Number(metadata.size) - }; -} -async function ensurePrivateDirectory(path) { - const absolute = resolve(path); - const parent = dirname(absolute); - if (await realpath(parent) !== parent) { - throw new Error("Directory parent must be physical."); - } - try { - await mkdir(absolute, { mode: PRIVATE_DIRECTORY_MODE }); - } catch (error) { - if (error.code !== "EEXIST") - throw error; - } - const metadata = await lstat(absolute); - if (await realpath(absolute) !== absolute || !metadata.isDirectory() || metadata.isSymbolicLink() || ownerUid() !== undefined && metadata.uid !== ownerUid() || (metadata.mode & 63) !== 0) { - throw new Error("Directory must be physical, owned, and private."); - } - return absolute; -} -var checkStableCandidate = (before, maximumBytes, expectation) => { - const uid = ownerUid(); - const links = BigInt(expectation.links ?? 1); - if (!before.isFile() || before.nlink !== links || uid !== undefined && before.uid !== BigInt(uid) || (expectation.exactMode !== undefined ? (before.mode & 0o777n) !== BigInt(expectation.exactMode) : expectation.ownerOnly !== false && (before.mode & 0o077n) !== 0n) || expectation.minimumBytes !== undefined && before.size < expectation.minimumBytes || before.size > BigInt(maximumBytes)) { - throw new Error("Unsafe private file."); - } -}; -var checkStableResult = (before, after) => { - if (after.isSymbolicLink() || !after.isFile() || after.dev !== before.dev || after.ino !== before.ino || after.nlink !== before.nlink || after.mode !== before.mode || after.uid !== before.uid || after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { - throw new Error("Private file changed during the read."); - } -}; -async function readOwnedFileStable(path, maximumBytes, expectation = {}) { - const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - try { - const before = await handle.stat({ bigint: true }); - checkStableCandidate(before, maximumBytes, expectation); - const buffer = Buffer.alloc(Number(before.size)); - let offset = 0; - while (offset < buffer.byteLength) { - const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, null); - if (bytesRead === 0) - break; - offset += bytesRead; - } - const after = await lstat(path, { bigint: true }); - checkStableResult(before, after); - if (offset !== buffer.byteLength) { - throw new Error("Private file changed during the read."); - } - return { bytes: buffer, dev: Number(before.dev), ino: Number(before.ino) }; - } finally { - await handle.close(); - } -} -function readOwnedFileStableSync(path, maximumBytes, expectation = {}) { - const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - try { - const before = fstatSync(descriptor, { bigint: true }); - checkStableCandidate(before, maximumBytes, expectation); - const buffer = Buffer.alloc(Number(before.size)); - let offset = 0; - while (offset < buffer.byteLength) { - const count = readSync(descriptor, buffer, offset, buffer.byteLength - offset, null); - if (count === 0) - break; - offset += count; - } - const after = lstatSync(path, { bigint: true }); - checkStableResult(before, after); - if (offset !== buffer.byteLength) { - throw new Error("Private file changed during the read."); - } - return { bytes: buffer, dev: Number(before.dev), ino: Number(before.ino) }; - } finally { - closeSync(descriptor); - } -} -async function readPrivateFile(path, maximumBytes) { - const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - try { - const metadata = await handle.stat(); - const uid = ownerUid(); - if (!metadata.isFile() || metadata.nlink !== 1 || uid !== undefined && metadata.uid !== uid || (metadata.mode & 63) !== 0 || metadata.size > maximumBytes) { - throw new Error("Unsafe private file."); - } - const buffer = Buffer.alloc(maximumBytes + 1); - const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); - if (bytesRead > maximumBytes) - throw new Error("Private file exceeds its size bound."); - return buffer.subarray(0, bytesRead); - } finally { - await handle.close(); - } -} - -// src/control-socket.ts -import { chmod, unlink } from "node:fs/promises"; -import { createServer, connect } from "node:net"; -import { dirname as dirname2 } from "node:path"; -class ControlSocketError extends Error { - code; - name = "ControlSocketError"; - constructor(code, message, options) { - super(message, options); - this.code = code; - } -} -var MAXIMUM_SOCKET_PATH_BYTES = 100; -var DEFAULT_MAXIMUM_CONNECTIONS = 16; -var DEFAULT_HEADER_TIMEOUT_MS = 5000; -var DEFAULT_IDLE_TIMEOUT_MS = 1e4; -var MAXIMUM_TIMEOUT_MS = 3600000; -var boundedTimeoutMs = (value, fallback) => { - const candidate = value ?? fallback; - if (!Number.isSafeInteger(candidate) || candidate < 1 || candidate > MAXIMUM_TIMEOUT_MS) { - throw new Error("Control socket timeouts must be positive integers within one hour."); - } - return candidate; -}; -var boundedCount = (value, fallback, maximum) => { - const candidate = value ?? fallback; - if (!Number.isSafeInteger(candidate) || candidate < 1 || candidate > maximum) { - throw new Error("Control socket bounds must be positive integers."); - } - return candidate; -}; -var socketIdentity = (path) => assertOwnedPath(path, { kind: "socket", exactMode: PRIVATE_FILE_MODE, links: 1 }); -var sameIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino; -var resolveBounds = (options) => { - const maximumFrameBytes = options.maximumFrameBytes; - if (!Number.isSafeInteger(maximumFrameBytes) || maximumFrameBytes < 1) { - throw new Error("Control frame bound must be a positive integer."); - } - return { - maximumFrameBytes, - maximumResponseBytes: options.maximumResponseBytes ?? maximumFrameBytes, - maximumConnections: boundedCount(options.maximumConnections, DEFAULT_MAXIMUM_CONNECTIONS, 1024), - maximumRequests: boundedCount(options.maximumRequestsPerConnection, 1, 1024), - headerTimeoutMs: boundedTimeoutMs(options.headerTimeoutMs, DEFAULT_HEADER_TIMEOUT_MS), - idleTimeoutMs: boundedTimeoutMs(options.idleTimeoutMs, DEFAULT_IDLE_TIMEOUT_MS) - }; -}; -function attachControlSocket(server, options) { - const bounds = resolveBounds(options); - const failure = options.failureResponse; - const encode = (value, reason) => { - const bytes = Buffer.from(`${JSON.stringify(value)} -`); - if (bytes.length <= bounds.maximumResponseBytes) - return bytes; - const fallback = Buffer.from(`${JSON.stringify(failure(reason))} -`); - if (fallback.length <= bounds.maximumResponseBytes) - return fallback; - return Buffer.alloc(0); - }; - const clients = new Set; - const work = new Set; - let closing = false; - server.on("connection", (socket) => { - if (closing || clients.size >= bounds.maximumConnections) { - const bytes = encode(failure("capacity"), "capacity"); - if (bytes.length === 0) - socket.destroy(); - else - socket.end(bytes); - return; - } - clients.add(socket); - let idleTimer; - socket.once("close", () => { - clients.delete(socket); - if (idleTimer !== undefined) - clearTimeout(idleTimer); - }); - socket.on("error", () => { - return; - }); - const controller = new AbortController; - let received = Buffer.alloc(0); - let requests = 0; - let chain = Promise.resolve(); - const headerTimer = setTimeout(() => socket.destroy(), bounds.headerTimeoutMs); - headerTimer.unref(); - const armIdle = () => { - if (idleTimer !== undefined) - clearTimeout(idleTimer); - if (requests >= bounds.maximumRequests) - return; - idleTimer = setTimeout(() => socket.destroy(), bounds.idleTimeoutMs); - idleTimer.unref(); - }; - socket.on("data", (chunk) => { - if (closing) { - socket.destroy(); - return; - } - received = Buffer.concat([received, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); - while (true) { - const newline = received.indexOf(10); - if (newline < 0) { - if (received.byteLength >= bounds.maximumFrameBytes) - socket.destroy(); - return; - } - if (newline === 0 || newline + 1 > bounds.maximumFrameBytes || requests >= bounds.maximumRequests) { - const bytes = encode(failure("limit"), "limit"); - if (bytes.length === 0) - socket.destroy(); - else - socket.end(bytes); - return; - } - const frame = received.subarray(0, newline); - received = received.subarray(newline + 1); - requests += 1; - clearTimeout(headerTimer); - const task = chain.catch(() => { - return; - }).then(async () => { - if (closing || socket.destroyed) - return; - let response; - try { - const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(frame)); - response = await options.onRequest(value, { signal: controller.signal }); - } catch { - response = failure("invalid-request"); - } - if (!socket.destroyed && !socket.writableEnded) { - const bytes = encode(response, "response-limit"); - if (bytes.length === 0 || socket.writableLength + bytes.length > bounds.maximumResponseBytes) { - socket.destroy(); - } else { - socket.write(bytes, () => { - if (requests >= bounds.maximumRequests) - socket.end(); - else - armIdle(); - }); - } - } - }).finally(() => work.delete(task)); - work.add(task); - chain = task; - } - }); - socket.on("end", () => { - if (received.byteLength !== 0) - socket.destroy(); - }); - }); - let closePromise; - return { - close() { - closePromise ??= (async () => { - closing = true; - for (const client of clients) - client.destroy(); - await new Promise((resolve2, reject) => server.close((error) => error !== undefined && error.code !== "ERR_SERVER_NOT_RUNNING" ? reject(error) : resolve2())); - await Promise.allSettled([...work]); - })(); - return closePromise; - } - }; -} -async function listenControlSocket(options) { - const socketPath = options.socketPath; - if (Buffer.byteLength(socketPath) > MAXIMUM_SOCKET_PATH_BYTES) { - throw new Error("Control socket path exceeds its platform limit."); - } - await ensurePrivateDirectory(dirname2(socketPath)); - try { - await socketIdentity(socketPath); - await unlink(socketPath); - } catch (error) { - if (error.code !== "ENOENT") - throw error; - } - const server = createServer(); - const transport = attachControlSocket(server, options); - let published; - try { - await new Promise((resolve2, reject) => { - server.once("error", reject); - server.listen(socketPath, () => { - server.off("error", reject); - resolve2(); - }); - }); - await chmod(socketPath, PRIVATE_FILE_MODE); - published = await socketIdentity(socketPath); - } catch (error) { - await transport.close().catch(() => { - return; - }); - throw error; - } - return { - socketPath, - async close() { - const failures = []; - try { - await transport.close(); - } catch (error) { - failures.push(error); - } - try { - const current = await socketIdentity(socketPath).catch(() => null); - if (current !== null && sameIdentity(current, published)) { - await unlink(socketPath); - } - } catch (error) { - failures.push(error); - } - if (failures.length > 0) { - throw failures.length === 1 ? failures[0] : new AggregateError(failures, "Control socket cleanup requires attention."); - } - } - }; -} -async function requestControlSocket(options) { - const { socketPath, maximumResponseBytes, timeoutMs } = options; - if (!Number.isSafeInteger(maximumResponseBytes) || maximumResponseBytes < 1) { - throw new Error("Control response bound must be a positive integer."); - } - if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAXIMUM_TIMEOUT_MS) { - throw new Error("Control request timeout must be a positive integer within one hour."); - } - const maximumRequestBytes = options.maximumRequestBytes ?? maximumResponseBytes; - const frame = Buffer.from(`${JSON.stringify(options.request)} -`); - if (frame.length > maximumRequestBytes) { - throw new Error("Control request exceeds its frame limit."); - } - let before; - try { - await assertOwnedPath(dirname2(socketPath), { kind: "directory", canonical: true }); - before = await socketIdentity(socketPath); - } catch (error) { - if (error.code === "ENOENT") { - throw new ControlSocketError("control-unavailable", "The control socket is unavailable.", { cause: error }); - } - throw error; - } - return new Promise((resolvePromise, rejectPromise) => { - const socket = connect(socketPath); - let buffer = Buffer.alloc(0); - let settled = false; - const settle = (error, value) => { - if (settled) - return; - settled = true; - clearTimeout(timer); - socket.destroy(); - if (error !== null) - rejectPromise(error); - else - resolvePromise(value); - }; - const timer = setTimeout(() => settle(new ControlSocketError("control-timeout", "Control request timed out.")), timeoutMs); - socket.once("connect", () => { - socketIdentity(socketPath).then((after) => { - if (!sameIdentity(before, after)) - throw new ControlSocketError("control-identity-changed", "Control socket identity changed."); - if (!settled) - socket.write(frame); - }).catch((error) => settle(error instanceof Error ? error : new Error("Control socket changed."))); - }); - socket.on("data", (chunk) => { - buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); - if (buffer.length > maximumResponseBytes) { - settle(new ControlSocketError("control-response-too-large", "Control response exceeds its frame limit.")); - return; - } - const newline = buffer.indexOf(10); - if (newline < 0) - return; - if (newline !== buffer.length - 1) { - settle(new ControlSocketError("control-extra-output", "Unexpected additional control output.")); - return; - } - try { - const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, newline))); - settle(null, options.parseResponse(value)); - } catch { - settle(new ControlSocketError("control-invalid-response", "Invalid control response.")); - } - }); - socket.once("error", () => settle(new ControlSocketError("control-unavailable", "The control socket is unavailable."))); - socket.once("close", () => { - if (!settled) - settle(new ControlSocketError("control-closed", "The control socket closed without a response.")); - }); - }); -} + ControlSocketError, + MAXIMUM_SOCKET_PATH_BYTES, + attachControlSocket, + listenControlSocket, + requestControlSocket +} from "./chunk-k2mgjyx4.js"; +import"./chunk-xrgz1k0h.js"; export { requestControlSocket, listenControlSocket, diff --git a/dist/custody-rust.js b/dist/custody-rust.js index a522e7c..34dbfdf 100644 --- a/dist/custody-rust.js +++ b/dist/custody-rust.js @@ -1,668 +1,31 @@ -// src/private-paths.ts import { - closeSync, - constants, - fstatSync, - lstatSync, - openSync, - readSync, - realpathSync -} from "node:fs"; -import { lstat, mkdir, open, realpath } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -var PRIVATE_DIRECTORY_MODE = 448; -var PRIVATE_FILE_MODE = 384; -var ownerUid = () => typeof process.getuid === "function" ? process.getuid() : undefined; -var kindMatches = (metadata, kind) => kind === "file" ? metadata.isFile() : kind === "directory" ? metadata.isDirectory() : metadata.isSocket(); -async function assertOwnedPath(path, expectation) { - const metadata = await lstat(path, { bigint: true }); - const uid = ownerUid(); - const expectedLinks = expectation.links ?? (expectation.kind === "directory" ? undefined : 1n); - if (!kindMatches(metadata, expectation.kind) || metadata.isSymbolicLink() || expectedLinks !== undefined && metadata.nlink !== BigInt(expectedLinks) || uid !== undefined && metadata.uid !== BigInt(uid) || expectation.exactMode !== undefined && (metadata.mode & 0o777n) !== BigInt(expectation.exactMode) || expectation.ownerOnly === true && (metadata.mode & 0o077n) !== 0n || expectation.canonical === true && await realpath(path) !== path || expectation.minimumBytes !== undefined && metadata.size < expectation.minimumBytes || expectation.maximumBytes !== undefined && metadata.size > expectation.maximumBytes) { - throw new Error(`Unsafe local ${expectation.kind}.`); - } - return { - dev: Number(metadata.dev), - ino: Number(metadata.ino), - size: Number(metadata.size) - }; -} -function assertOwnedPathSync(path, expectation) { - const metadata = lstatSync(path, { bigint: true }); - const uid = ownerUid(); - const expectedLinks = expectation.links ?? (expectation.kind === "directory" ? undefined : 1n); - if (!kindMatches(metadata, expectation.kind) || metadata.isSymbolicLink() || expectedLinks !== undefined && metadata.nlink !== BigInt(expectedLinks) || uid !== undefined && metadata.uid !== BigInt(uid) || expectation.exactMode !== undefined && (metadata.mode & 0o777n) !== BigInt(expectation.exactMode) || expectation.ownerOnly === true && (metadata.mode & 0o077n) !== 0n || expectation.canonical === true && realpathSync(path) !== path || expectation.minimumBytes !== undefined && metadata.size < expectation.minimumBytes || expectation.maximumBytes !== undefined && metadata.size > expectation.maximumBytes) { - throw new Error(`Unsafe local ${expectation.kind}.`); - } - return { - dev: Number(metadata.dev), - ino: Number(metadata.ino), - size: Number(metadata.size) - }; -} -async function ensurePrivateDirectory(path) { - const absolute = resolve(path); - const parent = dirname(absolute); - if (await realpath(parent) !== parent) { - throw new Error("Directory parent must be physical."); - } - try { - await mkdir(absolute, { mode: PRIVATE_DIRECTORY_MODE }); - } catch (error) { - if (error.code !== "EEXIST") - throw error; - } - const metadata = await lstat(absolute); - if (await realpath(absolute) !== absolute || !metadata.isDirectory() || metadata.isSymbolicLink() || ownerUid() !== undefined && metadata.uid !== ownerUid() || (metadata.mode & 63) !== 0) { - throw new Error("Directory must be physical, owned, and private."); - } - return absolute; -} -var checkStableCandidate = (before, maximumBytes, expectation) => { - const uid = ownerUid(); - const links = BigInt(expectation.links ?? 1); - if (!before.isFile() || before.nlink !== links || uid !== undefined && before.uid !== BigInt(uid) || (expectation.exactMode !== undefined ? (before.mode & 0o777n) !== BigInt(expectation.exactMode) : expectation.ownerOnly !== false && (before.mode & 0o077n) !== 0n) || expectation.minimumBytes !== undefined && before.size < expectation.minimumBytes || before.size > BigInt(maximumBytes)) { - throw new Error("Unsafe private file."); - } -}; -var checkStableResult = (before, after) => { - if (after.isSymbolicLink() || !after.isFile() || after.dev !== before.dev || after.ino !== before.ino || after.nlink !== before.nlink || after.mode !== before.mode || after.uid !== before.uid || after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { - throw new Error("Private file changed during the read."); - } -}; -async function readOwnedFileStable(path, maximumBytes, expectation = {}) { - const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - try { - const before = await handle.stat({ bigint: true }); - checkStableCandidate(before, maximumBytes, expectation); - const buffer = Buffer.alloc(Number(before.size)); - let offset = 0; - while (offset < buffer.byteLength) { - const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, null); - if (bytesRead === 0) - break; - offset += bytesRead; - } - const after = await lstat(path, { bigint: true }); - checkStableResult(before, after); - if (offset !== buffer.byteLength) { - throw new Error("Private file changed during the read."); - } - return { bytes: buffer, dev: Number(before.dev), ino: Number(before.ino) }; - } finally { - await handle.close(); - } -} -function readOwnedFileStableSync(path, maximumBytes, expectation = {}) { - const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - try { - const before = fstatSync(descriptor, { bigint: true }); - checkStableCandidate(before, maximumBytes, expectation); - const buffer = Buffer.alloc(Number(before.size)); - let offset = 0; - while (offset < buffer.byteLength) { - const count = readSync(descriptor, buffer, offset, buffer.byteLength - offset, null); - if (count === 0) - break; - offset += count; - } - const after = lstatSync(path, { bigint: true }); - checkStableResult(before, after); - if (offset !== buffer.byteLength) { - throw new Error("Private file changed during the read."); - } - return { bytes: buffer, dev: Number(before.dev), ino: Number(before.ino) }; - } finally { - closeSync(descriptor); - } -} -async function readPrivateFile(path, maximumBytes) { - const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - try { - const metadata = await handle.stat(); - const uid = ownerUid(); - if (!metadata.isFile() || metadata.nlink !== 1 || uid !== undefined && metadata.uid !== uid || (metadata.mode & 63) !== 0 || metadata.size > maximumBytes) { - throw new Error("Unsafe private file."); - } - const buffer = Buffer.alloc(maximumBytes + 1); - const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); - if (bytesRead > maximumBytes) - throw new Error("Private file exceeds its size bound."); - return buffer.subarray(0, bytesRead); - } finally { - await handle.close(); - } -} - -// src/atomic-publish.ts -import { randomUUID } from "node:crypto"; + createPrivateFileOnce, + publishPrivateFile +} from "./chunk-605s349d.js"; import { - closeSync as closeSync2, - constants as constants2, - fchmodSync, - fsyncSync, - linkSync, - openSync as openSync2, - unlinkSync, - writeSync -} from "node:fs"; -import { link, open as open2, rename, unlink } from "node:fs/promises"; -import { join } from "node:path"; -var safeFileName = /^[A-Za-z0-9][A-Za-z0-9._-]{0,126}$/u; -var assertSafeName = (name) => { - if (!safeFileName.test(name) || Buffer.byteLength(name) > 128) { - throw new Error("Unsafe publish name."); - } -}; -var syncDirectory = async (directory) => { - const handle = await open2(directory, constants2.O_RDONLY); - try { - await handle.sync(); - } finally { - await handle.close(); - } -}; -var writeStaged = async (staged, content) => { - const handle = await open2(staged, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY | constants2.O_NOFOLLOW, PRIVATE_FILE_MODE); - try { - await handle.chmod(PRIVATE_FILE_MODE); - await handle.writeFile(content); - await handle.sync(); - } finally { - await handle.close(); - } -}; -async function publishPrivateFile(directory, name, content, options = {}) { - assertSafeName(name); - const target = join(directory, name); - const temporary = join(directory, `.${name}.${randomUUID()}.tmp`); - try { - await writeStaged(temporary, content); - await options.beforeCommit?.(target); - await rename(temporary, target); - await syncDirectory(directory); - await assertOwnedPath(target, { - kind: "file", - exactMode: PRIVATE_FILE_MODE, - links: 1 - }); - } catch (error) { - await unlink(temporary).catch(() => { - return; - }); - throw error; - } -} -async function createPrivateFileOnce(directory, name, content) { - assertSafeName(name); - const target = join(directory, name); - const temporary = join(directory, `.${name}.${randomUUID()}.tmp`); - try { - await writeStaged(temporary, content); - try { - await link(temporary, target); - } catch (error) { - if (error.code === "EEXIST") - return "existing"; - throw error; - } - await syncDirectory(directory); - await assertOwnedPath(target, { - kind: "file", - exactMode: PRIVATE_FILE_MODE, - links: 2 - }); - return "created"; - } finally { - await unlink(temporary).catch(() => { - return; - }); - await syncDirectory(directory).catch(() => { - return; - }); - } -} -var writeStagedSync = (staged, content) => { - const descriptor = openSync2(staged, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY | constants2.O_NOFOLLOW, PRIVATE_FILE_MODE); - try { - fchmodSync(descriptor, PRIVATE_FILE_MODE); - const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"); - let offset = 0; - while (offset < bytes.byteLength) { - offset += writeSync(descriptor, bytes, offset, bytes.byteLength - offset); - } - fsyncSync(descriptor); - } finally { - closeSync2(descriptor); - } -}; -var syncDirectorySync = (directory) => { - const descriptor = openSync2(directory, constants2.O_RDONLY); - try { - fsyncSync(descriptor); - } finally { - closeSync2(descriptor); - } -}; -function createPrivateFileOnceSync(directory, name, content) { - assertSafeName(name); - const target = join(directory, name); - const temporary = join(directory, `.${name}.${randomUUID()}.tmp`); - try { - writeStagedSync(temporary, content); - try { - linkSync(temporary, target); - } catch (error) { - if (error.code === "EEXIST") - return "existing"; - throw error; - } - syncDirectorySync(directory); - assertOwnedPathSync(target, { - kind: "file", - exactMode: PRIVATE_FILE_MODE, - links: 2 - }); - return "created"; - } finally { - try { - unlinkSync(temporary); - } catch {} - try { - syncDirectorySync(directory); - } catch {} - } -} - -// src/control-socket.ts -import { chmod, unlink as unlink2 } from "node:fs/promises"; -import { createServer, connect } from "node:net"; -import { dirname as dirname2 } from "node:path"; -class ControlSocketError extends Error { - code; - name = "ControlSocketError"; - constructor(code, message, options) { - super(message, options); - this.code = code; - } -} -var MAXIMUM_SOCKET_PATH_BYTES = 100; -var DEFAULT_MAXIMUM_CONNECTIONS = 16; -var DEFAULT_HEADER_TIMEOUT_MS = 5000; -var DEFAULT_IDLE_TIMEOUT_MS = 1e4; -var MAXIMUM_TIMEOUT_MS = 3600000; -var boundedTimeoutMs = (value, fallback) => { - const candidate = value ?? fallback; - if (!Number.isSafeInteger(candidate) || candidate < 1 || candidate > MAXIMUM_TIMEOUT_MS) { - throw new Error("Control socket timeouts must be positive integers within one hour."); - } - return candidate; -}; -var boundedCount = (value, fallback, maximum) => { - const candidate = value ?? fallback; - if (!Number.isSafeInteger(candidate) || candidate < 1 || candidate > maximum) { - throw new Error("Control socket bounds must be positive integers."); - } - return candidate; -}; -var socketIdentity = (path) => assertOwnedPath(path, { kind: "socket", exactMode: PRIVATE_FILE_MODE, links: 1 }); -var sameIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino; -var resolveBounds = (options) => { - const maximumFrameBytes = options.maximumFrameBytes; - if (!Number.isSafeInteger(maximumFrameBytes) || maximumFrameBytes < 1) { - throw new Error("Control frame bound must be a positive integer."); - } - return { - maximumFrameBytes, - maximumResponseBytes: options.maximumResponseBytes ?? maximumFrameBytes, - maximumConnections: boundedCount(options.maximumConnections, DEFAULT_MAXIMUM_CONNECTIONS, 1024), - maximumRequests: boundedCount(options.maximumRequestsPerConnection, 1, 1024), - headerTimeoutMs: boundedTimeoutMs(options.headerTimeoutMs, DEFAULT_HEADER_TIMEOUT_MS), - idleTimeoutMs: boundedTimeoutMs(options.idleTimeoutMs, DEFAULT_IDLE_TIMEOUT_MS) - }; -}; -function attachControlSocket(server, options) { - const bounds = resolveBounds(options); - const failure = options.failureResponse; - const encode = (value, reason) => { - const bytes = Buffer.from(`${JSON.stringify(value)} -`); - if (bytes.length <= bounds.maximumResponseBytes) - return bytes; - const fallback = Buffer.from(`${JSON.stringify(failure(reason))} -`); - if (fallback.length <= bounds.maximumResponseBytes) - return fallback; - return Buffer.alloc(0); - }; - const clients = new Set; - const work = new Set; - let closing = false; - server.on("connection", (socket) => { - if (closing || clients.size >= bounds.maximumConnections) { - const bytes = encode(failure("capacity"), "capacity"); - if (bytes.length === 0) - socket.destroy(); - else - socket.end(bytes); - return; - } - clients.add(socket); - let idleTimer; - socket.once("close", () => { - clients.delete(socket); - if (idleTimer !== undefined) - clearTimeout(idleTimer); - }); - socket.on("error", () => { - return; - }); - const controller = new AbortController; - let received = Buffer.alloc(0); - let requests = 0; - let chain = Promise.resolve(); - const headerTimer = setTimeout(() => socket.destroy(), bounds.headerTimeoutMs); - headerTimer.unref(); - const armIdle = () => { - if (idleTimer !== undefined) - clearTimeout(idleTimer); - if (requests >= bounds.maximumRequests) - return; - idleTimer = setTimeout(() => socket.destroy(), bounds.idleTimeoutMs); - idleTimer.unref(); - }; - socket.on("data", (chunk) => { - if (closing) { - socket.destroy(); - return; - } - received = Buffer.concat([received, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); - while (true) { - const newline = received.indexOf(10); - if (newline < 0) { - if (received.byteLength >= bounds.maximumFrameBytes) - socket.destroy(); - return; - } - if (newline === 0 || newline + 1 > bounds.maximumFrameBytes || requests >= bounds.maximumRequests) { - const bytes = encode(failure("limit"), "limit"); - if (bytes.length === 0) - socket.destroy(); - else - socket.end(bytes); - return; - } - const frame = received.subarray(0, newline); - received = received.subarray(newline + 1); - requests += 1; - clearTimeout(headerTimer); - const task = chain.catch(() => { - return; - }).then(async () => { - if (closing || socket.destroyed) - return; - let response; - try { - const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(frame)); - response = await options.onRequest(value, { signal: controller.signal }); - } catch { - response = failure("invalid-request"); - } - if (!socket.destroyed && !socket.writableEnded) { - const bytes = encode(response, "response-limit"); - if (bytes.length === 0 || socket.writableLength + bytes.length > bounds.maximumResponseBytes) { - socket.destroy(); - } else { - socket.write(bytes, () => { - if (requests >= bounds.maximumRequests) - socket.end(); - else - armIdle(); - }); - } - } - }).finally(() => work.delete(task)); - work.add(task); - chain = task; - } - }); - socket.on("end", () => { - if (received.byteLength !== 0) - socket.destroy(); - }); - }); - let closePromise; - return { - close() { - closePromise ??= (async () => { - closing = true; - for (const client of clients) - client.destroy(); - await new Promise((resolve2, reject) => server.close((error) => error !== undefined && error.code !== "ERR_SERVER_NOT_RUNNING" ? reject(error) : resolve2())); - await Promise.allSettled([...work]); - })(); - return closePromise; - } - }; -} -async function listenControlSocket(options) { - const socketPath = options.socketPath; - if (Buffer.byteLength(socketPath) > MAXIMUM_SOCKET_PATH_BYTES) { - throw new Error("Control socket path exceeds its platform limit."); - } - await ensurePrivateDirectory(dirname2(socketPath)); - try { - await socketIdentity(socketPath); - await unlink2(socketPath); - } catch (error) { - if (error.code !== "ENOENT") - throw error; - } - const server = createServer(); - const transport = attachControlSocket(server, options); - let published; - try { - await new Promise((resolve2, reject) => { - server.once("error", reject); - server.listen(socketPath, () => { - server.off("error", reject); - resolve2(); - }); - }); - await chmod(socketPath, PRIVATE_FILE_MODE); - published = await socketIdentity(socketPath); - } catch (error) { - await transport.close().catch(() => { - return; - }); - throw error; - } - return { - socketPath, - async close() { - const failures = []; - try { - await transport.close(); - } catch (error) { - failures.push(error); - } - try { - const current = await socketIdentity(socketPath).catch(() => null); - if (current !== null && sameIdentity(current, published)) { - await unlink2(socketPath); - } - } catch (error) { - failures.push(error); - } - if (failures.length > 0) { - throw failures.length === 1 ? failures[0] : new AggregateError(failures, "Control socket cleanup requires attention."); - } - } - }; -} -async function requestControlSocket(options) { - const { socketPath, maximumResponseBytes, timeoutMs } = options; - if (!Number.isSafeInteger(maximumResponseBytes) || maximumResponseBytes < 1) { - throw new Error("Control response bound must be a positive integer."); - } - if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAXIMUM_TIMEOUT_MS) { - throw new Error("Control request timeout must be a positive integer within one hour."); - } - const maximumRequestBytes = options.maximumRequestBytes ?? maximumResponseBytes; - const frame = Buffer.from(`${JSON.stringify(options.request)} -`); - if (frame.length > maximumRequestBytes) { - throw new Error("Control request exceeds its frame limit."); - } - let before; - try { - await assertOwnedPath(dirname2(socketPath), { kind: "directory", canonical: true }); - before = await socketIdentity(socketPath); - } catch (error) { - if (error.code === "ENOENT") { - throw new ControlSocketError("control-unavailable", "The control socket is unavailable.", { cause: error }); - } - throw error; - } - return new Promise((resolvePromise, rejectPromise) => { - const socket = connect(socketPath); - let buffer = Buffer.alloc(0); - let settled = false; - const settle = (error, value) => { - if (settled) - return; - settled = true; - clearTimeout(timer); - socket.destroy(); - if (error !== null) - rejectPromise(error); - else - resolvePromise(value); - }; - const timer = setTimeout(() => settle(new ControlSocketError("control-timeout", "Control request timed out.")), timeoutMs); - socket.once("connect", () => { - socketIdentity(socketPath).then((after) => { - if (!sameIdentity(before, after)) - throw new ControlSocketError("control-identity-changed", "Control socket identity changed."); - if (!settled) - socket.write(frame); - }).catch((error) => settle(error instanceof Error ? error : new Error("Control socket changed."))); - }); - socket.on("data", (chunk) => { - buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); - if (buffer.length > maximumResponseBytes) { - settle(new ControlSocketError("control-response-too-large", "Control response exceeds its frame limit.")); - return; - } - const newline = buffer.indexOf(10); - if (newline < 0) - return; - if (newline !== buffer.length - 1) { - settle(new ControlSocketError("control-extra-output", "Unexpected additional control output.")); - return; - } - try { - const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, newline))); - settle(null, options.parseResponse(value)); - } catch { - settle(new ControlSocketError("control-invalid-response", "Invalid control response.")); - } - }); - socket.once("error", () => settle(new ControlSocketError("control-unavailable", "The control socket is unavailable."))); - socket.once("close", () => { - if (!settled) - settle(new ControlSocketError("control-closed", "The control socket closed without a response.")); - }); - }); -} - -// src/protected-input.ts -import { fstatSync as fstatSync2, readSync as readSync2 } from "node:fs"; -import { isatty } from "node:tty"; -var DEFAULT_PROTECTED_INPUT_MAXIMUM_BYTES = 65536; - -class ProtectedInputError extends Error { - code; - name = "ProtectedInputError"; - constructor(code, message) { - super(message); - this.code = code; - } -} -var PROTECTED_INPUT_TERMINAL_MESSAGE = "Pipe or redirect the value in instead of typing it, so it stays out of your terminal history."; -function readProtectedDescriptor(descriptor, options = {}) { - const maximumBytes = options.maximumBytes ?? DEFAULT_PROTECTED_INPUT_MAXIMUM_BYTES; - if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) { - throw new Error("Protected input bound must be a positive integer."); - } - if (!Number.isSafeInteger(descriptor) || descriptor < 0) { - throw new Error("Protected input requires a valid descriptor."); - } - if (isatty(descriptor)) { - throw new ProtectedInputError("protected-terminal", PROTECTED_INPUT_TERMINAL_MESSAGE); - } - const metadata = fstatSync2(descriptor, { bigint: true }); - if (metadata.isFile()) { - const uid = typeof process.getuid === "function" ? process.getuid() : undefined; - if (uid !== undefined && metadata.uid !== BigInt(uid) || (metadata.mode & 0o077n) !== 0n) { - throw new ProtectedInputError("protected-unsafe-file", "Protected input file must be owned and private."); - } - } - const buffer = Buffer.alloc(maximumBytes + 1); - let offset = 0; - while (true) { - const read = readSync2(descriptor, buffer, offset, buffer.length - offset, null); - if (read === 0) - break; - offset += read; - if (offset > maximumBytes) - throw new ProtectedInputError("protected-too-large", "Protected input exceeds its size bound."); - if (offset === buffer.length) - throw new ProtectedInputError("protected-too-large", "Protected input exceeds its size bound."); - } - return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset)); -} -function readProtectedStdin(options = {}) { - return readProtectedDescriptor(0, options); -} - -// src/rust-fallback.ts -var MAX_FALLBACK_TAGS = 32; -var MAX_FALLBACK_NOTICES_PER_TAG = 4; -var DIAGNOSTIC_FIELD = /^[A-Za-z0-9._-]{1,64}$/u; -var emittedNotices = new Map; -function boundedField(value) { - return DIAGNOSTIC_FIELD.test(value) ? value : "other"; -} -function emitLocalCustodyFallback(notice) { - const tag = boundedField(notice.tag); - const reason = boundedField(notice.reason); - const inputClass = notice.inputClass === undefined ? undefined : boundedField(notice.inputClass); - const diagnostic = inputClass === undefined ? reason : `${reason}:${inputClass}`; - let seen = emittedNotices.get(tag); - if (seen === undefined) { - if (emittedNotices.size >= MAX_FALLBACK_TAGS) - return; - seen = new Set; - emittedNotices.set(tag, seen); - } - if (seen.has(diagnostic) || seen.size >= MAX_FALLBACK_NOTICES_PER_TAG) - return; - seen.add(diagnostic); - try { - if (typeof process !== "undefined" && typeof process.stderr?.write === "function") { - const detail = inputClass === undefined ? reason : `${reason} input=${inputClass}`; - process.stderr.write(`[${tag}] ${detail} -`); - } - } catch {} -} + attachControlSocket, + listenControlSocket, + requestControlSocket +} from "./chunk-k2mgjyx4.js"; +import { + assertOwnedPath, + ensurePrivateDirectory, + readOwnedFileStable +} from "./chunk-xrgz1k0h.js"; +import { + DEFAULT_PROTECTED_INPUT_MAXIMUM_BYTES, + readProtectedDescriptor, + readProtectedStdin +} from "./chunk-8gyk2127.js"; +import { + emitLocalCustodyFallback +} from "./chunk-np3mgd4s.js"; // src/custody-rust.ts import { spawn } from "node:child_process"; -import { fstatSync as fstatSync3 } from "node:fs"; -import { lstat as lstat2, realpath as realpath2, stat } from "node:fs/promises"; -import { dirname as dirname3, resolve as resolve2 } from "node:path"; +import { fstatSync } from "node:fs"; +import { lstat, realpath, stat } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; var SPAWN_TIMEOUT_MS = 120000; var ENVELOPE_SLACK_BYTES = 16 * 1024; @@ -732,15 +95,15 @@ function currentPlatformArch() { } function artifactBaseDirectory() { const modulePath = fileURLToPath(import.meta.url); - const moduleDir = dirname3(modulePath); - const base = moduleDir.endsWith("/src") || moduleDir.endsWith("\\src") ? resolve2(moduleDir, "..", "dist") : moduleDir; - return resolve2(base, "rust-artifacts", "local-custody"); + const moduleDir = dirname(modulePath); + const base = moduleDir.endsWith("/src") || moduleDir.endsWith("\\src") ? resolve(moduleDir, "..", "dist") : moduleDir; + return resolve(base, "rust-artifacts", "local-custody"); } function sidecarBinaryPath(platform = currentPlatformArch().platform, arch = currentPlatformArch().arch) { const override = process.env.HRANESS_LOCAL_CUSTODY_CLI_PATH; if (override !== undefined && override.length > 0) - return resolve2(override); - return resolve2(artifactBaseDirectory(), `${platform}-${arch}`, "local-custody"); + return resolve(override); + return resolve(artifactBaseDirectory(), `${platform}-${arch}`, "local-custody"); } async function runSidecar(binaryPath, requestJson, maximumResponseBytes, sharedDescriptor) { const stdio = sharedDescriptor === undefined ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe", sharedDescriptor]; @@ -859,7 +222,7 @@ function base64Bound(contentBytes) { } var BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/u; async function rustEnsurePrivateDirectory(binary, path) { - const absolute = resolve2(path); + const absolute = resolve(path); const parsed = await runRequest(binary, { op: "ensure_private_directory", path: absolute }, FIXED_REQUEST_BYTES, FIXED_RESPONSE_BYTES); const candidate = parsed; if (!isRecord(parsed) || candidate.path !== absolute || !isSafeCount(candidate.dev) || !isSafeCount(candidate.ino)) { @@ -920,11 +283,11 @@ async function rustReadOwnedFileStable(binary, path, maximumBytes, expectation) return Object.freeze({ bytes, dev: candidate.dev, ino: candidate.ino }); } async function publishDirectoryEligible(directory) { - const absolute = resolve2(directory); + const absolute = resolve(directory); try { - const metadata = await lstat2(absolute); + const metadata = await lstat(absolute); const uid = typeof process.getuid === "function" ? process.getuid() : undefined; - if (!metadata.isDirectory() || metadata.isSymbolicLink() || uid !== undefined && metadata.uid !== uid || (metadata.mode & 63) !== 0 || await realpath2(absolute) !== absolute || await realpath2(dirname3(absolute)) !== dirname3(absolute)) { + if (!metadata.isDirectory() || metadata.isSymbolicLink() || uid !== undefined && metadata.uid !== uid || (metadata.mode & 63) !== 0 || await realpath(absolute) !== absolute || await realpath(dirname(absolute)) !== dirname(absolute)) { return false; } return true; @@ -941,7 +304,7 @@ async function rustAtomicPublish(binary, directory, name, content, createOnce) { const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"); const parsed = await runRequest(binary, { op: "atomic_publish", - dir: resolve2(directory), + dir: resolve(directory), name, contentBase64: bytes.toString("base64"), createOnce @@ -974,7 +337,7 @@ async function rustRequestControlSocket(binary, options) { if (frame.length > maximumRequestBytes) { throw new Error("Control request exceeds its frame limit."); } - await assertOwnedPath(dirname3(socketPath), { kind: "directory", canonical: true }); + await assertOwnedPath(dirname(socketPath), { kind: "directory", canonical: true }); const parsed = await runRequest(binary, { op: "control_socket_request", socketPath, @@ -1089,7 +452,7 @@ async function loadLocalCustodyRustEngine() { if (!Number.isSafeInteger(descriptor) || descriptor < 0) { throw new Error("Protected input requires a valid descriptor."); } - const metadata = fstatSync3(descriptor); + const metadata = fstatSync(descriptor); if (!metadata.isFile()) { fallbackNotice("unsupported-input", descriptorClass(metadata)); return readProtectedDescriptor(descriptor, options); diff --git a/dist/index.js b/dist/index.js index 238a8ef..1c7692f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,630 +1,32 @@ -// src/private-paths.ts import { - closeSync, - constants, - fstatSync, - lstatSync, - openSync, - readSync, - realpathSync -} from "node:fs"; -import { lstat, mkdir, open, realpath } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -var PRIVATE_DIRECTORY_MODE = 448; -var PRIVATE_FILE_MODE = 384; -var ownerUid = () => typeof process.getuid === "function" ? process.getuid() : undefined; -var kindMatches = (metadata, kind) => kind === "file" ? metadata.isFile() : kind === "directory" ? metadata.isDirectory() : metadata.isSocket(); -async function assertOwnedPath(path, expectation) { - const metadata = await lstat(path, { bigint: true }); - const uid = ownerUid(); - const expectedLinks = expectation.links ?? (expectation.kind === "directory" ? undefined : 1n); - if (!kindMatches(metadata, expectation.kind) || metadata.isSymbolicLink() || expectedLinks !== undefined && metadata.nlink !== BigInt(expectedLinks) || uid !== undefined && metadata.uid !== BigInt(uid) || expectation.exactMode !== undefined && (metadata.mode & 0o777n) !== BigInt(expectation.exactMode) || expectation.ownerOnly === true && (metadata.mode & 0o077n) !== 0n || expectation.canonical === true && await realpath(path) !== path || expectation.minimumBytes !== undefined && metadata.size < expectation.minimumBytes || expectation.maximumBytes !== undefined && metadata.size > expectation.maximumBytes) { - throw new Error(`Unsafe local ${expectation.kind}.`); - } - return { - dev: Number(metadata.dev), - ino: Number(metadata.ino), - size: Number(metadata.size) - }; -} -function assertOwnedPathSync(path, expectation) { - const metadata = lstatSync(path, { bigint: true }); - const uid = ownerUid(); - const expectedLinks = expectation.links ?? (expectation.kind === "directory" ? undefined : 1n); - if (!kindMatches(metadata, expectation.kind) || metadata.isSymbolicLink() || expectedLinks !== undefined && metadata.nlink !== BigInt(expectedLinks) || uid !== undefined && metadata.uid !== BigInt(uid) || expectation.exactMode !== undefined && (metadata.mode & 0o777n) !== BigInt(expectation.exactMode) || expectation.ownerOnly === true && (metadata.mode & 0o077n) !== 0n || expectation.canonical === true && realpathSync(path) !== path || expectation.minimumBytes !== undefined && metadata.size < expectation.minimumBytes || expectation.maximumBytes !== undefined && metadata.size > expectation.maximumBytes) { - throw new Error(`Unsafe local ${expectation.kind}.`); - } - return { - dev: Number(metadata.dev), - ino: Number(metadata.ino), - size: Number(metadata.size) - }; -} -async function ensurePrivateDirectory(path) { - const absolute = resolve(path); - const parent = dirname(absolute); - if (await realpath(parent) !== parent) { - throw new Error("Directory parent must be physical."); - } - try { - await mkdir(absolute, { mode: PRIVATE_DIRECTORY_MODE }); - } catch (error) { - if (error.code !== "EEXIST") - throw error; - } - const metadata = await lstat(absolute); - if (await realpath(absolute) !== absolute || !metadata.isDirectory() || metadata.isSymbolicLink() || ownerUid() !== undefined && metadata.uid !== ownerUid() || (metadata.mode & 63) !== 0) { - throw new Error("Directory must be physical, owned, and private."); - } - return absolute; -} -var checkStableCandidate = (before, maximumBytes, expectation) => { - const uid = ownerUid(); - const links = BigInt(expectation.links ?? 1); - if (!before.isFile() || before.nlink !== links || uid !== undefined && before.uid !== BigInt(uid) || (expectation.exactMode !== undefined ? (before.mode & 0o777n) !== BigInt(expectation.exactMode) : expectation.ownerOnly !== false && (before.mode & 0o077n) !== 0n) || expectation.minimumBytes !== undefined && before.size < expectation.minimumBytes || before.size > BigInt(maximumBytes)) { - throw new Error("Unsafe private file."); - } -}; -var checkStableResult = (before, after) => { - if (after.isSymbolicLink() || !after.isFile() || after.dev !== before.dev || after.ino !== before.ino || after.nlink !== before.nlink || after.mode !== before.mode || after.uid !== before.uid || after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { - throw new Error("Private file changed during the read."); - } -}; -async function readOwnedFileStable(path, maximumBytes, expectation = {}) { - const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - try { - const before = await handle.stat({ bigint: true }); - checkStableCandidate(before, maximumBytes, expectation); - const buffer = Buffer.alloc(Number(before.size)); - let offset = 0; - while (offset < buffer.byteLength) { - const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, null); - if (bytesRead === 0) - break; - offset += bytesRead; - } - const after = await lstat(path, { bigint: true }); - checkStableResult(before, after); - if (offset !== buffer.byteLength) { - throw new Error("Private file changed during the read."); - } - return { bytes: buffer, dev: Number(before.dev), ino: Number(before.ino) }; - } finally { - await handle.close(); - } -} -function readOwnedFileStableSync(path, maximumBytes, expectation = {}) { - const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - try { - const before = fstatSync(descriptor, { bigint: true }); - checkStableCandidate(before, maximumBytes, expectation); - const buffer = Buffer.alloc(Number(before.size)); - let offset = 0; - while (offset < buffer.byteLength) { - const count = readSync(descriptor, buffer, offset, buffer.byteLength - offset, null); - if (count === 0) - break; - offset += count; - } - const after = lstatSync(path, { bigint: true }); - checkStableResult(before, after); - if (offset !== buffer.byteLength) { - throw new Error("Private file changed during the read."); - } - return { bytes: buffer, dev: Number(before.dev), ino: Number(before.ino) }; - } finally { - closeSync(descriptor); - } -} -async function readPrivateFile(path, maximumBytes) { - const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - try { - const metadata = await handle.stat(); - const uid = ownerUid(); - if (!metadata.isFile() || metadata.nlink !== 1 || uid !== undefined && metadata.uid !== uid || (metadata.mode & 63) !== 0 || metadata.size > maximumBytes) { - throw new Error("Unsafe private file."); - } - const buffer = Buffer.alloc(maximumBytes + 1); - const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); - if (bytesRead > maximumBytes) - throw new Error("Private file exceeds its size bound."); - return buffer.subarray(0, bytesRead); - } finally { - await handle.close(); - } -} - -// src/atomic-publish.ts -import { randomUUID } from "node:crypto"; + createPrivateFileOnce, + createPrivateFileOnceSync, + publishPrivateFile +} from "./chunk-605s349d.js"; import { - closeSync as closeSync2, - constants as constants2, - fchmodSync, - fsyncSync, - linkSync, - openSync as openSync2, - unlinkSync, - writeSync -} from "node:fs"; -import { link, open as open2, rename, unlink } from "node:fs/promises"; -import { join } from "node:path"; -var safeFileName = /^[A-Za-z0-9][A-Za-z0-9._-]{0,126}$/u; -var assertSafeName = (name) => { - if (!safeFileName.test(name) || Buffer.byteLength(name) > 128) { - throw new Error("Unsafe publish name."); - } -}; -var syncDirectory = async (directory) => { - const handle = await open2(directory, constants2.O_RDONLY); - try { - await handle.sync(); - } finally { - await handle.close(); - } -}; -var writeStaged = async (staged, content) => { - const handle = await open2(staged, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY | constants2.O_NOFOLLOW, PRIVATE_FILE_MODE); - try { - await handle.chmod(PRIVATE_FILE_MODE); - await handle.writeFile(content); - await handle.sync(); - } finally { - await handle.close(); - } -}; -async function publishPrivateFile(directory, name, content, options = {}) { - assertSafeName(name); - const target = join(directory, name); - const temporary = join(directory, `.${name}.${randomUUID()}.tmp`); - try { - await writeStaged(temporary, content); - await options.beforeCommit?.(target); - await rename(temporary, target); - await syncDirectory(directory); - await assertOwnedPath(target, { - kind: "file", - exactMode: PRIVATE_FILE_MODE, - links: 1 - }); - } catch (error) { - await unlink(temporary).catch(() => { - return; - }); - throw error; - } -} -async function createPrivateFileOnce(directory, name, content) { - assertSafeName(name); - const target = join(directory, name); - const temporary = join(directory, `.${name}.${randomUUID()}.tmp`); - try { - await writeStaged(temporary, content); - try { - await link(temporary, target); - } catch (error) { - if (error.code === "EEXIST") - return "existing"; - throw error; - } - await syncDirectory(directory); - await assertOwnedPath(target, { - kind: "file", - exactMode: PRIVATE_FILE_MODE, - links: 2 - }); - return "created"; - } finally { - await unlink(temporary).catch(() => { - return; - }); - await syncDirectory(directory).catch(() => { - return; - }); - } -} -var writeStagedSync = (staged, content) => { - const descriptor = openSync2(staged, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY | constants2.O_NOFOLLOW, PRIVATE_FILE_MODE); - try { - fchmodSync(descriptor, PRIVATE_FILE_MODE); - const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"); - let offset = 0; - while (offset < bytes.byteLength) { - offset += writeSync(descriptor, bytes, offset, bytes.byteLength - offset); - } - fsyncSync(descriptor); - } finally { - closeSync2(descriptor); - } -}; -var syncDirectorySync = (directory) => { - const descriptor = openSync2(directory, constants2.O_RDONLY); - try { - fsyncSync(descriptor); - } finally { - closeSync2(descriptor); - } -}; -function createPrivateFileOnceSync(directory, name, content) { - assertSafeName(name); - const target = join(directory, name); - const temporary = join(directory, `.${name}.${randomUUID()}.tmp`); - try { - writeStagedSync(temporary, content); - try { - linkSync(temporary, target); - } catch (error) { - if (error.code === "EEXIST") - return "existing"; - throw error; - } - syncDirectorySync(directory); - assertOwnedPathSync(target, { - kind: "file", - exactMode: PRIVATE_FILE_MODE, - links: 2 - }); - return "created"; - } finally { - try { - unlinkSync(temporary); - } catch {} - try { - syncDirectorySync(directory); - } catch {} - } -} - -// src/control-socket.ts -import { chmod, unlink as unlink2 } from "node:fs/promises"; -import { createServer, connect } from "node:net"; -import { dirname as dirname2 } from "node:path"; -class ControlSocketError extends Error { - code; - name = "ControlSocketError"; - constructor(code, message, options) { - super(message, options); - this.code = code; - } -} -var MAXIMUM_SOCKET_PATH_BYTES = 100; -var DEFAULT_MAXIMUM_CONNECTIONS = 16; -var DEFAULT_HEADER_TIMEOUT_MS = 5000; -var DEFAULT_IDLE_TIMEOUT_MS = 1e4; -var MAXIMUM_TIMEOUT_MS = 3600000; -var boundedTimeoutMs = (value, fallback) => { - const candidate = value ?? fallback; - if (!Number.isSafeInteger(candidate) || candidate < 1 || candidate > MAXIMUM_TIMEOUT_MS) { - throw new Error("Control socket timeouts must be positive integers within one hour."); - } - return candidate; -}; -var boundedCount = (value, fallback, maximum) => { - const candidate = value ?? fallback; - if (!Number.isSafeInteger(candidate) || candidate < 1 || candidate > maximum) { - throw new Error("Control socket bounds must be positive integers."); - } - return candidate; -}; -var socketIdentity = (path) => assertOwnedPath(path, { kind: "socket", exactMode: PRIVATE_FILE_MODE, links: 1 }); -var sameIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino; -var resolveBounds = (options) => { - const maximumFrameBytes = options.maximumFrameBytes; - if (!Number.isSafeInteger(maximumFrameBytes) || maximumFrameBytes < 1) { - throw new Error("Control frame bound must be a positive integer."); - } - return { - maximumFrameBytes, - maximumResponseBytes: options.maximumResponseBytes ?? maximumFrameBytes, - maximumConnections: boundedCount(options.maximumConnections, DEFAULT_MAXIMUM_CONNECTIONS, 1024), - maximumRequests: boundedCount(options.maximumRequestsPerConnection, 1, 1024), - headerTimeoutMs: boundedTimeoutMs(options.headerTimeoutMs, DEFAULT_HEADER_TIMEOUT_MS), - idleTimeoutMs: boundedTimeoutMs(options.idleTimeoutMs, DEFAULT_IDLE_TIMEOUT_MS) - }; -}; -function attachControlSocket(server, options) { - const bounds = resolveBounds(options); - const failure = options.failureResponse; - const encode = (value, reason) => { - const bytes = Buffer.from(`${JSON.stringify(value)} -`); - if (bytes.length <= bounds.maximumResponseBytes) - return bytes; - const fallback = Buffer.from(`${JSON.stringify(failure(reason))} -`); - if (fallback.length <= bounds.maximumResponseBytes) - return fallback; - return Buffer.alloc(0); - }; - const clients = new Set; - const work = new Set; - let closing = false; - server.on("connection", (socket) => { - if (closing || clients.size >= bounds.maximumConnections) { - const bytes = encode(failure("capacity"), "capacity"); - if (bytes.length === 0) - socket.destroy(); - else - socket.end(bytes); - return; - } - clients.add(socket); - let idleTimer; - socket.once("close", () => { - clients.delete(socket); - if (idleTimer !== undefined) - clearTimeout(idleTimer); - }); - socket.on("error", () => { - return; - }); - const controller = new AbortController; - let received = Buffer.alloc(0); - let requests = 0; - let chain = Promise.resolve(); - const headerTimer = setTimeout(() => socket.destroy(), bounds.headerTimeoutMs); - headerTimer.unref(); - const armIdle = () => { - if (idleTimer !== undefined) - clearTimeout(idleTimer); - if (requests >= bounds.maximumRequests) - return; - idleTimer = setTimeout(() => socket.destroy(), bounds.idleTimeoutMs); - idleTimer.unref(); - }; - socket.on("data", (chunk) => { - if (closing) { - socket.destroy(); - return; - } - received = Buffer.concat([received, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); - while (true) { - const newline = received.indexOf(10); - if (newline < 0) { - if (received.byteLength >= bounds.maximumFrameBytes) - socket.destroy(); - return; - } - if (newline === 0 || newline + 1 > bounds.maximumFrameBytes || requests >= bounds.maximumRequests) { - const bytes = encode(failure("limit"), "limit"); - if (bytes.length === 0) - socket.destroy(); - else - socket.end(bytes); - return; - } - const frame = received.subarray(0, newline); - received = received.subarray(newline + 1); - requests += 1; - clearTimeout(headerTimer); - const task = chain.catch(() => { - return; - }).then(async () => { - if (closing || socket.destroyed) - return; - let response; - try { - const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(frame)); - response = await options.onRequest(value, { signal: controller.signal }); - } catch { - response = failure("invalid-request"); - } - if (!socket.destroyed && !socket.writableEnded) { - const bytes = encode(response, "response-limit"); - if (bytes.length === 0 || socket.writableLength + bytes.length > bounds.maximumResponseBytes) { - socket.destroy(); - } else { - socket.write(bytes, () => { - if (requests >= bounds.maximumRequests) - socket.end(); - else - armIdle(); - }); - } - } - }).finally(() => work.delete(task)); - work.add(task); - chain = task; - } - }); - socket.on("end", () => { - if (received.byteLength !== 0) - socket.destroy(); - }); - }); - let closePromise; - return { - close() { - closePromise ??= (async () => { - closing = true; - for (const client of clients) - client.destroy(); - await new Promise((resolve2, reject) => server.close((error) => error !== undefined && error.code !== "ERR_SERVER_NOT_RUNNING" ? reject(error) : resolve2())); - await Promise.allSettled([...work]); - })(); - return closePromise; - } - }; -} -async function listenControlSocket(options) { - const socketPath = options.socketPath; - if (Buffer.byteLength(socketPath) > MAXIMUM_SOCKET_PATH_BYTES) { - throw new Error("Control socket path exceeds its platform limit."); - } - await ensurePrivateDirectory(dirname2(socketPath)); - try { - await socketIdentity(socketPath); - await unlink2(socketPath); - } catch (error) { - if (error.code !== "ENOENT") - throw error; - } - const server = createServer(); - const transport = attachControlSocket(server, options); - let published; - try { - await new Promise((resolve2, reject) => { - server.once("error", reject); - server.listen(socketPath, () => { - server.off("error", reject); - resolve2(); - }); - }); - await chmod(socketPath, PRIVATE_FILE_MODE); - published = await socketIdentity(socketPath); - } catch (error) { - await transport.close().catch(() => { - return; - }); - throw error; - } - return { - socketPath, - async close() { - const failures = []; - try { - await transport.close(); - } catch (error) { - failures.push(error); - } - try { - const current = await socketIdentity(socketPath).catch(() => null); - if (current !== null && sameIdentity(current, published)) { - await unlink2(socketPath); - } - } catch (error) { - failures.push(error); - } - if (failures.length > 0) { - throw failures.length === 1 ? failures[0] : new AggregateError(failures, "Control socket cleanup requires attention."); - } - } - }; -} -async function requestControlSocket(options) { - const { socketPath, maximumResponseBytes, timeoutMs } = options; - if (!Number.isSafeInteger(maximumResponseBytes) || maximumResponseBytes < 1) { - throw new Error("Control response bound must be a positive integer."); - } - if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAXIMUM_TIMEOUT_MS) { - throw new Error("Control request timeout must be a positive integer within one hour."); - } - const maximumRequestBytes = options.maximumRequestBytes ?? maximumResponseBytes; - const frame = Buffer.from(`${JSON.stringify(options.request)} -`); - if (frame.length > maximumRequestBytes) { - throw new Error("Control request exceeds its frame limit."); - } - let before; - try { - await assertOwnedPath(dirname2(socketPath), { kind: "directory", canonical: true }); - before = await socketIdentity(socketPath); - } catch (error) { - if (error.code === "ENOENT") { - throw new ControlSocketError("control-unavailable", "The control socket is unavailable.", { cause: error }); - } - throw error; - } - return new Promise((resolvePromise, rejectPromise) => { - const socket = connect(socketPath); - let buffer = Buffer.alloc(0); - let settled = false; - const settle = (error, value) => { - if (settled) - return; - settled = true; - clearTimeout(timer); - socket.destroy(); - if (error !== null) - rejectPromise(error); - else - resolvePromise(value); - }; - const timer = setTimeout(() => settle(new ControlSocketError("control-timeout", "Control request timed out.")), timeoutMs); - socket.once("connect", () => { - socketIdentity(socketPath).then((after) => { - if (!sameIdentity(before, after)) - throw new ControlSocketError("control-identity-changed", "Control socket identity changed."); - if (!settled) - socket.write(frame); - }).catch((error) => settle(error instanceof Error ? error : new Error("Control socket changed."))); - }); - socket.on("data", (chunk) => { - buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); - if (buffer.length > maximumResponseBytes) { - settle(new ControlSocketError("control-response-too-large", "Control response exceeds its frame limit.")); - return; - } - const newline = buffer.indexOf(10); - if (newline < 0) - return; - if (newline !== buffer.length - 1) { - settle(new ControlSocketError("control-extra-output", "Unexpected additional control output.")); - return; - } - try { - const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, newline))); - settle(null, options.parseResponse(value)); - } catch { - settle(new ControlSocketError("control-invalid-response", "Invalid control response.")); - } - }); - socket.once("error", () => settle(new ControlSocketError("control-unavailable", "The control socket is unavailable."))); - socket.once("close", () => { - if (!settled) - settle(new ControlSocketError("control-closed", "The control socket closed without a response.")); - }); - }); -} - -// src/protected-input.ts -import { fstatSync as fstatSync2, readSync as readSync2 } from "node:fs"; -import { isatty } from "node:tty"; -var DEFAULT_PROTECTED_INPUT_MAXIMUM_BYTES = 65536; - -class ProtectedInputError extends Error { - code; - name = "ProtectedInputError"; - constructor(code, message) { - super(message); - this.code = code; - } -} -var PROTECTED_INPUT_TERMINAL_MESSAGE = "Pipe or redirect the value in instead of typing it, so it stays out of your terminal history."; -function readProtectedDescriptor(descriptor, options = {}) { - const maximumBytes = options.maximumBytes ?? DEFAULT_PROTECTED_INPUT_MAXIMUM_BYTES; - if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) { - throw new Error("Protected input bound must be a positive integer."); - } - if (!Number.isSafeInteger(descriptor) || descriptor < 0) { - throw new Error("Protected input requires a valid descriptor."); - } - if (isatty(descriptor)) { - throw new ProtectedInputError("protected-terminal", PROTECTED_INPUT_TERMINAL_MESSAGE); - } - const metadata = fstatSync2(descriptor, { bigint: true }); - if (metadata.isFile()) { - const uid = typeof process.getuid === "function" ? process.getuid() : undefined; - if (uid !== undefined && metadata.uid !== BigInt(uid) || (metadata.mode & 0o077n) !== 0n) { - throw new ProtectedInputError("protected-unsafe-file", "Protected input file must be owned and private."); - } - } - const buffer = Buffer.alloc(maximumBytes + 1); - let offset = 0; - while (true) { - const read = readSync2(descriptor, buffer, offset, buffer.length - offset, null); - if (read === 0) - break; - offset += read; - if (offset > maximumBytes) - throw new ProtectedInputError("protected-too-large", "Protected input exceeds its size bound."); - if (offset === buffer.length) - throw new ProtectedInputError("protected-too-large", "Protected input exceeds its size bound."); - } - return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset)); -} -function readProtectedStdin(options = {}) { - return readProtectedDescriptor(0, options); -} + ControlSocketError, + MAXIMUM_SOCKET_PATH_BYTES, + attachControlSocket, + listenControlSocket, + requestControlSocket +} from "./chunk-k2mgjyx4.js"; +import { + PRIVATE_DIRECTORY_MODE, + PRIVATE_FILE_MODE, + assertOwnedPath, + assertOwnedPathSync, + ensurePrivateDirectory, + readOwnedFileStable, + readOwnedFileStableSync, + readPrivateFile +} from "./chunk-xrgz1k0h.js"; +import { + DEFAULT_PROTECTED_INPUT_MAXIMUM_BYTES, + PROTECTED_INPUT_TERMINAL_MESSAGE, + ProtectedInputError, + readProtectedDescriptor, + readProtectedStdin +} from "./chunk-8gyk2127.js"; // src/describe.ts var CUSTODY_ERROR_COPY = Object.freeze({ "service-not-running": Object.freeze({ diff --git a/dist/private-paths.js b/dist/private-paths.js index 2570957..b50d709 100644 --- a/dist/private-paths.js +++ b/dist/private-paths.js @@ -1,138 +1,13 @@ -// src/private-paths.ts import { - closeSync, - constants, - fstatSync, - lstatSync, - openSync, - readSync, - realpathSync -} from "node:fs"; -import { lstat, mkdir, open, realpath } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -var PRIVATE_DIRECTORY_MODE = 448; -var PRIVATE_FILE_MODE = 384; -var ownerUid = () => typeof process.getuid === "function" ? process.getuid() : undefined; -var kindMatches = (metadata, kind) => kind === "file" ? metadata.isFile() : kind === "directory" ? metadata.isDirectory() : metadata.isSocket(); -async function assertOwnedPath(path, expectation) { - const metadata = await lstat(path, { bigint: true }); - const uid = ownerUid(); - const expectedLinks = expectation.links ?? (expectation.kind === "directory" ? undefined : 1n); - if (!kindMatches(metadata, expectation.kind) || metadata.isSymbolicLink() || expectedLinks !== undefined && metadata.nlink !== BigInt(expectedLinks) || uid !== undefined && metadata.uid !== BigInt(uid) || expectation.exactMode !== undefined && (metadata.mode & 0o777n) !== BigInt(expectation.exactMode) || expectation.ownerOnly === true && (metadata.mode & 0o077n) !== 0n || expectation.canonical === true && await realpath(path) !== path || expectation.minimumBytes !== undefined && metadata.size < expectation.minimumBytes || expectation.maximumBytes !== undefined && metadata.size > expectation.maximumBytes) { - throw new Error(`Unsafe local ${expectation.kind}.`); - } - return { - dev: Number(metadata.dev), - ino: Number(metadata.ino), - size: Number(metadata.size) - }; -} -function assertOwnedPathSync(path, expectation) { - const metadata = lstatSync(path, { bigint: true }); - const uid = ownerUid(); - const expectedLinks = expectation.links ?? (expectation.kind === "directory" ? undefined : 1n); - if (!kindMatches(metadata, expectation.kind) || metadata.isSymbolicLink() || expectedLinks !== undefined && metadata.nlink !== BigInt(expectedLinks) || uid !== undefined && metadata.uid !== BigInt(uid) || expectation.exactMode !== undefined && (metadata.mode & 0o777n) !== BigInt(expectation.exactMode) || expectation.ownerOnly === true && (metadata.mode & 0o077n) !== 0n || expectation.canonical === true && realpathSync(path) !== path || expectation.minimumBytes !== undefined && metadata.size < expectation.minimumBytes || expectation.maximumBytes !== undefined && metadata.size > expectation.maximumBytes) { - throw new Error(`Unsafe local ${expectation.kind}.`); - } - return { - dev: Number(metadata.dev), - ino: Number(metadata.ino), - size: Number(metadata.size) - }; -} -async function ensurePrivateDirectory(path) { - const absolute = resolve(path); - const parent = dirname(absolute); - if (await realpath(parent) !== parent) { - throw new Error("Directory parent must be physical."); - } - try { - await mkdir(absolute, { mode: PRIVATE_DIRECTORY_MODE }); - } catch (error) { - if (error.code !== "EEXIST") - throw error; - } - const metadata = await lstat(absolute); - if (await realpath(absolute) !== absolute || !metadata.isDirectory() || metadata.isSymbolicLink() || ownerUid() !== undefined && metadata.uid !== ownerUid() || (metadata.mode & 63) !== 0) { - throw new Error("Directory must be physical, owned, and private."); - } - return absolute; -} -var checkStableCandidate = (before, maximumBytes, expectation) => { - const uid = ownerUid(); - const links = BigInt(expectation.links ?? 1); - if (!before.isFile() || before.nlink !== links || uid !== undefined && before.uid !== BigInt(uid) || (expectation.exactMode !== undefined ? (before.mode & 0o777n) !== BigInt(expectation.exactMode) : expectation.ownerOnly !== false && (before.mode & 0o077n) !== 0n) || expectation.minimumBytes !== undefined && before.size < expectation.minimumBytes || before.size > BigInt(maximumBytes)) { - throw new Error("Unsafe private file."); - } -}; -var checkStableResult = (before, after) => { - if (after.isSymbolicLink() || !after.isFile() || after.dev !== before.dev || after.ino !== before.ino || after.nlink !== before.nlink || after.mode !== before.mode || after.uid !== before.uid || after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { - throw new Error("Private file changed during the read."); - } -}; -async function readOwnedFileStable(path, maximumBytes, expectation = {}) { - const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - try { - const before = await handle.stat({ bigint: true }); - checkStableCandidate(before, maximumBytes, expectation); - const buffer = Buffer.alloc(Number(before.size)); - let offset = 0; - while (offset < buffer.byteLength) { - const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, null); - if (bytesRead === 0) - break; - offset += bytesRead; - } - const after = await lstat(path, { bigint: true }); - checkStableResult(before, after); - if (offset !== buffer.byteLength) { - throw new Error("Private file changed during the read."); - } - return { bytes: buffer, dev: Number(before.dev), ino: Number(before.ino) }; - } finally { - await handle.close(); - } -} -function readOwnedFileStableSync(path, maximumBytes, expectation = {}) { - const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - try { - const before = fstatSync(descriptor, { bigint: true }); - checkStableCandidate(before, maximumBytes, expectation); - const buffer = Buffer.alloc(Number(before.size)); - let offset = 0; - while (offset < buffer.byteLength) { - const count = readSync(descriptor, buffer, offset, buffer.byteLength - offset, null); - if (count === 0) - break; - offset += count; - } - const after = lstatSync(path, { bigint: true }); - checkStableResult(before, after); - if (offset !== buffer.byteLength) { - throw new Error("Private file changed during the read."); - } - return { bytes: buffer, dev: Number(before.dev), ino: Number(before.ino) }; - } finally { - closeSync(descriptor); - } -} -async function readPrivateFile(path, maximumBytes) { - const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - try { - const metadata = await handle.stat(); - const uid = ownerUid(); - if (!metadata.isFile() || metadata.nlink !== 1 || uid !== undefined && metadata.uid !== uid || (metadata.mode & 63) !== 0 || metadata.size > maximumBytes) { - throw new Error("Unsafe private file."); - } - const buffer = Buffer.alloc(maximumBytes + 1); - const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); - if (bytesRead > maximumBytes) - throw new Error("Private file exceeds its size bound."); - return buffer.subarray(0, bytesRead); - } finally { - await handle.close(); - } -} + PRIVATE_DIRECTORY_MODE, + PRIVATE_FILE_MODE, + assertOwnedPath, + assertOwnedPathSync, + ensurePrivateDirectory, + readOwnedFileStable, + readOwnedFileStableSync, + readPrivateFile +} from "./chunk-xrgz1k0h.js"; export { readPrivateFile, readOwnedFileStableSync, diff --git a/dist/protected-input.js b/dist/protected-input.js index da63780..0d43412 100644 --- a/dist/protected-input.js +++ b/dist/protected-input.js @@ -1,52 +1,10 @@ -// src/protected-input.ts -import { fstatSync, readSync } from "node:fs"; -import { isatty } from "node:tty"; -var DEFAULT_PROTECTED_INPUT_MAXIMUM_BYTES = 65536; - -class ProtectedInputError extends Error { - code; - name = "ProtectedInputError"; - constructor(code, message) { - super(message); - this.code = code; - } -} -var PROTECTED_INPUT_TERMINAL_MESSAGE = "Pipe or redirect the value in instead of typing it, so it stays out of your terminal history."; -function readProtectedDescriptor(descriptor, options = {}) { - const maximumBytes = options.maximumBytes ?? DEFAULT_PROTECTED_INPUT_MAXIMUM_BYTES; - if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) { - throw new Error("Protected input bound must be a positive integer."); - } - if (!Number.isSafeInteger(descriptor) || descriptor < 0) { - throw new Error("Protected input requires a valid descriptor."); - } - if (isatty(descriptor)) { - throw new ProtectedInputError("protected-terminal", PROTECTED_INPUT_TERMINAL_MESSAGE); - } - const metadata = fstatSync(descriptor, { bigint: true }); - if (metadata.isFile()) { - const uid = typeof process.getuid === "function" ? process.getuid() : undefined; - if (uid !== undefined && metadata.uid !== BigInt(uid) || (metadata.mode & 0o077n) !== 0n) { - throw new ProtectedInputError("protected-unsafe-file", "Protected input file must be owned and private."); - } - } - const buffer = Buffer.alloc(maximumBytes + 1); - let offset = 0; - while (true) { - const read = readSync(descriptor, buffer, offset, buffer.length - offset, null); - if (read === 0) - break; - offset += read; - if (offset > maximumBytes) - throw new ProtectedInputError("protected-too-large", "Protected input exceeds its size bound."); - if (offset === buffer.length) - throw new ProtectedInputError("protected-too-large", "Protected input exceeds its size bound."); - } - return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset)); -} -function readProtectedStdin(options = {}) { - return readProtectedDescriptor(0, options); -} +import { + DEFAULT_PROTECTED_INPUT_MAXIMUM_BYTES, + PROTECTED_INPUT_TERMINAL_MESSAGE, + ProtectedInputError, + readProtectedDescriptor, + readProtectedStdin +} from "./chunk-8gyk2127.js"; export { readProtectedStdin, readProtectedDescriptor, diff --git a/dist/rust-fallback.js b/dist/rust-fallback.js index da663d2..3bbc2b5 100644 --- a/dist/rust-fallback.js +++ b/dist/rust-fallback.js @@ -1,34 +1,6 @@ -// src/rust-fallback.ts -var MAX_FALLBACK_TAGS = 32; -var MAX_FALLBACK_NOTICES_PER_TAG = 4; -var DIAGNOSTIC_FIELD = /^[A-Za-z0-9._-]{1,64}$/u; -var emittedNotices = new Map; -function boundedField(value) { - return DIAGNOSTIC_FIELD.test(value) ? value : "other"; -} -function emitLocalCustodyFallback(notice) { - const tag = boundedField(notice.tag); - const reason = boundedField(notice.reason); - const inputClass = notice.inputClass === undefined ? undefined : boundedField(notice.inputClass); - const diagnostic = inputClass === undefined ? reason : `${reason}:${inputClass}`; - let seen = emittedNotices.get(tag); - if (seen === undefined) { - if (emittedNotices.size >= MAX_FALLBACK_TAGS) - return; - seen = new Set; - emittedNotices.set(tag, seen); - } - if (seen.has(diagnostic) || seen.size >= MAX_FALLBACK_NOTICES_PER_TAG) - return; - seen.add(diagnostic); - try { - if (typeof process !== "undefined" && typeof process.stderr?.write === "function") { - const detail = inputClass === undefined ? reason : `${reason} input=${inputClass}`; - process.stderr.write(`[${tag}] ${detail} -`); - } - } catch {} -} +import { + emitLocalCustodyFallback +} from "./chunk-np3mgd4s.js"; export { emitLocalCustodyFallback }; diff --git a/package.json b/package.json index 6789d84..15bae15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hraness/local-custody", - "version": "0.7.0", + "version": "0.8.0", "description": "Owner-only files, atomic private writes, a local JSON control socket, and file-descriptor secret input for Hraness product CLIs.", "license": "MIT", "type": "module", diff --git a/portfolio-inventory.json b/portfolio-inventory.json index 0e5ed44..b007c99 100644 --- a/portfolio-inventory.json +++ b/portfolio-inventory.json @@ -8,7 +8,7 @@ "name": "@hraness/local-custody", "path": ".", "visibility": "public", - "version": "0.7.0" + "version": "0.8.0" } ], "dependencies": [], diff --git a/rust/Cargo.toml b/rust/Cargo.toml index acb0a11..668ae1d 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "local-custody" -version = "0.7.0" +version = "0.8.0" edition = "2021" license = "MIT" description = "Owner-only path checks, stable private reads, and atomic private file writes for Rust CLIs." diff --git a/rust/src/lib.rs b/rust/src/lib.rs index c2341a6..b441ddd 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1733,8 +1733,9 @@ const DEFAULT_PROTECTED_INPUT_MAXIMUM_BYTES: usize = 65_536; /// /// - Rejects negative descriptors. /// - Rejects TTYs. -/// - On Unix, the descriptor must refer to a regular file owned by the -/// current user with no group/other access bits. +/// - On Unix, the descriptor must be a pipe, a socket, or a regular file. A +/// regular file must be owned by the current user with no group/other +/// access bits. Other kinds (directories, devices) are refused. /// - Reads at most `maximum_bytes` and fails if more data is available. /// - Returns valid UTF-8 or fails closed. #[cfg(unix)] @@ -1768,24 +1769,30 @@ pub fn read_protected_descriptor( format!("cannot fstat descriptor {fd}"), )); } - if (stat.st_mode & libc::S_IFMT) != libc::S_IFREG { - return Err(CustodyError::new( - "kind", - "descriptor is not a regular file", - )); - } - if let Some(uid) = current_uid() { - if stat.st_uid != uid { + let kind = stat.st_mode & libc::S_IFMT; + // A pipe or socket carries what the caller piped in (`pbpaste | cli + // login --stdin`); it has no owner or mode of its own to check. + if kind == libc::S_IFIFO || kind == libc::S_IFSOCK { + // Accepted as is. + } else if kind == libc::S_IFREG { + if let Some(uid) = current_uid() { + if stat.st_uid != uid { + return Err(CustodyError::new( + "owner", + "descriptor is not owned by current user", + )); + } + } + if stat.st_mode & 0o077 != 0 { return Err(CustodyError::new( - "owner", - "descriptor is not owned by current user", + "mode", + "descriptor allows group/other access", )); } - } - if stat.st_mode & 0o077 != 0 { + } else { return Err(CustodyError::new( - "mode", - "descriptor allows group/other access", + "kind", + "descriptor is not a pipe, socket or regular file", )); } } diff --git a/rust/src/main.rs b/rust/src/main.rs index 901f971..96cc2ef 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -77,7 +77,10 @@ enum Request { }, #[serde(rename = "read_protected_stdin")] ReadProtectedStdin { + // Parsed so malformed requests still fail as `invalid-request`; the op + // itself is refused (stdin carries the protocol). #[serde(rename = "maximumBytes")] + #[allow(dead_code)] maximum_bytes: Option, }, #[serde(rename = "control_socket_request")] @@ -250,14 +253,25 @@ fn dispatch(req: Request) -> Result { Ok(json!({ "path": published.path, "created": published.created })) } Request::ReadProtectedDescriptor { fd, maximum_bytes } => { + // Descriptors 0-2 carry this protocol (a pipe the library would + // now accept), so they are never protected input here. + if (0..=2).contains(&fd) { + return Err(( + "kind".to_string(), + "sidecar stdio carries the protocol, not protected input".to_string(), + )); + } let content = local_custody::read_protected_descriptor(fd, maximum_bytes) .map_err(|e| (e.code, e.message))?; Ok(json!({ "content": content })) } - Request::ReadProtectedStdin { maximum_bytes } => { - let content = local_custody::read_protected_stdin(maximum_bytes) - .map_err(|e| (e.code, e.message))?; - Ok(json!({ "content": content })) + Request::ReadProtectedStdin { .. } => { + // The sidecar's stdin carries this protocol, so it is never + // protected input: reading it would consume later requests. + Err(( + "kind".to_string(), + "sidecar stdin carries the protocol, not protected input".to_string(), + )) } Request::ControlSocket { socket_path, diff --git a/rust/tests/protected_input.rs b/rust/tests/protected_input.rs index 1991773..b9a164e 100644 --- a/rust/tests/protected_input.rs +++ b/rust/tests/protected_input.rs @@ -45,3 +45,44 @@ fn negative_descriptor_rejected() { let err = read_protected_descriptor(-1, Some(64)).unwrap_err(); assert_eq!(err.code, "invalid"); } + +fn pipe_with(content: &[u8]) -> i32 { + let mut fds = [0i32; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); + let written = unsafe { libc::write(fds[1], content.as_ptr().cast(), content.len()) }; + assert_eq!(written, content.len() as isize); + unsafe { libc::close(fds[1]) }; + fds[0] +} + +#[test] +fn piped_input_returns_content() { + let read_end = pipe_with(b"piped-token"); + let result = read_protected_descriptor(read_end, Some(1_024)); + unsafe { libc::close(read_end) }; + assert_eq!(result.unwrap(), "piped-token"); +} + +#[test] +fn piped_input_beyond_bound_rejected() { + let read_end = pipe_with(&[b'x'; 128]); + let err = read_protected_descriptor(read_end, Some(64)).unwrap_err(); + unsafe { libc::close(read_end) }; + assert_eq!(err.code, "limit"); +} + +#[test] +fn empty_pipe_returns_empty_content() { + let read_end = pipe_with(b""); + let result = read_protected_descriptor(read_end, Some(64)); + unsafe { libc::close(read_end) }; + assert_eq!(result.unwrap(), ""); +} + +#[test] +fn directory_descriptor_rejected() { + let dir = TempDir::new().unwrap(); + let handle = fs::File::open(dir.path()).unwrap(); + let err = read_protected_descriptor(handle.as_raw_fd(), Some(64)).unwrap_err(); + assert_eq!(err.code, "kind"); +} diff --git a/scripts/build.ts b/scripts/build.ts index 9b78aba..17c9ef0 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -17,6 +17,10 @@ const result = await Bun.build({ format: "esm", outdir: "dist", target: "node", + // Shared modules become chunks, so an error class has one identity across + // subpaths: `instanceof` works whichever entry created or catches it. + splitting: true, + naming: { chunk: "chunk-[hash].[ext]" }, }); if (!result.success) { for (const log of result.logs) console.error(log); diff --git a/scripts/package-smoke.ts b/scripts/package-smoke.ts index 2c83960..2c3c989 100644 --- a/scripts/package-smoke.ts +++ b/scripts/package-smoke.ts @@ -50,6 +50,23 @@ try { "node", "--input-type=module", "-e", `await Promise.all(${JSON.stringify(importSpecifiers)}.map((specifier) => import(specifier)))`, ], consumer); + // One class identity per error type across every subpath: an error thrown + // through one entrypoint passes `instanceof` against the class from another. + await run([ + "node", "--input-type=module", "-e", + `import assert from "node:assert/strict"; +import * as root from "${packageName}"; +import * as socket from "${packageName}/control-socket"; +import * as input from "${packageName}/protected-input"; +import * as rust from "${packageName}/custody-rust"; +assert.equal(root.ControlSocketError, socket.ControlSocketError); +assert.equal(root.ProtectedInputError, input.ProtectedInputError); +const refused = new rust.CustodyError("mode", "detail"); +assert.ok(refused instanceof rust.CustodyError); +try { await root.requestControlSocket({ socketPath: "/nonexistent/lc.sock", request: {}, maximumResponseBytes: 64, timeoutMs: 1000, parseResponse: (v) => v }); assert.fail("expected a refusal"); } +catch (error) { assert.ok(error instanceof socket.ControlSocketError, String(error)); } +console.log("error identity across subpaths passed");`, + ], consumer); // Exercise the packed runtime end to end under Node: private directory, // atomic publication, and a live control-socket round trip. await run([ diff --git a/spec/custody.md b/spec/custody.md index 9e85513..c93e30e 100644 --- a/spec/custody.md +++ b/spec/custody.md @@ -161,7 +161,8 @@ modified — and returns the same `dev`/`ino` identity. 1. Input comes from an open descriptor, never argv or the environment. 2. Terminals are refused. -3. A regular file must be uid-owned with `mode & 0o077 === 0`. +3. Pipes and sockets are accepted. A regular file must be uid-owned with + `mode & 0o077 === 0`. The Rust reader refuses other kinds with `kind`. 4. Reads stop at EOF or the byte bound; exceeding the bound fails. 5. Content is fatal-decoded UTF-8; the caller owns trimming and parsing. @@ -183,7 +184,9 @@ behind a newline-delimited JSON protocol on stdio. `{path, created}` where `created` is `false` only when `createOnce` preserved a pre-existing target; `read_protected_descriptor` `{fd, maximumBytes}` → `{content}`; - `read_protected_stdin` `{maximumBytes}` → `{content}`; + `read_protected_stdin` `{maximumBytes}` → `{content}`; the sidecar + refuses descriptors 0–2 and `read_protected_stdin` with `kind`, because + its stdio carries this protocol; `control_socket_request` `{socketPath, request, maximumResponseBytes, timeoutMs}` → the socket's raw response value. 3. `exactMode` is an octal **string** (for example `"0600"`); byte bounds are diff --git a/spec/vectors.json b/spec/vectors.json index 744927a..4c6678c 100644 --- a/spec/vectors.json +++ b/spec/vectors.json @@ -265,10 +265,15 @@ "expect": { "ok": false, "code": "invalid" } }, { - "name": "non-regular descriptor rejected", + "name": "sidecar protocol descriptor rejected", "request": { "op": "read_protected_descriptor", "fd": 0, "maximumBytes": 64 }, "expect": { "ok": false, "code": "kind" } }, + { + "name": "sidecar protocol stdin rejected", + "request": { "op": "read_protected_stdin", "maximumBytes": 64 }, + "expect": { "ok": false, "code": "kind" } + }, { "name": "control request to missing socket", "request": { "op": "control_socket_request", "socketPath": "$TEMP/private/no.sock", "request": {}, "maximumResponseBytes": 1024, "timeoutMs": 500 }, diff --git a/src/custody-rust.ts b/src/custody-rust.ts index 3e30011..753dd39 100644 --- a/src/custody-rust.ts +++ b/src/custody-rust.ts @@ -689,8 +689,8 @@ export async function loadLocalCustodyRustEngine(): Promise Date: Sat, 26 Sep 2026 23:01:19 -0400 Subject: [PATCH 2/2] Retry interrupted reads of piped input and say pipe or redirect in Rust Review follow-up: a pipe from a slow writer can block long enough for a signal without SA_RESTART to interrupt read(2); the reader now retries EINTR like std::io::Read, including the over-bound probe. The Rust terminal refusal now matches the TypeScript copy (pipe or redirect). Co-Authored-By: Claude Opus 5.5 (1M context) --- rust/src/lib.rs | 25 ++++++++++++++++++++++--- rust/tests/protected_input.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/rust/src/lib.rs b/rust/src/lib.rs index b441ddd..dbbf6fc 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1759,7 +1759,7 @@ pub fn read_protected_descriptor( if is_tty { return Err(CustodyError::new( "tty", - "Redirect the value from a file only you can read instead of typing it, so it stays out of your terminal history.", + "Pipe or redirect the value in instead of typing it, so it stays out of your terminal history.", )); } let mut stat: libc::stat = unsafe { std::mem::zeroed() }; @@ -1807,7 +1807,7 @@ pub fn read_protected_descriptor( while buf.len() < maximum_bytes { let remaining = maximum_bytes - buf.len(); let mut chunk = vec![0u8; remaining.min(4096)]; - let n = unsafe { libc::read(fd, chunk.as_mut_ptr().cast(), chunk.len()) }; + let n = read_retrying_interrupts(fd, &mut chunk); if n < 0 { return Err(CustodyError::new( "read", @@ -1822,7 +1822,13 @@ pub fn read_protected_descriptor( } // Detect whether any additional bytes remain beyond the bound. let mut extra = [0u8; 1]; - let n = unsafe { libc::read(fd, extra.as_mut_ptr().cast(), 1) }; + let n = read_retrying_interrupts(fd, &mut extra); + if n < 0 { + return Err(CustodyError::new( + "read", + format!("cannot read descriptor {fd}"), + )); + } if n > 0 { return Err(CustodyError::new( "limit", @@ -1837,6 +1843,19 @@ pub fn read_protected_descriptor( }) } +/// `read(2)` that retries `EINTR`, like `std::io::Read`: a pipe from a slow +/// writer can block long enough for a signal without `SA_RESTART` to land. +#[cfg(unix)] +fn read_retrying_interrupts(fd: i32, buffer: &mut [u8]) -> isize { + loop { + let n = unsafe { libc::read(fd, buffer.as_mut_ptr().cast(), buffer.len()) }; + if n < 0 && std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted { + continue; + } + return n; + } +} + #[cfg(not(unix))] pub fn read_protected_descriptor( _fd: i32, diff --git a/rust/tests/protected_input.rs b/rust/tests/protected_input.rs index b9a164e..a6b0a7d 100644 --- a/rust/tests/protected_input.rs +++ b/rust/tests/protected_input.rs @@ -86,3 +86,36 @@ fn directory_descriptor_rejected() { let err = read_protected_descriptor(handle.as_raw_fd(), Some(64)).unwrap_err(); assert_eq!(err.code, "kind"); } + +extern "C" fn ignore_signal(_: libc::c_int) {} + +#[test] +fn piped_input_survives_an_interrupting_signal() { + // A handler without SA_RESTART makes a blocked read return EINTR. + unsafe { + let mut action: libc::sigaction = std::mem::zeroed(); + action.sa_sigaction = ignore_signal as *const () as usize; + action.sa_flags = 0; + libc::sigemptyset(&mut action.sa_mask); + assert_eq!( + libc::sigaction(libc::SIGUSR2, &action, std::ptr::null_mut()), + 0 + ); + } + let mut fds = [0i32; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); + let (read_end, write_end) = (fds[0], fds[1]); + let reader = unsafe { libc::pthread_self() } as usize; + let writer = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(100)); + unsafe { libc::pthread_kill(reader as libc::pthread_t, libc::SIGUSR2) }; + std::thread::sleep(std::time::Duration::from_millis(50)); + let content = b"slow-token"; + unsafe { libc::write(write_end, content.as_ptr().cast(), content.len()) }; + unsafe { libc::close(write_end) }; + }); + let result = read_protected_descriptor(read_end, Some(1_024)); + writer.join().unwrap(); + unsafe { libc::close(read_end) }; + assert_eq!(result.unwrap(), "slow-token"); +}