src / imageSnapshots.ts
import { createHash, randomUUID } from "node:crypto";
import { mkdir, realpath, readFile, writeFile, rename, unlink } from "node:fs/promises";
import { resolve, relative, isAbsolute, join } from "node:path";
import { IMAGE_LIMIT, imageMime, readImage } from "./images";
const hash = (bytes: Buffer | string) => createHash("sha256").update(bytes).digest("hex");
// Snapshots belong to this chat's working directory, never a global image queue.
export class ImageSnapshots {
private directory?: Promise<string>;
constructor(private readonly getRoot: () => string) {}
private root(): Promise<string> {
return this.directory ??= (async () => {
const root = await realpath(this.getRoot());
const directory = join(root, ".strata-image-cache");
await mkdir(directory, { recursive: true });
const actual = await realpath(directory);
const rel = relative(root, actual);
if (!rel || isAbsolute(rel) || rel === ".." || rel.startsWith("../") || rel.startsWith("..\\")) throw new Error("Image cache escapes the chat working directory.");
return actual;
})();
}
private async atomic(path: string, data: Buffer | string): Promise<void> {
const temp = `${path}.${randomUUID()}.tmp`;
try { await writeFile(temp, data, { flag: "wx" }); await rename(temp, path); }
finally { await unlink(temp).catch(() => {}); }
}
async remember(identity: string, bytes: Buffer): Promise<void> {
if (bytes.length > IMAGE_LIMIT) throw new Error("Image snapshot exceeds 20 MiB.");
const directory = await this.root();
const digest = hash(bytes);
await this.atomic(join(directory, `${digest}.image`), bytes);
await this.atomic(join(directory, `${hash(identity)}.json`), JSON.stringify({ version: 1, digest, size: bytes.length }));
}
async recover(identity: string, signal: AbortSignal): Promise<Buffer | undefined> {
signal.throwIfAborted();
const directory = await this.root();
let data;
try { data = JSON.parse(await readFile(join(directory, `${hash(identity)}.json`), "utf8")); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; }
if (data.version !== 1 || !/^[a-f0-9]{64}$/.test(data.digest) || !Number.isInteger(data.size) || data.size > IMAGE_LIMIT || data.size < 1) throw new Error("Invalid image snapshot index.");
const bytes = await readImage(join(directory, `${data.digest}.image`), signal);
if (bytes.length !== data.size || hash(bytes) !== data.digest) throw new Error("Image snapshot changed or is corrupt.");
imageMime(bytes);
return bytes;
}
}
src / imageSnapshots.ts
import { createHash, randomUUID } from "node:crypto";
import { mkdir, realpath, readFile, writeFile, rename, unlink } from "node:fs/promises";
import { resolve, relative, isAbsolute, join } from "node:path";
import { IMAGE_LIMIT, imageMime, readImage } from "./images";
const hash = (bytes: Buffer | string) => createHash("sha256").update(bytes).digest("hex");
// Snapshots belong to this chat's working directory, never a global image queue.
export class ImageSnapshots {
private directory?: Promise<string>;
constructor(private readonly getRoot: () => string) {}
private root(): Promise<string> {
return this.directory ??= (async () => {
const root = await realpath(this.getRoot());
const directory = join(root, ".strata-image-cache");
await mkdir(directory, { recursive: true });
const actual = await realpath(directory);
const rel = relative(root, actual);
if (!rel || isAbsolute(rel) || rel === ".." || rel.startsWith("../") || rel.startsWith("..\\")) throw new Error("Image cache escapes the chat working directory.");
return actual;
})();
}
private async atomic(path: string, data: Buffer | string): Promise<void> {
const temp = `${path}.${randomUUID()}.tmp`;
try { await writeFile(temp, data, { flag: "wx" }); await rename(temp, path); }
finally { await unlink(temp).catch(() => {}); }
}
async remember(identity: string, bytes: Buffer): Promise<void> {
if (bytes.length > IMAGE_LIMIT) throw new Error("Image snapshot exceeds 20 MiB.");
const directory = await this.root();
const digest = hash(bytes);
await this.atomic(join(directory, `${digest}.image`), bytes);
await this.atomic(join(directory, `${hash(identity)}.json`), JSON.stringify({ version: 1, digest, size: bytes.length }));
}
async recover(identity: string, signal: AbortSignal): Promise<Buffer | undefined> {
signal.throwIfAborted();
const directory = await this.root();
let data;
try { data = JSON.parse(await readFile(join(directory, `${hash(identity)}.json`), "utf8")); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; }
if (data.version !== 1 || !/^[a-f0-9]{64}$/.test(data.digest) || !Number.isInteger(data.size) || data.size > IMAGE_LIMIT || data.size < 1) throw new Error("Invalid image snapshot index.");
const bytes = await readImage(join(directory, `${data.digest}.image`), signal);
if (bytes.length !== data.size || hash(bytes) !== data.digest) throw new Error("Image snapshot changed or is corrupt.");
imageMime(bytes);
return bytes;
}
}