Files
ThothII/backend/src/auth/windows-auth-storage.ts
T

640 lines
28 KiB
TypeScript

import { spawn, spawnSync } from "node:child_process";
import { posix, win32 } from "node:path";
import type { Readable, Writable } from "node:stream";
import { z } from "zod";
const PROTOCOL_VERSION = 1;
const MAX_PROTOCOL_BYTES = 64 * 1024;
const MAX_RESPONSE_BYTES = 64 * 1024;
const MAX_AUTH_CONFIG_BYTES = 1024 * 1024;
const MAX_AUTH_CONFIG_BASE64_BYTES = 4 * Math.ceil(MAX_AUTH_CONFIG_BYTES / 3);
const MAX_AUTH_CONFIG_RESPONSE_BYTES = MAX_AUTH_CONFIG_BASE64_BYTES + 1024;
const MAX_SESSION_BYTES = 16 * 1024;
const MAX_OIDC_BYTES = 8 * 1024;
const DEFAULT_MAX_ENTRIES = 256;
const MAX_ENTRIES = 512;
const TIMEOUT_MS = 5_000;
const TERMINATION_GRACE_MS = 100;
const FINAL_SETTLEMENT_MS = 750;
const DIGEST_FILENAME = /^[a-f0-9]{64}\.json$/;
const CLAIM_FILENAME = /^[a-f0-9]{64}\.claim$/;
const OIDC_SLOT_FILENAME = /^slot-(?:[0-5][0-9]|6[0-3])\.json$/;
const AUTH_CONFIG_FILENAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,249}\.yaml$/;
const invalid = (): Error => new Error("auth_session_store_invalid");
export type WindowsAuthStorageDirectory = "sessions" | "oidc";
export interface WindowsAuthStorageEntry {
name: string;
modifiedUnixMs: number;
}
export interface WindowsAuthStoragePage {
entries: WindowsAuthStorageEntry[];
more: boolean;
}
/** Internal adapter boundary for the file-session store's native Windows path. */
export interface WindowsAuthStorageBridge {
validateRoot(root: string): Promise<void>;
ensureLayout(root: string): Promise<void>;
readAuthConfig(path: string): Buffer;
readLocalUsers(path: string): Promise<Buffer>;
create(root: string, directory: WindowsAuthStorageDirectory, filename: string, contents: Buffer): Promise<boolean>;
read(root: string, directory: WindowsAuthStorageDirectory, filename: string): Promise<Buffer | undefined>;
replace(root: string, directory: WindowsAuthStorageDirectory, filename: string, contents: Buffer): Promise<void>;
remove(root: string, directory: WindowsAuthStorageDirectory, filename: string): Promise<boolean>;
list(
root: string,
directory: WindowsAuthStorageDirectory,
maximumEntries?: number,
): Promise<WindowsAuthStorageEntry[]>;
listPage(
root: string,
directory: "sessions",
afterName: string | undefined,
maximumEntries: number,
): Promise<WindowsAuthStoragePage>;
claimConsume(root: string, filename: string): Promise<Buffer | undefined>;
readClaim(root: string, filename: string): Promise<Buffer | undefined>;
removeClaim(root: string, filename: string): Promise<boolean>;
}
export interface WindowsAuthStorageInvocation {
executable: string;
args: readonly string[];
input: Buffer;
timeoutMs: number;
maximumOutputBytes: number;
}
export interface WindowsAuthStorageInvocationResult {
code: number;
stdout: Buffer;
stderr: Buffer;
}
interface WindowsAuthStorageChild {
readonly stdin: Writable | null;
readonly stdout: Readable | null;
readonly stderr: Readable | null;
kill(signal?: NodeJS.Signals | number): boolean;
unref?(): void;
on(event: "error", listener: (error: Error) => void): this;
once(event: "error", listener: (error: Error) => void): this;
once(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
removeListener?(event: "error" | "close", listener: (...args: any[]) => void): this;
}
type WindowsAuthStorageSpawn = (
executable: string,
args: readonly string[],
options: { shell: false; windowsHide: true; stdio: ["pipe", "pipe", "pipe"]; env: NodeJS.ProcessEnv },
) => WindowsAuthStorageChild;
export interface WindowsAuthStorageBridgeOptions {
/** Test-only transport seam. Production always uses the no-shell child-process invocation. */
invoke?: (invocation: WindowsAuthStorageInvocation) => Promise<WindowsAuthStorageInvocationResult>;
/** Test-only synchronous seam used by the synchronous authentication-config provider. */
invokeSync?: (invocation: WindowsAuthStorageInvocation) => WindowsAuthStorageInvocationResult;
/** Optional configured tht path. Defaults to THT_BIN, then the safe bare command `tht`. */
thtExecutable?: string;
/** Test-only child-launch seam; production uses the fixed no-shell Node child-process launcher. */
spawnChild?: WindowsAuthStorageSpawn;
/** Test-only input scheduling seam for real child-process lifecycle tests. */
beforeInputForTest?: () => Promise<void>;
/** Test-only bounded lifecycle timings. Production always uses the fixed deadlines below. */
deadlinesForTest?: {
timeoutMs?: number;
terminationGraceMs?: number;
finalSettlementMs?: number;
};
}
type AuthStoragePathStyle = "posix" | "windows";
const responseSchema = z.strictObject({
version: z.literal(PROTOCOL_VERSION),
ok: z.literal(true),
created: z.boolean().optional(),
replaced: z.boolean().optional(),
removed: z.boolean().optional(),
found: z.boolean().optional(),
contentBase64: z.string().max(MAX_AUTH_CONFIG_BASE64_BYTES).optional(),
entries: z.array(z.strictObject({
name: z.string().max(128),
modifiedUnixMs: z.number().int().safe().nonnegative(),
})).max(MAX_ENTRIES).optional(),
more: z.boolean().optional(),
validated: z.boolean().optional(),
prepared: z.boolean().optional(),
});
type BridgeResponse = z.infer<typeof responseSchema>;
interface BridgeRequest {
version: typeof PROTOCOL_VERSION;
operation: "validate-root" | "ensure-layout" | "read-auth-config" | "read-local-users" | "create" | "read" | "replace" | "remove" | "list" | "claim-consume" | "read-claim" | "remove-claim";
root: string;
directory?: WindowsAuthStorageDirectory;
filename?: string;
contentBase64?: string;
maximumEntries?: number;
afterName?: string;
continuation?: true;
}
function directoryMaximum(directory: WindowsAuthStorageDirectory): number {
return directory === "sessions" ? MAX_SESSION_BYTES : MAX_OIDC_BYTES;
}
function canonicalBase64(value: string, maximum: number): Buffer {
if (typeof value !== "string" || value.length > Math.ceil(maximum / 3) * 4) throw invalid();
try {
const decoded = Buffer.from(value, "base64");
if (decoded.length === 0 || decoded.length > maximum || decoded.toString("base64") !== value) throw invalid();
return decoded;
} catch {
throw invalid();
}
}
function validateRoot(root: string, pathStyle: AuthStoragePathStyle): void {
const paths = pathStyle === "windows" ? win32 : posix;
if (typeof root !== "string" || root.length === 0 || /[\u0000-\u001f\u007f]/.test(root)
|| !paths.isAbsolute(root) || paths.normalize(root) !== root) throw invalid();
}
function validateFilename(filename: string, allowClaim = false, allowOidcSlot = false): void {
if (typeof filename !== "string" || (!DIGEST_FILENAME.test(filename)
&& !(allowClaim && CLAIM_FILENAME.test(filename))
&& !(allowOidcSlot && OIDC_SLOT_FILENAME.test(filename)))) throw invalid();
}
function safeThtExecutable(value: string | undefined, pathStyle: AuthStoragePathStyle): string {
const executable = value ?? process.env.THT_AUTH_STORAGE_BIN ?? process.env.THT_BIN ?? "tht";
if (typeof executable !== "string" || executable.length === 0 || /[\u0000-\u001f\u007f]/.test(executable)) throw invalid();
if (executable === "tht" || (pathStyle === "windows" && executable === "tht.exe")) return executable;
const paths = pathStyle === "windows" ? win32 : posix;
if (paths.isAbsolute(executable) && paths.normalize(executable) === executable
&& (pathStyle === "posix" || /\.exe$/i.test(executable))) return executable;
throw invalid();
}
function parseResponse(result: WindowsAuthStorageInvocationResult, maximumOutputBytes: number): BridgeResponse {
if (!Number.isInteger(result.code) || result.code !== 0 || !Buffer.isBuffer(result.stdout)
|| !Buffer.isBuffer(result.stderr) || result.stderr.length > MAX_RESPONSE_BYTES
|| result.stdout.length === 0 || result.stdout.length > maximumOutputBytes) {
throw invalid();
}
try {
const source = new TextDecoder("utf-8", { fatal: true }).decode(result.stdout);
return responseSchema.parse(JSON.parse(source));
} catch {
throw invalid();
}
}
function encodedRequest(request: BridgeRequest, pathStyle: AuthStoragePathStyle): Buffer {
validateRoot(request.root, pathStyle);
if (request.operation === "validate-root" || request.operation === "ensure-layout") {
if (request.directory !== undefined || request.filename !== undefined || request.contentBase64 !== undefined
|| request.maximumEntries !== undefined || request.afterName !== undefined || request.continuation !== undefined) throw invalid();
} else if (request.operation === "read-auth-config" || request.operation === "read-local-users") {
if (request.directory !== undefined || request.contentBase64 !== undefined || request.maximumEntries !== undefined
|| request.afterName !== undefined || request.continuation !== undefined
|| request.filename === undefined || !AUTH_CONFIG_FILENAME.test(request.filename)) throw invalid();
} else if (request.operation === "list") {
if (request.directory === undefined) throw invalid();
if (request.filename !== undefined || request.contentBase64 !== undefined) throw invalid();
if (request.maximumEntries !== undefined && (!Number.isInteger(request.maximumEntries)
|| request.maximumEntries < 1 || request.maximumEntries > MAX_ENTRIES)) throw invalid();
if (request.continuation === true) {
if (request.directory !== "sessions" || (request.afterName !== undefined && !DIGEST_FILENAME.test(request.afterName))) {
throw invalid();
}
} else if (request.afterName !== undefined || request.continuation !== undefined) {
throw invalid();
}
} else {
if (request.directory === undefined) throw invalid();
if (request.maximumEntries !== undefined || request.afterName !== undefined || request.continuation !== undefined) throw invalid();
if (request.filename === undefined) throw invalid();
const allowClaim = request.operation === "remove" && request.directory === "oidc";
const allowOidcSlot = request.directory === "oidc"
&& (request.operation === "create" || request.operation === "read" || request.operation === "remove");
validateFilename(request.filename, allowClaim, allowOidcSlot);
if (request.contentBase64 !== undefined && request.operation !== "create" && request.operation !== "replace") throw invalid();
}
if ((request.operation === "claim-consume" || request.operation === "read-claim" || request.operation === "remove-claim")
&& request.directory !== "oidc") throw invalid();
if (request.contentBase64 !== undefined) {
if (request.directory === undefined) throw invalid();
canonicalBase64(request.contentBase64, directoryMaximum(request.directory));
}
const encoded = Buffer.from(JSON.stringify(request), "utf8");
if (encoded.length === 0 || encoded.length > MAX_PROTOCOL_BYTES) throw invalid();
return encoded;
}
function environmentForBridge(): NodeJS.ProcessEnv {
const path = process.env.PATH;
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT;
return {
...(path === undefined ? {} : { PATH: path }),
...(systemRoot === undefined ? {} : { SystemRoot: systemRoot }),
};
}
const spawnTht: WindowsAuthStorageSpawn = (executable, args, options) => spawn(executable, [...args], options);
function invokeThtSync(invocation: WindowsAuthStorageInvocation): WindowsAuthStorageInvocationResult {
try {
const result = spawnSync(invocation.executable, [...invocation.args], {
shell: false,
windowsHide: true,
env: environmentForBridge(),
input: invocation.input,
timeout: invocation.timeoutMs,
maxBuffer: invocation.maximumOutputBytes,
encoding: "buffer",
});
if (result.error || result.signal !== null || typeof result.status !== "number"
|| !Buffer.isBuffer(result.stdout) || !Buffer.isBuffer(result.stderr)) throw invalid();
return { code: result.status, stdout: result.stdout, stderr: result.stderr };
} catch {
throw invalid();
}
}
async function invokeTht(
invocation: WindowsAuthStorageInvocation,
spawnChild: WindowsAuthStorageSpawn = spawnTht,
beforeInputForTest?: () => Promise<void>,
terminationGraceMs = TERMINATION_GRACE_MS,
finalSettlementMs = FINAL_SETTLEMENT_MS,
): Promise<WindowsAuthStorageInvocationResult> {
return new Promise((resolve, reject) => {
let settled = false;
let aborted = false;
let timeout: NodeJS.Timeout | undefined;
let terminationTimer: NodeJS.Timeout | undefined;
let finalSettlementTimer: NodeJS.Timeout | undefined;
let lateErrorReleaseTimer: NodeJS.Timeout | undefined;
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let child: WindowsAuthStorageChild | undefined;
let stdin: Writable | undefined;
let stdoutStream: Readable | undefined;
let stderrStream: Readable | undefined;
const swallowChildError = (): void => undefined;
const swallowStreamError = (): void => undefined;
const releaseQuarantine = (): void => {
if (lateErrorReleaseTimer !== undefined) clearTimeout(lateErrorReleaseTimer);
lateErrorReleaseTimer = undefined;
removeChildListener("error", swallowChildError);
removeChildListener("close", releaseQuarantine);
removeStreamListener(stdin, "error", swallowStreamError);
removeStreamListener(stdoutStream, "error", swallowStreamError);
removeStreamListener(stderrStream, "error", swallowStreamError);
};
const quarantineLateErrors = (): void => {
// A final-deadline settlement can precede a broken ChildProcess object's terminal events.
// Keep only no-capture listeners for a bounded grace period so a late EventEmitter error
// cannot become uncaught, including after an already-observed close event.
child?.on("error", swallowChildError);
child?.once("close", releaseQuarantine);
stdin?.on("error", swallowStreamError);
stdoutStream?.on("error", swallowStreamError);
stderrStream?.on("error", swallowStreamError);
lateErrorReleaseTimer = setTimeout(releaseQuarantine, finalSettlementMs);
lateErrorReleaseTimer.unref?.();
};
const removeChildListener = (event: "error" | "close", listener: (...args: any[]) => void): void => {
try { child?.removeListener?.(event, listener); } catch { /* the helper is already terminal */ }
};
const removeStreamListener = (stream: Writable | Readable | undefined, event: "data" | "error", listener: (...args: any[]) => void): void => {
try { stream?.removeListener(event, listener); } catch { /* the helper is already terminal */ }
};
const stopStream = (stream: Writable | Readable | null | undefined): void => {
try { stream?.destroy(); } catch { /* abort is already fail-closed */ }
};
const onChildError = (): void => abort();
const onStdinError = (): void => abort();
const onStdoutError = (): void => abort();
const onStderrError = (): void => abort();
const onStdoutData = (chunk: Buffer): void => {
if (aborted || settled) return;
stdoutBytes += chunk.length;
if (stdoutBytes > invocation.maximumOutputBytes) {
abort();
return;
}
stdout.push(Buffer.from(chunk));
};
const onStderrData = (chunk: Buffer): void => {
if (aborted || settled) return;
stderrBytes += chunk.length;
if (stderrBytes > MAX_RESPONSE_BYTES) {
abort();
return;
}
stderr.push(Buffer.from(chunk));
};
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (settled) return;
if (aborted || code === null || !Number.isInteger(code) || signal !== null) {
settle(() => reject(invalid()), true);
return;
}
settle(() => resolve({
code,
stdout: Buffer.concat(stdout),
stderr: Buffer.concat(stderr),
}));
};
const cleanup = (quarantine = false): void => {
if (timeout !== undefined) clearTimeout(timeout);
if (terminationTimer !== undefined) clearTimeout(terminationTimer);
if (finalSettlementTimer !== undefined) clearTimeout(finalSettlementTimer);
removeChildListener("error", onChildError);
removeChildListener("close", onClose as (...args: any[]) => void);
removeStreamListener(stdin, "error", onStdinError);
removeStreamListener(stdoutStream, "data", onStdoutData);
removeStreamListener(stdoutStream, "error", onStdoutError);
removeStreamListener(stderrStream, "data", onStderrData);
removeStreamListener(stderrStream, "error", onStderrError);
if (quarantine) quarantineLateErrors();
};
const settle = (callback: () => void, quarantine = false): void => {
if (settled) return;
settled = true;
cleanup(quarantine);
callback();
};
const abort = (): void => {
if (aborted || settled) return;
aborted = true;
if (timeout !== undefined) clearTimeout(timeout);
if (child !== undefined) {
stopStream(stdin);
stopStream(stdoutStream);
stopStream(stderrStream);
try { child.kill("SIGTERM"); } catch { /* final settlement still owns completion */ }
try { child.unref?.(); } catch { /* the bounded timers still own completion */ }
}
terminationTimer = setTimeout(() => {
if (settled || child === undefined) return;
try { child.kill("SIGKILL"); } catch { /* final settlement still owns completion */ }
}, terminationGraceMs);
finalSettlementTimer = setTimeout(() => {
settle(() => reject(invalid()), true);
}, finalSettlementMs);
};
try {
child = spawnChild(invocation.executable, invocation.args, {
shell: false,
windowsHide: true,
stdio: ["pipe", "pipe", "pipe"],
env: environmentForBridge(),
});
} catch {
settle(() => reject(invalid()));
return;
}
child.once("close", onClose);
child.on("error", onChildError);
if (!child.stdin || !child.stdout || !child.stderr) {
abort();
return;
}
stdin = child.stdin;
stdoutStream = child.stdout;
stderrStream = child.stderr;
timeout = setTimeout(() => {
abort();
}, invocation.timeoutMs);
stdoutStream.on("data", onStdoutData);
stdoutStream.once("error", onStdoutError);
stderrStream.on("data", onStderrData);
stderrStream.once("error", onStderrError);
stdin.once("error", onStdinError);
const writeInput = (): void => {
if (aborted || settled) return;
try {
stdin.end(invocation.input);
} catch {
abort();
}
};
if (beforeInputForTest === undefined) {
writeInput();
} else {
void Promise.resolve().then(beforeInputForTest).then(writeInput, abort);
}
});
}
function contentFrom(response: BridgeResponse, maximum: number): Buffer | undefined {
if (response.found !== true) {
if (response.contentBase64 !== undefined) throw invalid();
return undefined;
}
if (response.contentBase64 === undefined) throw invalid();
return canonicalBase64(response.contentBase64, maximum);
}
function listedEntries(
response: BridgeResponse,
directory: WindowsAuthStorageDirectory,
maximumEntries: number,
): WindowsAuthStorageEntry[] {
if (response.entries === undefined || response.entries.length > maximumEntries) throw invalid();
const names = new Set<string>();
for (const entry of response.entries) {
if (!DIGEST_FILENAME.test(entry.name) && !(directory === "oidc"
&& (CLAIM_FILENAME.test(entry.name) || OIDC_SLOT_FILENAME.test(entry.name)))) throw invalid();
if (names.has(entry.name)) throw invalid();
names.add(entry.name);
}
return response.entries.map((entry) => ({ name: entry.name, modifiedUnixMs: entry.modifiedUnixMs }));
}
function createAuthStorageBridge(
pathStyle: AuthStoragePathStyle,
options: WindowsAuthStorageBridgeOptions = {},
): WindowsAuthStorageBridge {
const executable = safeThtExecutable(options.thtExecutable, pathStyle);
const testDeadlines = options.deadlinesForTest;
const timeoutMs = testDeadlines?.timeoutMs ?? TIMEOUT_MS;
const terminationGraceMs = testDeadlines?.terminationGraceMs ?? TERMINATION_GRACE_MS;
const finalSettlementMs = testDeadlines?.finalSettlementMs ?? FINAL_SETTLEMENT_MS;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > TIMEOUT_MS
|| !Number.isSafeInteger(terminationGraceMs) || terminationGraceMs < 1 || terminationGraceMs > TIMEOUT_MS
|| !Number.isSafeInteger(finalSettlementMs) || finalSettlementMs <= terminationGraceMs || finalSettlementMs > TIMEOUT_MS) {
throw invalid();
}
const invoke = options.invoke ?? ((invocation: WindowsAuthStorageInvocation) => invokeTht(
invocation,
options.spawnChild,
options.beforeInputForTest,
terminationGraceMs,
finalSettlementMs,
));
const invokeSync = options.invokeSync ?? invokeThtSync;
const request = async (value: BridgeRequest): Promise<BridgeResponse> => {
try {
const maximumOutputBytes = value.operation === "read-local-users"
? MAX_AUTH_CONFIG_RESPONSE_BYTES
: MAX_RESPONSE_BYTES;
const response = await invoke({
executable,
args: ["_auth-storage"],
input: encodedRequest(value, pathStyle),
timeoutMs,
maximumOutputBytes,
});
return parseResponse(response, maximumOutputBytes);
} catch {
throw invalid();
}
};
const syncRequest = (value: BridgeRequest): BridgeResponse => {
try {
const response = invokeSync({
executable,
args: ["_auth-storage"],
input: encodedRequest(value, pathStyle),
timeoutMs,
maximumOutputBytes: MAX_AUTH_CONFIG_RESPONSE_BYTES,
});
return parseResponse(response, MAX_AUTH_CONFIG_RESPONSE_BYTES);
} catch {
throw invalid();
}
};
const recordRequest = (operation: "create" | "read" | "replace" | "remove" | "claim-consume" | "read-claim" | "remove-claim", root: string, directory: WindowsAuthStorageDirectory, filename: string, contents?: Buffer): BridgeRequest => ({
version: PROTOCOL_VERSION,
operation,
root,
directory,
filename,
...(contents === undefined ? {} : { contentBase64: contents.toString("base64") }),
});
return {
async validateRoot(root) {
const response = await request({ version: PROTOCOL_VERSION, operation: "validate-root", root });
if (response.validated !== true
|| Object.keys(response).some((key) => !["version", "ok", "validated"].includes(key))) throw invalid();
},
async ensureLayout(root) {
const response = await request({ version: PROTOCOL_VERSION, operation: "ensure-layout", root });
if (response.prepared !== true
|| Object.keys(response).some((key) => !["version", "ok", "prepared"].includes(key))) throw invalid();
},
readAuthConfig(path) {
const paths = pathStyle === "windows" ? win32 : posix;
if (typeof path !== "string" || path.length === 0 || /[\u0000-\u001f\u007f]/.test(path)
|| !paths.isAbsolute(path) || paths.normalize(path) !== path) throw invalid();
const root = paths.dirname(path);
const filename = paths.basename(path);
if (!AUTH_CONFIG_FILENAME.test(filename) || paths.join(root, filename) !== path) throw invalid();
const response = syncRequest({ version: PROTOCOL_VERSION, operation: "read-auth-config", root, filename });
if (Object.keys(response).some((key) => !["version", "ok", "found", "contentBase64"].includes(key))) throw invalid();
const contents = contentFrom(response, MAX_AUTH_CONFIG_BYTES);
if (contents === undefined) throw invalid();
return contents;
},
async readLocalUsers(path) {
const paths = pathStyle === "windows" ? win32 : posix;
if (typeof path !== "string" || path.length === 0 || /[\u0000-\u001f\u007f]/.test(path)
|| !paths.isAbsolute(path) || paths.normalize(path) !== path) throw invalid();
const root = paths.dirname(path);
const filename = paths.basename(path);
if (!AUTH_CONFIG_FILENAME.test(filename) || paths.join(root, filename) !== path) throw invalid();
const response = await request({ version: PROTOCOL_VERSION, operation: "read-local-users", root, filename });
if (Object.keys(response).some((key) => !["version", "ok", "found", "contentBase64"].includes(key))) throw invalid();
const contents = contentFrom(response, MAX_AUTH_CONFIG_BYTES);
if (contents === undefined) throw invalid();
return contents;
},
async create(root, directory, filename, contents) {
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > directoryMaximum(directory)) throw invalid();
const response = await request(recordRequest("create", root, directory, filename, contents));
if (response.created === undefined) throw invalid();
return response.created;
},
async read(root, directory, filename) {
return contentFrom(await request(recordRequest("read", root, directory, filename)), directoryMaximum(directory));
},
async replace(root, directory, filename, contents) {
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > directoryMaximum(directory)) throw invalid();
const response = await request(recordRequest("replace", root, directory, filename, contents));
if (response.replaced !== true) throw invalid();
},
async remove(root, directory, filename) {
const response = await request(recordRequest("remove", root, directory, filename));
return response.removed === true;
},
async list(root, directory, maximumEntries = DEFAULT_MAX_ENTRIES) {
if (!Number.isInteger(maximumEntries) || maximumEntries < 1 || maximumEntries > MAX_ENTRIES) throw invalid();
const response = await request({
version: PROTOCOL_VERSION,
operation: "list",
root,
directory,
maximumEntries,
});
if (response.more !== undefined) throw invalid();
return listedEntries(response, directory, maximumEntries);
},
async listPage(root, directory, afterName, maximumEntries) {
if (directory !== "sessions" || !Number.isInteger(maximumEntries)
|| maximumEntries < 1 || maximumEntries > MAX_ENTRIES
|| (afterName !== undefined && !DIGEST_FILENAME.test(afterName))) throw invalid();
const response = await request({
version: PROTOCOL_VERSION,
operation: "list",
root,
directory,
maximumEntries,
continuation: true,
...(afterName === undefined ? {} : { afterName }),
});
if (response.more === undefined) throw invalid();
const entries = listedEntries(response, directory, maximumEntries);
let previous = afterName;
for (const entry of entries) {
if (previous !== undefined && entry.name <= previous) throw invalid();
previous = entry.name;
}
if (response.more && entries.length !== maximumEntries) throw invalid();
if (response.more && (previous === undefined || previous === afterName)) throw invalid();
return { entries, more: response.more };
},
async claimConsume(root, filename) {
return contentFrom(await request(recordRequest("claim-consume", root, "oidc", filename)), MAX_OIDC_BYTES);
},
async readClaim(root, filename) {
return contentFrom(await request(recordRequest("read-claim", root, "oidc", filename)), MAX_OIDC_BYTES);
},
async removeClaim(root, filename) {
const response = await request(recordRequest("remove-claim", root, "oidc", filename));
return response.removed === true;
},
};
}
export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridgeOptions = {}): WindowsAuthStorageBridge {
return createAuthStorageBridge("windows", options);
}
/** POSIX uses the same single hidden tht protocol and bounds, with native canonical path rules. */
export function createPosixAuthStorageBridge(options: WindowsAuthStorageBridgeOptions = {}): WindowsAuthStorageBridge {
return createAuthStorageBridge("posix", options);
}