Files
tech-log-frontend/src/contracts/contract-set-canonical.ts
T

112 lines
3.4 KiB
TypeScript

/**
* §5.3. The single canonical byte producer for a contract set.
*
* Node build scripts and the browser runtime share this function. Only the hash
* adapter differs (Node `crypto` vs Web Crypto), so a digest can never diverge
* because of JSON property order or a locale-sensitive sort.
*/
export type ContractSetPackage = Readonly<{
packageId: string;
version: string;
digest: `sha256:${string}`;
runtimeProtocolVersion: 1;
sourceRevision: string;
}>;
export const CONTRACT_SET_ALGORITHM = "CA_CONTRACT_SET_V1" as const;
const HEADER = "CA_FRONTEND_CONTRACT_SET_V1\u0000";
const encoder = new TextEncoder();
function compareUtf8(left: string, right: string): number {
const a = encoder.encode(left);
const b = encoder.encode(right);
const shared = Math.min(a.length, b.length);
for (let index = 0; index < shared; index += 1) {
const difference = (a[index] as number) - (b[index] as number);
if (difference !== 0) return difference;
}
return a.length - b.length;
}
export function canonicalizeContractSet(
packages: readonly ContractSetPackage[],
): Uint8Array {
const seen = new Set<string>();
for (const entry of packages) {
if (seen.has(entry.packageId)) {
throw new TypeError("Duplicate contract set package identity.");
}
seen.add(entry.packageId);
}
const sorted = [...packages].sort((left, right) =>
compareUtf8(left.packageId, right.packageId),
);
const chunks: Uint8Array[] = [encoder.encode(HEADER)];
for (const entry of sorted) {
appendString(chunks, entry.packageId);
appendString(chunks, entry.version);
appendString(chunks, entry.digest);
chunks.push(u32be(entry.runtimeProtocolVersion));
appendString(chunks, entry.sourceRevision);
}
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const output = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.length;
}
return output;
}
function appendString(chunks: Uint8Array[], value: string): void {
const bytes = encoder.encode(value);
chunks.push(u32be(bytes.length));
chunks.push(bytes);
}
function u32be(value: number): Uint8Array {
if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) {
throw new TypeError("Contract set length prefix is out of range.");
}
const bytes = new Uint8Array(4);
new DataView(bytes.buffer).setUint32(0, value, false);
return bytes;
}
export function toLowerHex(digest: ArrayBuffer | Uint8Array): string {
const bytes =
digest instanceof Uint8Array ? digest : new Uint8Array(digest);
let output = "";
for (const byte of bytes) output += byte.toString(16).padStart(2, "0");
return output;
}
/**
* Browser-side digest. Node callers pass their own `crypto.createHash` adapter
* through {@link computeContractSetDigestWith}.
*/
export async function computeContractSetDigest(
packages: readonly ContractSetPackage[],
): Promise<`sha256:${string}`> {
const bytes = canonicalizeContractSet(packages);
const buffer = await crypto.subtle.digest(
"SHA-256",
bytes.slice().buffer as ArrayBuffer,
);
return `sha256:${toLowerHex(buffer)}`;
}
export function computeContractSetDigestWith(
packages: readonly ContractSetPackage[],
sha256: (bytes: Uint8Array) => Uint8Array,
): `sha256:${string}` {
return `sha256:${toLowerHex(sha256(canonicalizeContractSet(packages)))}`;
}