import type { ErrorCode } from "./error"; export const LIBRARY_OPERATION_IDENTITY_SCHEMA = 1 as const; export const LIBRARY_OPERATION_IDENTITY_BUDGET = Object.freeze({ maxSourceLocatorBytes: 4_096, maxDataBlockIdBytes: 1_024, maxOwnerIdBytes: 1_024, }); export type LibraryOperation = "APPEND" | "LINK" | "LIBRARY_OVERRIDE"; export interface LibrarySourceIdentityIR { schemaVersion: typeof LIBRARY_OPERATION_IDENTITY_SCHEMA; sourceLibraryId: string; sourceLocator: string; sourceSha256: string; } export interface LocalMainOwnerIR { kind: "LOCAL_MAIN"; projectId: string; localDataBlockId: string; } export interface SourceLibraryOwnerIR { kind: "SOURCE_LIBRARY"; sourceLibraryId: string; sourceDataBlockId: string; } export interface LocalOverrideOwnerIR { kind: "LOCAL_OVERRIDE"; projectId: string; localDataBlockId: string; referenceSourceDataBlockId: string; hierarchyRootDataBlockId: string; } export type LibraryOperationOwnerIR = LocalMainOwnerIR | SourceLibraryOwnerIR | LocalOverrideOwnerIR; export interface LibraryOperationBindingIR { schemaVersion: typeof LIBRARY_OPERATION_IDENTITY_SCHEMA; operation: LibraryOperation; source: LibrarySourceIdentityIR; sourceDataBlockId: string; owner: LibraryOperationOwnerIR; readOnly: boolean; referenceReadOnly: boolean; sourceGeneration: number; sourceRevision: number; dependencyClosureSha256: string; invalidationToken: string; } export interface LibrarySourceStateIR { sourceLibraryId: string; sourceSha256: string; sourceGeneration: number; sourceRevision: number; dependencyClosureSha256: string; } export class LibraryOperationIdentityError extends Error { readonly code: ErrorCode; constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "LibraryOperationIdentityError"; this.code = code; } } const SHA256 = /^[a-f0-9]{64}$/; const LIBRARY_ID = /^library:[a-f0-9]{64}$/; const INVALIDATION_TOKEN = /^libtoken:[a-f0-9]{64}$/; const encoder = new TextEncoder(); function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function exactKeys(value: Record, allowed: readonly string[], path: string): void { const keys = new Set(allowed); if (Object.keys(value).some((key) => !keys.has(key))) { throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${path} contains undeclared fields`); } } function text(value: unknown, path: string, maximumBytes: number): string { if (typeof value !== "string" || value.length === 0 || encoder.encode(value).byteLength > maximumBytes) { throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${path} is outside its UTF-8 byte budget`); } return value; } function digest(value: unknown, path: string): string { if (typeof value !== "string" || !SHA256.test(value)) { throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${path} must be a lowercase SHA-256 digest`); } return value; } function integer(value: unknown, path: string, minimum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) { throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${path} must be a safe integer >= ${minimum}`); } return 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 sha256(value: string): Promise { const result = await crypto.subtle.digest("SHA-256", encoder.encode(value)); return Array.from(new Uint8Array(result), (byte) => byte.toString(16).padStart(2, "0")).join(""); } export async function computeLibrarySourceId(value: { sourceLocator: string; sourceSha256: string }): Promise { return `library:${await sha256(stableJSON({ schema: "BLENDER_LIBRARY_SOURCE_V1", sourceLocator: value.sourceLocator, sourceSha256: value.sourceSha256, }))}`; } export async function createLibrarySourceIdentity(value: { sourceLocator: string; sourceSha256: string; }): Promise { const sourceLocator = text(value.sourceLocator, "sourceLocator", LIBRARY_OPERATION_IDENTITY_BUDGET.maxSourceLocatorBytes); const sourceSha256 = digest(value.sourceSha256, "sourceSha256"); return { schemaVersion: LIBRARY_OPERATION_IDENTITY_SCHEMA, sourceLibraryId: await computeLibrarySourceId({ sourceLocator, sourceSha256 }), sourceLocator, sourceSha256, }; } export async function parseLibrarySourceIdentity(value: unknown): Promise { if (!record(value) || value.schemaVersion !== LIBRARY_OPERATION_IDENTITY_SCHEMA) { throw new LibraryOperationIdentityError("PROTOCOL_MISMATCH", "unsupported library source identity schema"); } exactKeys(value, ["schemaVersion", "sourceLibraryId", "sourceLocator", "sourceSha256"], "source"); const sourceLocator = text(value.sourceLocator, "source.sourceLocator", LIBRARY_OPERATION_IDENTITY_BUDGET.maxSourceLocatorBytes); const sourceSha256 = digest(value.sourceSha256, "source.sourceSha256"); if (typeof value.sourceLibraryId !== "string" || !LIBRARY_ID.test(value.sourceLibraryId) || value.sourceLibraryId !== await computeLibrarySourceId({ sourceLocator, sourceSha256 })) { throw new LibraryOperationIdentityError("ASSET_SOURCE_HASH_MISMATCH", "sourceLibraryId does not bind locator and source SHA-256"); } return { schemaVersion: LIBRARY_OPERATION_IDENTITY_SCHEMA, sourceLibraryId: value.sourceLibraryId, sourceLocator, sourceSha256, }; } function parseOwner(value: unknown, operation: LibraryOperation, source: LibrarySourceIdentityIR, sourceDataBlockId: string): LibraryOperationOwnerIR { if (!record(value)) throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "owner must be an object"); if (operation === "APPEND") { exactKeys(value, ["kind", "projectId", "localDataBlockId"], "owner"); if (value.kind !== "LOCAL_MAIN") throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "APPEND owner must be LOCAL_MAIN"); return { kind: "LOCAL_MAIN", projectId: text(value.projectId, "owner.projectId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxOwnerIdBytes), localDataBlockId: text(value.localDataBlockId, "owner.localDataBlockId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxDataBlockIdBytes), }; } if (operation === "LINK") { exactKeys(value, ["kind", "sourceLibraryId", "sourceDataBlockId"], "owner"); if (value.kind !== "SOURCE_LIBRARY" || value.sourceLibraryId !== source.sourceLibraryId || value.sourceDataBlockId !== sourceDataBlockId) { throw new LibraryOperationIdentityError("ASSET_SOURCE_HASH_MISMATCH", "LINK owner must match the source library and data-block"); } return { kind: "SOURCE_LIBRARY", sourceLibraryId: source.sourceLibraryId, sourceDataBlockId }; } exactKeys(value, ["kind", "projectId", "localDataBlockId", "referenceSourceDataBlockId", "hierarchyRootDataBlockId"], "owner"); if (value.kind !== "LOCAL_OVERRIDE" || value.referenceSourceDataBlockId !== sourceDataBlockId) { throw new LibraryOperationIdentityError("ASSET_SOURCE_HASH_MISMATCH", "LIBRARY_OVERRIDE owner must retain the source reference"); } return { kind: "LOCAL_OVERRIDE", projectId: text(value.projectId, "owner.projectId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxOwnerIdBytes), localDataBlockId: text(value.localDataBlockId, "owner.localDataBlockId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxDataBlockIdBytes), referenceSourceDataBlockId: sourceDataBlockId, hierarchyRootDataBlockId: text(value.hierarchyRootDataBlockId, "owner.hierarchyRootDataBlockId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxDataBlockIdBytes), }; } type LibraryOperationBindingInput = Omit; function assertOwnershipSemantics(operation: LibraryOperation, readOnly: boolean, referenceReadOnly: boolean): void { const valid = operation === "APPEND" ? !readOnly && !referenceReadOnly : operation === "LINK" ? readOnly && referenceReadOnly : !readOnly && referenceReadOnly; if (!valid) { throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${operation} read-only semantics are invalid`); } } async function parseBindingBase(value: unknown): Promise { if (!record(value) || value.schemaVersion !== LIBRARY_OPERATION_IDENTITY_SCHEMA) { throw new LibraryOperationIdentityError("PROTOCOL_MISMATCH", "unsupported library operation binding schema"); } const operation = value.operation; if (operation !== "APPEND" && operation !== "LINK" && operation !== "LIBRARY_OVERRIDE") { throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "operation is invalid"); } const source = await parseLibrarySourceIdentity(value.source); const sourceDataBlockId = text(value.sourceDataBlockId, "sourceDataBlockId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxDataBlockIdBytes); if (typeof value.readOnly !== "boolean" || typeof value.referenceReadOnly !== "boolean") { throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "read-only fields must be boolean"); } assertOwnershipSemantics(operation, value.readOnly, value.referenceReadOnly); return { schemaVersion: LIBRARY_OPERATION_IDENTITY_SCHEMA, operation, source, sourceDataBlockId, owner: parseOwner(value.owner, operation, source, sourceDataBlockId), readOnly: value.readOnly, referenceReadOnly: value.referenceReadOnly, sourceGeneration: integer(value.sourceGeneration, "sourceGeneration", 1), sourceRevision: integer(value.sourceRevision, "sourceRevision", 0), dependencyClosureSha256: digest(value.dependencyClosureSha256, "dependencyClosureSha256"), }; } export async function computeLibraryInvalidationToken(value: LibraryOperationBindingInput): Promise { return `libtoken:${await sha256(stableJSON(value))}`; } export async function createLibraryOperationBinding(value: LibraryOperationBindingInput): Promise { if (!record(value)) throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "binding must be an object"); exactKeys(value, [ "schemaVersion", "operation", "source", "sourceDataBlockId", "owner", "readOnly", "referenceReadOnly", "sourceGeneration", "sourceRevision", "dependencyClosureSha256", ], "binding"); const parsed = await parseBindingBase(value); return { ...parsed, invalidationToken: await computeLibraryInvalidationToken(parsed) }; } export async function parseLibraryOperationBinding(value: unknown): Promise { if (!record(value)) throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "binding must be an object"); exactKeys(value, [ "schemaVersion", "operation", "source", "sourceDataBlockId", "owner", "readOnly", "referenceReadOnly", "sourceGeneration", "sourceRevision", "dependencyClosureSha256", "invalidationToken", ], "binding"); const parsed = await parseBindingBase(value); if (typeof value.invalidationToken !== "string" || !INVALIDATION_TOKEN.test(value.invalidationToken) || value.invalidationToken !== await computeLibraryInvalidationToken(parsed)) { throw new LibraryOperationIdentityError("REVISION_CONFLICT", "library operation invalidation token is stale or forged"); } return { ...parsed, invalidationToken: value.invalidationToken }; } function parseSourceState(value: unknown): LibrarySourceStateIR { if (!record(value)) throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "source state must be an object"); exactKeys(value, ["sourceLibraryId", "sourceSha256", "sourceGeneration", "sourceRevision", "dependencyClosureSha256"], "source state"); if (typeof value.sourceLibraryId !== "string" || !LIBRARY_ID.test(value.sourceLibraryId)) { throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "source state library ID is invalid"); } return { sourceLibraryId: value.sourceLibraryId, sourceSha256: digest(value.sourceSha256, "source state sourceSha256"), sourceGeneration: integer(value.sourceGeneration, "source state sourceGeneration", 1), sourceRevision: integer(value.sourceRevision, "source state sourceRevision", 0), dependencyClosureSha256: digest(value.dependencyClosureSha256, "source state dependencyClosureSha256"), }; } export async function assertLibraryOperationBindingCurrent( bindingValue: unknown, sourceStateValue: unknown, ): Promise { const binding = await parseLibraryOperationBinding(bindingValue); const state = parseSourceState(sourceStateValue); if (binding.source.sourceLibraryId !== state.sourceLibraryId || binding.source.sourceSha256 !== state.sourceSha256 || binding.sourceGeneration !== state.sourceGeneration || binding.sourceRevision !== state.sourceRevision || binding.dependencyClosureSha256 !== state.dependencyClosureSha256) { throw new LibraryOperationIdentityError("REVISION_CONFLICT", "library source or dependency closure has been invalidated"); } return binding; }