275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { constants as fsConstants } from "node:fs";
|
|
import { lstat, link, mkdir, open, realpath, unlink } from "node:fs/promises";
|
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
import { FAST_BROWSER_ARTIFACT_LIMITS } from "./contracts.ts";
|
|
import type {
|
|
PrivateArtifactKind,
|
|
PrivateArtifactMetadata,
|
|
PrivateArtifactOwner,
|
|
PrivateArtifactStore,
|
|
PrivateArtifactWriter,
|
|
} from "./contracts.ts";
|
|
|
|
const MAX_OWNER_FIELD = 200;
|
|
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
const ROOT_MODE = 0o700;
|
|
const FILE_MODE = 0o600;
|
|
|
|
export interface PrivateArtifactStoreOptions {
|
|
/** A dedicated local directory. It is never selected by a caller per artifact. */
|
|
root: string;
|
|
/** Maximum bytes per artifact; defaults to eight MiB. */
|
|
maxArtifactBytes?: number;
|
|
}
|
|
|
|
function safeOwnerField(value: unknown, label: string): string {
|
|
if (typeof value !== "string" || value.length === 0 || value.length > MAX_OWNER_FIELD || value.includes("\0")) {
|
|
throw new Error(`${label} is invalid`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function validateOwner(owner: PrivateArtifactOwner): PrivateArtifactOwner {
|
|
if (!owner || typeof owner !== "object") throw new Error("artifact owner is invalid");
|
|
return Object.freeze({
|
|
sessionId: safeOwnerField(owner.sessionId, "artifact sessionId"),
|
|
runId: safeOwnerField(owner.runId, "artifact runId"),
|
|
operationId: safeOwnerField(owner.operationId, "artifact operationId"),
|
|
});
|
|
}
|
|
|
|
function validateKind(kind: PrivateArtifactKind): PrivateArtifactKind {
|
|
if (kind !== "screenshot" && kind !== "diagnostics") throw new Error("artifact kind is invalid");
|
|
return kind;
|
|
}
|
|
|
|
function encodedComponent(value: string): string {
|
|
// Owner identifiers are deliberately not included verbatim in filesystem
|
|
// paths. This both prevents traversal and keeps credentials/tokens out of
|
|
// local artifact metadata and directory names.
|
|
return Buffer.from(value, "utf8").toString("base64url").slice(0, 96);
|
|
}
|
|
|
|
function artifactId(): string {
|
|
return `a-${randomBytes(18).toString("hex")}`;
|
|
}
|
|
|
|
async function assertRegularDirectory(path: string): Promise<void> {
|
|
const stat = await lstat(path);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("artifact path is not a private directory");
|
|
if (stat.mode & 0o077) throw new Error("artifact directory permissions are too broad");
|
|
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
if (uid !== undefined && stat.uid !== uid) throw new Error("artifact directory is not owned by this process");
|
|
}
|
|
|
|
async function assertNoSymlinkComponents(path: string): Promise<void> {
|
|
const absolute = resolve(path);
|
|
const parts = absolute.split(sep);
|
|
let current = parts[0] || sep;
|
|
for (const part of parts.slice(1)) {
|
|
if (!part) continue;
|
|
current = current === sep ? join(current, part) : join(current, part);
|
|
const stat = await lstat(current).catch(() => undefined);
|
|
if (stat?.isSymbolicLink()) throw new Error("artifact path may not contain symlinks");
|
|
}
|
|
}
|
|
|
|
async function ensurePrivateDirectory(path: string, create = true, tighten = true): Promise<void> {
|
|
await assertNoSymlinkComponents(path);
|
|
if (create) {
|
|
try {
|
|
await mkdir(path, { recursive: true, mode: ROOT_MODE });
|
|
} catch (error) {
|
|
throw new Error("could not create private artifact directory", { cause: error });
|
|
}
|
|
}
|
|
const stat = await lstat(path).catch(() => undefined);
|
|
if (!stat || !stat.isDirectory() || stat.isSymbolicLink()) {
|
|
throw new Error("artifact path is not a private directory");
|
|
}
|
|
// Never chmod a caller-owned parent or weaken a pre-existing directory's
|
|
// permissions. Store roots must already be private; only mkdir-created
|
|
// children are accepted with the requested mode.
|
|
if (tighten) await assertRegularDirectory(path);
|
|
}
|
|
|
|
function contentType(kind: PrivateArtifactKind): PrivateArtifactMetadata["contentType"] {
|
|
return kind === "screenshot" ? "image/png" : "application/json";
|
|
}
|
|
|
|
function isWithin(root: string, candidate: string): boolean {
|
|
const rel = relative(root, candidate);
|
|
return rel !== "" && rel !== ".." && !rel.startsWith(`..${resolve("/")}`) && !rel.startsWith("../") && !rel.startsWith("..\\") && !rel.includes("\0");
|
|
}
|
|
|
|
class FileArtifactWriter implements PrivateArtifactWriter {
|
|
readonly owner: PrivateArtifactOwner;
|
|
readonly kind: PrivateArtifactKind;
|
|
private handle: Awaited<ReturnType<typeof open>> | undefined;
|
|
private bytesWritten = 0;
|
|
private state: "open" | "committed" | "aborted" = "open";
|
|
private readonly tempPath: string;
|
|
private readonly finalPath: string;
|
|
private readonly id: string;
|
|
private readonly maxBytes: number;
|
|
private readonly onDone: () => void;
|
|
|
|
constructor(
|
|
owner: PrivateArtifactOwner,
|
|
kind: PrivateArtifactKind,
|
|
handle: Awaited<ReturnType<typeof open>>,
|
|
tempPath: string,
|
|
finalPath: string,
|
|
id: string,
|
|
maxBytes: number,
|
|
onDone: () => void,
|
|
) {
|
|
this.owner = owner;
|
|
this.kind = kind;
|
|
this.handle = handle;
|
|
this.tempPath = tempPath;
|
|
this.finalPath = finalPath;
|
|
this.id = id;
|
|
this.maxBytes = maxBytes;
|
|
this.onDone = onDone;
|
|
}
|
|
|
|
async write(chunk: Uint8Array): Promise<void> {
|
|
if (this.state !== "open" || !this.handle) throw new Error("artifact writer is closed");
|
|
if (!(chunk instanceof Uint8Array)) throw new Error("artifact chunk is invalid");
|
|
if (chunk.byteLength > this.maxBytes - this.bytesWritten) {
|
|
await this.abort();
|
|
throw new Error("artifact exceeds the private size limit");
|
|
}
|
|
if (chunk.byteLength === 0) return;
|
|
try {
|
|
await this.handle.write(chunk);
|
|
this.bytesWritten += chunk.byteLength;
|
|
} catch (error) {
|
|
await this.abort();
|
|
throw new Error("could not write private artifact", { cause: error });
|
|
}
|
|
}
|
|
|
|
async commit(): Promise<PrivateArtifactMetadata> {
|
|
if (this.state !== "open" || !this.handle) throw new Error("artifact writer is closed");
|
|
try {
|
|
await this.handle.sync();
|
|
const stat = await this.handle.stat();
|
|
if (!stat.isFile() || stat.size !== this.bytesWritten || stat.mode & 0o077) {
|
|
throw new Error("private artifact validation failed");
|
|
}
|
|
await this.handle.chmod(FILE_MODE);
|
|
await this.handle.close();
|
|
this.handle = undefined;
|
|
// link() makes the completed file visible atomically without replacing a
|
|
// pre-existing symlink or foreign artifact. The temporary inode is then
|
|
// removed; no caller-selected path is ever opened for writing.
|
|
await link(this.tempPath, this.finalPath);
|
|
await unlink(this.tempPath);
|
|
this.state = "committed";
|
|
this.onDone();
|
|
return Object.freeze({
|
|
artifactId: this.id,
|
|
kind: this.kind,
|
|
contentType: contentType(this.kind),
|
|
path: this.finalPath,
|
|
bytes: this.bytesWritten,
|
|
owner: this.owner,
|
|
permissions: "0600",
|
|
localOnly: true,
|
|
modelForwarded: false,
|
|
});
|
|
} catch (error) {
|
|
await this.abort();
|
|
throw error instanceof Error && error.message.startsWith("private artifact")
|
|
? error
|
|
: new Error("could not commit private artifact", { cause: error });
|
|
}
|
|
}
|
|
|
|
async abort(): Promise<void> {
|
|
if (this.state !== "open") return;
|
|
this.state = "aborted";
|
|
const handle = this.handle;
|
|
this.handle = undefined;
|
|
try { await handle?.close(); } catch { /* best effort cleanup */ }
|
|
try { await unlink(this.tempPath); } catch { /* already absent */ }
|
|
this.onDone();
|
|
}
|
|
}
|
|
|
|
/** Filesystem-backed implementation of the frozen PrivateArtifactStore API. */
|
|
export class FilePrivateArtifactStore implements PrivateArtifactStore {
|
|
readonly root: string;
|
|
private readonly maxBytes: number;
|
|
private readonly active = new Set<FileArtifactWriter>();
|
|
private closed = false;
|
|
private ready: Promise<void>;
|
|
|
|
constructor(options: PrivateArtifactStoreOptions | string) {
|
|
const root = typeof options === "string" ? options : options.root;
|
|
const maxBytes = typeof options === "string" ? DEFAULT_MAX_BYTES : options.maxArtifactBytes ?? DEFAULT_MAX_BYTES;
|
|
if (typeof root !== "string" || root.length === 0 || root.includes("\0")) throw new Error("artifact root is invalid");
|
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > DEFAULT_MAX_BYTES) throw new Error("artifact size limit is invalid");
|
|
this.root = resolve(root);
|
|
this.maxBytes = maxBytes;
|
|
this.ready = this.initialize();
|
|
}
|
|
|
|
private async initialize(): Promise<void> {
|
|
const parent = dirname(this.root);
|
|
await ensurePrivateDirectory(parent, true, false);
|
|
await assertNoSymlinkComponents(this.root);
|
|
const existing = await lstat(this.root).catch(() => undefined);
|
|
if (existing?.isSymbolicLink()) throw new Error("artifact root may not be a symlink");
|
|
await ensurePrivateDirectory(this.root);
|
|
await assertNoSymlinkComponents(this.root);
|
|
const canonical = await realpath(this.root);
|
|
if (canonical !== this.root) throw new Error("artifact root may not resolve through a symlink");
|
|
}
|
|
|
|
async open(owner: PrivateArtifactOwner, kind: PrivateArtifactKind): Promise<PrivateArtifactWriter> {
|
|
if (this.closed) throw new Error("artifact store is closed");
|
|
const checkedOwner = validateOwner(owner);
|
|
const checkedKind = validateKind(kind);
|
|
await this.ready;
|
|
if (this.closed) throw new Error("artifact store is closed");
|
|
const sessionDir = join(this.root, encodedComponent(checkedOwner.sessionId));
|
|
const runDir = join(sessionDir, encodedComponent(checkedOwner.runId));
|
|
await ensurePrivateDirectory(sessionDir);
|
|
await ensurePrivateDirectory(runDir);
|
|
const id = artifactId();
|
|
const extension = checkedKind === "screenshot" ? "png" : "json";
|
|
const finalPath = join(runDir, `${id}.${extension}`);
|
|
const tempPath = join(runDir, `.${id}.tmp`);
|
|
if (!isWithin(this.root, finalPath) || !isWithin(this.root, tempPath)) throw new Error("artifact path escaped private root");
|
|
// wx + O_NOFOLLOW ensures a malicious replacement cannot redirect writes.
|
|
const flags = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0);
|
|
const handle = await open(tempPath, flags, FILE_MODE).catch((error) => {
|
|
throw new Error("could not create private artifact", { cause: error });
|
|
});
|
|
await handle.chmod(FILE_MODE);
|
|
const kindLimit = checkedKind === "screenshot" ? this.maxBytes : Math.min(this.maxBytes, FAST_BROWSER_ARTIFACT_LIMITS.diagnosticsBytes);
|
|
const writer = new FileArtifactWriter(checkedOwner, checkedKind, handle, tempPath, finalPath, id, kindLimit, () => this.active.delete(writer));
|
|
this.active.add(writer);
|
|
return writer;
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
if (this.closed) return;
|
|
this.closed = true;
|
|
await this.ready.catch(() => undefined);
|
|
await Promise.all([...this.active].map((writer) => writer.abort()));
|
|
}
|
|
}
|
|
|
|
export function createPrivateArtifactStore(options: PrivateArtifactStoreOptions | string): PrivateArtifactStore {
|
|
return new FilePrivateArtifactStore(options);
|
|
}
|
|
|
|
// A descriptive alias keeps the concrete implementation discoverable without
|
|
// widening the frozen contract surface.
|
|
export const createArtifactStore = createPrivateArtifactStore;
|