293 lines
13 KiB
TypeScript
293 lines
13 KiB
TypeScript
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<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function exactKeys(value: Record<string, unknown>, 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<string> {
|
|
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<string> {
|
|
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<LibrarySourceIdentityIR> {
|
|
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<LibrarySourceIdentityIR> {
|
|
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<LibraryOperationBindingIR, "invalidationToken">;
|
|
|
|
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<LibraryOperationBindingInput> {
|
|
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<string> {
|
|
return `libtoken:${await sha256(stableJSON(value))}`;
|
|
}
|
|
|
|
export async function createLibraryOperationBinding(value: LibraryOperationBindingInput): Promise<LibraryOperationBindingIR> {
|
|
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<LibraryOperationBindingIR> {
|
|
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<LibraryOperationBindingIR> {
|
|
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;
|
|
}
|