227 lines
10 KiB
TypeScript
227 lines
10 KiB
TypeScript
import {
|
|
parseAssetCatalogManifestV2,
|
|
type AssetCatalogV2EntryIR,
|
|
type AssetCatalogV2PreviewIR,
|
|
} from "../../../protocol/asset-catalog-v2";
|
|
import type { ErrorCode } from "../../../protocol/error";
|
|
import { ASSET_CATALOG_INDEX_V2_ID } from "./asset-catalog-indexeddb";
|
|
import { projectLayout } from "./opfs-files";
|
|
|
|
export const ASSET_PREVIEW_QUARANTINE_SCHEMA = 1 as const;
|
|
export const ASSET_PREVIEW_QUARANTINE_PREFIX = "asset-preview:quarantine:v1:" as const;
|
|
|
|
export interface AssetPreviewQuarantineReceiptIR {
|
|
schemaVersion: typeof ASSET_PREVIEW_QUARANTINE_SCHEMA;
|
|
id: string;
|
|
task: "M12-02F";
|
|
status: "QUARANTINED";
|
|
projectId: string;
|
|
assetId: string;
|
|
catalogRevision: number;
|
|
expected: AssetCatalogV2PreviewIR;
|
|
actualByteLength: number | null;
|
|
actualSha256: string | null;
|
|
code: "ASSET_SOURCE_HASH_MISMATCH";
|
|
quarantinePath: string | null;
|
|
}
|
|
|
|
export type AssetPreviewInspectionIR =
|
|
| {
|
|
status: "READY";
|
|
asset: AssetCatalogV2EntryIR;
|
|
preview: AssetCatalogV2PreviewIR;
|
|
data: ArrayBuffer;
|
|
receipt: null;
|
|
}
|
|
| {
|
|
status: "QUARANTINED";
|
|
asset: AssetCatalogV2EntryIR;
|
|
preview: null;
|
|
data: null;
|
|
receipt: AssetPreviewQuarantineReceiptIR;
|
|
};
|
|
|
|
export class AssetPreviewQuarantineError extends Error {
|
|
readonly code: ErrorCode;
|
|
|
|
constructor(code: ErrorCode, message: string, options?: ErrorOptions) {
|
|
super(`${code}: ${message}`, options);
|
|
this.name = "AssetPreviewQuarantineError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
interface StoredRow {
|
|
id: string;
|
|
value?: unknown;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
type OpfsStorage = StorageManager & { getDirectory?: () => Promise<FileSystemDirectoryHandle> };
|
|
|
|
function record(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function stableJSON(value: unknown): string {
|
|
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
|
|
if (record(value)) {
|
|
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
async function sha256Bytes(value: ArrayBuffer): Promise<string> {
|
|
const digest = await crypto.subtle.digest("SHA-256", value);
|
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
function transactionComplete(transaction: IDBTransaction, failure: () => Error | undefined): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
transaction.oncomplete = () => resolve();
|
|
transaction.onerror = () => reject(failure() ?? transaction.error ?? new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "Preview quarantine transaction failed"));
|
|
transaction.onabort = () => reject(failure() ?? transaction.error ?? new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "Preview quarantine transaction aborted"));
|
|
});
|
|
}
|
|
|
|
function readRow(store: IDBObjectStore, id: string): Promise<StoredRow | undefined> {
|
|
return new Promise((resolve, reject) => {
|
|
const request = store.get(id);
|
|
request.onsuccess = () => resolve(request.result as StoredRow | undefined);
|
|
request.onerror = () => reject(request.error ?? new AssetPreviewQuarantineError("STORAGE_TRANSACTION", `Preview quarantine lookup failed for ${id}`));
|
|
});
|
|
}
|
|
|
|
function receiptId(assetId: string): string {
|
|
return `${ASSET_PREVIEW_QUARANTINE_PREFIX}${assetId}`;
|
|
}
|
|
|
|
function parseReceipt(value: unknown, asset: AssetCatalogV2EntryIR, projectId: string, revision: number): AssetPreviewQuarantineReceiptIR | null {
|
|
const digest = /^[a-f0-9]{64}$/;
|
|
if (!record(value) || value.schemaVersion !== ASSET_PREVIEW_QUARANTINE_SCHEMA || value.id !== receiptId(asset.assetId) ||
|
|
value.task !== "M12-02F" || value.status !== "QUARANTINED" || value.projectId !== projectId ||
|
|
value.assetId !== asset.assetId || value.catalogRevision !== revision || value.code !== "ASSET_SOURCE_HASH_MISMATCH" ||
|
|
!record(value.expected) || !asset.preview || stableJSON(value.expected) !== stableJSON(asset.preview) ||
|
|
(value.actualByteLength !== null && (!Number.isSafeInteger(value.actualByteLength) || Number(value.actualByteLength) < 0)) ||
|
|
(value.actualSha256 !== null && (typeof value.actualSha256 !== "string" || !digest.test(value.actualSha256))) ||
|
|
(value.quarantinePath !== null && (typeof value.quarantinePath !== "string" || value.quarantinePath.length === 0))) {
|
|
return null;
|
|
}
|
|
return value as unknown as AssetPreviewQuarantineReceiptIR;
|
|
}
|
|
|
|
async function directory(root: FileSystemDirectoryHandle, path: string, create = false): Promise<FileSystemDirectoryHandle> {
|
|
let current = root;
|
|
for (const segment of path.split("/")) current = await current.getDirectoryHandle(segment, { create });
|
|
return current;
|
|
}
|
|
|
|
async function readPayload(projectId: string, sha256: string, storage?: StorageManager): Promise<ArrayBuffer | null> {
|
|
const manager = (storage ?? (typeof navigator === "undefined" ? undefined : navigator.storage)) as OpfsStorage | undefined;
|
|
if (!manager?.getDirectory) throw new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "OPFS is unavailable for preview inspection");
|
|
try {
|
|
const layout = projectLayout(projectId);
|
|
const source = await directory(await manager.getDirectory(), `${layout.assetsPath}/sha256/${sha256.slice(0, 2)}`);
|
|
return (await (await source.getFileHandle(sha256)).getFile()).arrayBuffer();
|
|
}
|
|
catch (error) {
|
|
if (error instanceof DOMException && error.name === "NotFoundError") return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function quarantinePayload(
|
|
projectId: string,
|
|
expectedSha256: string,
|
|
data: ArrayBuffer | null,
|
|
actualSha256: string | null,
|
|
storage?: StorageManager,
|
|
): Promise<string | null> {
|
|
if (!data || !actualSha256) return null;
|
|
const manager = (storage ?? (typeof navigator === "undefined" ? undefined : navigator.storage)) as OpfsStorage | undefined;
|
|
if (!manager?.getDirectory) throw new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "OPFS is unavailable for preview quarantine");
|
|
const layout = projectLayout(projectId);
|
|
const root = await manager.getDirectory();
|
|
const source = await directory(root, `${layout.assetsPath}/sha256/${expectedSha256.slice(0, 2)}`);
|
|
const quarantine = await directory(root, `${layout.assetsPath}/quarantine`, true);
|
|
const name = `${expectedSha256}.${actualSha256.slice(0, 12)}.corrupt`;
|
|
const handle = await quarantine.getFileHandle(name, { create: true });
|
|
const writable = await handle.createWritable();
|
|
await writable.write(data);
|
|
await writable.close();
|
|
const copied = await (await handle.getFile()).arrayBuffer();
|
|
if (copied.byteLength !== data.byteLength || await sha256Bytes(copied) !== actualSha256) {
|
|
await quarantine.removeEntry(name).catch(() => undefined);
|
|
throw new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "Preview quarantine copy verification failed");
|
|
}
|
|
await source.removeEntry(expectedSha256);
|
|
return `${layout.assetsPath}/quarantine/${name}`;
|
|
}
|
|
|
|
export async function inspectAssetPreviewWithQuarantine(
|
|
database: IDBDatabase,
|
|
projectId: string,
|
|
assetId: string,
|
|
options: { settingStore?: string; storage?: StorageManager } = {},
|
|
): Promise<AssetPreviewInspectionIR> {
|
|
const settingStore = options.settingStore ?? "setting";
|
|
if (!database.objectStoreNames.contains(settingStore)) {
|
|
throw new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "Catalog setting store is unavailable");
|
|
}
|
|
const readTransaction = database.transaction(settingStore, "readonly");
|
|
const store = readTransaction.objectStore(settingStore);
|
|
const [catalogRow, quarantineRow] = await Promise.all([
|
|
readRow(store, ASSET_CATALOG_INDEX_V2_ID),
|
|
readRow(store, receiptId(assetId)),
|
|
]);
|
|
await transactionComplete(readTransaction, () => undefined);
|
|
if (!catalogRow) throw new AssetPreviewQuarantineError("ASSET_MANIFEST_INVALID", "Catalog v2 index is missing");
|
|
const manifest = await parseAssetCatalogManifestV2(catalogRow.value);
|
|
const asset = manifest.assets.find((candidate) => candidate.assetId === assetId);
|
|
if (!asset) throw new AssetPreviewQuarantineError("ASSET_MANIFEST_INVALID", "Preview asset is not in the catalog");
|
|
if (!asset.preview) throw new AssetPreviewQuarantineError("ASSET_MANIFEST_INVALID", "Catalog asset has no preview reference");
|
|
const existingReceipt = parseReceipt(quarantineRow, asset, projectId, manifest.revision);
|
|
if (existingReceipt) return { status: "QUARANTINED", asset, preview: null, data: null, receipt: existingReceipt };
|
|
|
|
const data = await readPayload(projectId, asset.preview.sha256, options.storage);
|
|
const actualSha256 = data ? await sha256Bytes(data) : null;
|
|
if (data && data.byteLength === asset.preview.byteLength && actualSha256 === asset.preview.sha256) {
|
|
return { status: "READY", asset, preview: asset.preview, data, receipt: null };
|
|
}
|
|
const quarantinePath = await quarantinePayload(projectId, asset.preview.sha256, data, actualSha256, options.storage);
|
|
const receipt: AssetPreviewQuarantineReceiptIR = {
|
|
schemaVersion: ASSET_PREVIEW_QUARANTINE_SCHEMA,
|
|
id: receiptId(asset.assetId),
|
|
task: "M12-02F",
|
|
status: "QUARANTINED",
|
|
projectId,
|
|
assetId: asset.assetId,
|
|
catalogRevision: manifest.revision,
|
|
expected: asset.preview,
|
|
actualByteLength: data?.byteLength ?? null,
|
|
actualSha256,
|
|
code: "ASSET_SOURCE_HASH_MISMATCH",
|
|
quarantinePath,
|
|
};
|
|
|
|
let failure: Error | undefined;
|
|
const transaction = database.transaction(settingStore, "readwrite");
|
|
const completion = transactionComplete(transaction, () => failure);
|
|
const writeStore = transaction.objectStore(settingStore);
|
|
const request = writeStore.get(ASSET_CATALOG_INDEX_V2_ID);
|
|
request.onerror = () => {
|
|
failure = request.error ?? new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "Catalog recheck failed during preview quarantine");
|
|
};
|
|
request.onsuccess = () => {
|
|
const current = request.result as StoredRow | undefined;
|
|
if (!current || stableJSON(current.value) !== stableJSON(catalogRow.value)) {
|
|
failure = new AssetPreviewQuarantineError("REVISION_CONFLICT", "Catalog changed while corrupt preview was quarantined");
|
|
transaction.abort();
|
|
return;
|
|
}
|
|
writeStore.put(receipt);
|
|
};
|
|
await completion;
|
|
return { status: "QUARANTINED", asset, preview: null, data: null, receipt };
|
|
}
|