Repository Public
1import { randomBytes, randomUUID } from 'node:crypto';2import { createWriteStream } from 'node:fs';3import { mkdir, rename, rm, stat } from 'node:fs/promises';4import { dirname, join } from 'node:path';5import type { Readable } from 'node:stream';6import { pipeline } from 'node:stream/promises';7import { config, loadMasterKey } from './config.ts';8import { type BlockSource, decryptRange, encryptFile, fileSource, unwrapKey, wrapKey } from './crypto.ts';9import type { Db } from './db.ts';1011/** Where sealed objects live. Keys are opaque; nothing above this interface knows about paths. */12export interface BlobDriver {13 /** Move a sealed local file into the store under `key`. */14 ingest(key: string, sealedPath: string): Promise<void>;15 open(key: string): Promise<BlockSource>;16 remove(key: string): Promise<void>;17 exists(key: string): Promise<boolean>;18}19class FsDriver implements BlobDriver {20 constructor(private root: string) {}21 private path(key: string) { if (!/^[0-9a-f]{2}\/[0-9a-f]{2}\/[0-9a-f-]{36}$/.test(key)) throw new Error('bad blob key'); return join(this.root, key); }22 async ingest(key: string, sealedPath: string) { const p = this.path(key); await mkdir(dirname(p), { recursive: true, mode: 0o700 }); await rename(sealedPath, p); }23 open(key: string) { return fileSource(this.path(key)); }24 async remove(key: string) { await rm(this.path(key), { force: true }); }25 async exists(key: string) { return stat(this.path(key)).then(() => true, () => false); }26}27export const driver: BlobDriver = new FsDriver(config.blobDir);2829let master: Buffer | undefined;30export const masterKey = () => (master ??= loadMasterKey());31export interface Sealed { storage_key: string; wrapped_key: Buffer; size: number | bigint }3233/** Encrypt a plaintext file under a fresh data key and store it. The caller records the returned row. */34export async function sealFile(plainPath: string, size: number): Promise<{ storage_key: string; wrapped_key: Buffer }> {35 const id = randomUUID();36 const storage_key = `${id.slice(0, 2)}/${id.slice(2, 4)}/${id}`;37 const dataKey = randomBytes(32);38 const tmp = join(config.tmpDir, `seal-${id}`);39 try {40 await encryptFile(dataKey, plainPath, tmp, size);41 await driver.ingest(storage_key, tmp);42 } catch (e) { await rm(tmp, { force: true }); throw e; }43 return { storage_key, wrapped_key: wrapKey(masterKey(), dataKey) };44}45/** Plaintext stream of an object, optionally a byte range (inclusive). */46export function readSealed(o: Sealed, start?: number, end?: number): Readable {47 const key = unwrapKey(masterKey(), Buffer.from(o.wrapped_key));48 return decryptRange(key, () => driver.open(o.storage_key), Number(o.size), start, end);49}50/** Decrypt a whole object into a private work file (worker only). The caller deletes it. */51export async function materialize(o: Sealed, dest: string): Promise<void> {52 await pipeline(readSealed(o), createWriteStream(dest, { mode: 0o600, flags: 'wx' }));53}5455export interface ObjectRow extends Sealed { id: string; owner_id: string; sha256: string; scan_status: string }56/**57 * Store content for an owner, reusing the owner's identical object when it exists. Deduplication never crosses58 * owners: another account holding the same bytes changes nothing observable (time, quota or result).59 * Must run under lockOwner. Returns [row, created].60 */61export async function ensureObject(t: Db, ownerId: string, plainPath: string, size: number, sha256: string): Promise<[ObjectRow, boolean]> {62 const [existing] = await t<ObjectRow[]>`select * from storage_objects where owner_id = ${ownerId} and sha256 = ${sha256} and size = ${size}`;63 if (existing && await driver.exists(existing.storage_key)) return [existing, false];64 const sealed = await sealFile(plainPath, size);65 try {66 if (existing) { // the row survived but its blob is gone: repair in place67 const [row] = await t<ObjectRow[]>`update storage_objects set storage_key = ${sealed.storage_key}, wrapped_key = ${sealed.wrapped_key} where id = ${existing.id} returning *`;68 return [row!, false];69 }70 const [row] = await t<ObjectRow[]>`insert into storage_objects (owner_id, sha256, size, storage_key, wrapped_key)71 values (${ownerId}, ${sha256}, ${size}, ${sealed.storage_key}, ${sealed.wrapped_key}) returning *`;72 return [row!, true];73 } catch (e) { await driver.remove(sealed.storage_key); throw e; }74}75/** Bytes an owner stores (distinct objects). */76export async function usedBytes(t: Db, ownerId: string): Promise<number> {77 const [r] = await t<{ n: string }[]>`select coalesce(sum(size), 0)::text as n from storage_objects where owner_id = ${ownerId}`;78 return Number(r!.n);79}80