112 lines
3.4 KiB
TypeScript
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)))}`;
|
|
}
|