import { createHash, randomUUID } from "node:crypto"; import { constants } from "node:fs"; import { lstat, mkdir, open, rm } from "node:fs/promises"; import path from "node:path"; import type { AdapterContext, ArtifactByteRange, ArtifactPut, ArtifactStore, ArtifactStreamPut, NotificationMessage, NotificationProvider, StoredArtifactStream, } from "@rakazo/adapter-kit"; import { ArtifactTooLargeError } from "@rakazo/adapter-kit"; import { byteChunks, fileByteStream, writeFully } from "./byte-streams.js"; const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0; /** Space ids and artifact keys are generated ids; anything else could address another path. */ const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; export class LocalArtifactStore implements ArtifactStore { constructor(private readonly root: string) {} describe() { return { id: "local-artifacts", contractVersion: "1", adapterVersion: "0.1.0", capabilities: { stream: true }, }; } private spaceDir(context: AdapterContext) { if (!SAFE_SEGMENT.test(context.spaceId)) throw new Error("Invalid artifact space"); return path.join(this.root, "artifacts", context.spaceId); } private file(key: string, context: AdapterContext) { if (!SAFE_SEGMENT.test(key)) throw new Error("Invalid artifact key"); return path.join(this.spaceDir(context), key); } /** Creates a new owner-only file under a fresh key; never follows or replaces an existing path. */ private async create(context: AdapterContext) { const dir = this.spaceDir(context); await mkdir(dir, { recursive: true }); const key = randomUUID(); const file = path.join(dir, key); const handle = await open( file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | O_NOFOLLOW, 0o600, ); return { key, file, handle }; } private openForRead(key: string, context: AdapterContext) { return open(this.file(key, context), constants.O_RDONLY | O_NOFOLLOW); } async put(artifact: ArtifactPut, context: AdapterContext) { const { key, handle } = await this.create(context); try { await handle.writeFile(artifact.bytes); } finally { await handle.close(); } return { id: key, hash: String(artifact.bytes.byteLength) }; } async putStream( artifact: ArtifactStreamPut, context: AdapterContext, ): Promise { if (!Number.isSafeInteger(artifact.maxBytes) || artifact.maxBytes < 0) { throw new Error("maxBytes must be a non-negative integer"); } const { key, file, handle } = await this.create(context); const hash = createHash("sha256"); let size = 0; try { try { for await (const chunk of byteChunks(artifact.stream)) { context.signal.throwIfAborted(); size += chunk.byteLength; if (size > artifact.maxBytes) throw new ArtifactTooLargeError(artifact.maxBytes); hash.update(chunk); await writeFully(handle, chunk); } } finally { await handle.close(); } } catch (error) { await rm(file, { force: true }).catch(() => undefined); throw error; } return { key, size, sha256: hash.digest("hex") }; } async get(id: string, context: AdapterContext) { const handle = await this.openForRead(id, context); try { return new Uint8Array(await handle.readFile()); } finally { await handle.close(); } } async stat(key: string, context: AdapterContext) { const handle = await this.openForRead(key, context); try { const info = await handle.stat(); if (!info.isFile()) throw new Error("Artifact is not a file"); return { size: info.size }; } finally { await handle.close(); } } async createReadStream(key: string, context: AdapterContext, range?: ArtifactByteRange) { const handle = await this.openForRead(key, context); try { const info = await handle.stat(); if (!info.isFile()) throw new Error("Artifact is not a file"); const start = range?.start ?? 0; const end = range?.end ?? info.size - 1; if ( range && (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end >= info.size) ) { throw new RangeError("Range does not fit the artifact"); } return fileByteStream(handle, start, end); } catch (error) { await handle.close(); throw error; } } async localPath(key: string, context: AdapterContext) { const file = this.file(key, context); const info = await lstat(file); if (!info.isFile()) throw new Error("Artifact is not a file"); return file; } async remove(id: string, context: AdapterContext) { await rm(this.file(id, context), { force: true }); } } export class CapturingNotificationProvider implements NotificationProvider { readonly sent: NotificationMessage[] = []; describe() { return { id: "capturing", contractVersion: "1", adapterVersion: "0.1.0", capabilities: { push: false, email: false }, }; } async send(message: NotificationMessage, _context: AdapterContext): Promise { this.sent.push(message); } }