Advance Blender 5.2 web parity through M12-03D
This commit is contained in:
292
web/protocol/library-operation-identity.ts
Normal file
292
web/protocol/library-operation-identity.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user