mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
refactor(producer): address review feedback on planHash
- Add `PLAN_HASH_SCHEMA_PREFIX = "hyperframes-plan-hash-v1\x00"` mixed into every digest. Future framing changes must bump the trailing integer; this makes cross-version mismatches visible at the wire format instead of producing silent collisions. Impossible to backfill later, easy to bake in now. - Hoist the `0x00` field delimiter to module scope (`FIELD_DELIMITER`). - Document the UTF-8 encoding contract for all string-typed input fields in the file-level JSDoc, so external verifiers know the encoding without reading the implementation. - Add a known-digest test for one fixed reference input (`995b4105...`). If the framing changes silently this test fails, forcing the developer to also bump the schema prefix. - Add an explicit `canonicalJsonStringify(undefined) → TypeError` test to pin the contract. No callers yet, so no behavior change in any code path. Review feedback addressed: vanceingalls on #717. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,16 @@ describe("computePlanHash", () => {
|
||||
expect(a).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
// Pin the schema-prefix-mixed-in result for one fixed input. If the
|
||||
// framing of `computePlanHash` ever changes silently, this test must
|
||||
// be updated (which means bumping `PLAN_HASH_SCHEMA_PREFIX` in the
|
||||
// source too). Catches accidental drift across producer versions.
|
||||
it("matches the known digest for a fixed reference input", () => {
|
||||
expect(computePlanHash(makeInput())).toBe(
|
||||
"995b4105a1a629965e85dc5d92c6aab9b888e39acdb14369d1bc781aa3247a94",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores asset order in the input array", () => {
|
||||
const ordered = computePlanHash(
|
||||
makeInput({
|
||||
@@ -206,6 +216,10 @@ describe("canonicalJsonStringify", () => {
|
||||
expect(() => canonicalJsonStringify(Symbol("s"))).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("rejects undefined at the top level", () => {
|
||||
expect(() => canonicalJsonStringify(undefined)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("produces equal output for semantically equal objects with different key order", () => {
|
||||
const a = canonicalJsonStringify({
|
||||
encoder: "libx264-software",
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* See DISTRIBUTED-RENDERING-PLAN.md §4.2 for the contract:
|
||||
*
|
||||
* planHash = sha256(
|
||||
* composition_html_bytes
|
||||
* SCHEMA_PREFIX
|
||||
* ⊕ composition_html_bytes
|
||||
* ⊕ asset_shas (sorted by relative path)
|
||||
* ⊕ font_snapshot_sha
|
||||
* ⊕ encoder_config_canonical_json
|
||||
@@ -17,12 +18,35 @@
|
||||
* use this to short-circuit `plan()` on workflow replay and to detect
|
||||
* cross-version mismatches (§9.3 PLAN_HASH_MISMATCH).
|
||||
*
|
||||
* Phase 1 ships the pure hashing utility only; no caller exists yet. The
|
||||
* `services/distributed/plan.ts` PR in Phase 3 will compose it.
|
||||
* Pure utility; no caller exists yet — the distributed-render
|
||||
* `services/distributed/plan.ts` will compose it.
|
||||
*
|
||||
* ## Encoding contract
|
||||
*
|
||||
* Every string-typed component (`fontSnapshotSha`,
|
||||
* `encoderConfigCanonicalJson`, `producerVersion`, `ffmpegVersion`, asset
|
||||
* paths and shas, the dimensions tuple) is hashed as UTF-8. External
|
||||
* verifiers must encode the same way. Binary fields (`compositionHtml`)
|
||||
* are hashed verbatim.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Schema-version prefix mixed into every digest. Bump the trailing version
|
||||
* integer whenever the framing of `computePlanHash` changes (new fields,
|
||||
* new delimiter, new field order, etc.) so every cached plan from an older
|
||||
* producer is forced to mismatch and re-plan. This is impossible to
|
||||
* backfill, so a deliberate bump is the only correct action.
|
||||
*/
|
||||
const PLAN_HASH_SCHEMA_PREFIX = "hyperframes-plan-hash-v1\x00";
|
||||
|
||||
/**
|
||||
* 0x00 byte used to frame each `hash.update()` call. Hoisted to module
|
||||
* scope so it's not reallocated on every `computePlanHash` invocation.
|
||||
*/
|
||||
const FIELD_DELIMITER = Buffer.from([0x00]);
|
||||
|
||||
/**
|
||||
* SHA-256 hex digest of an asset, paired with its plan-relative path. Sort
|
||||
* order across an asset list is by `path` (byte-wise ascending) to keep the
|
||||
@@ -75,29 +99,30 @@ export interface PlanHashInput {
|
||||
*/
|
||||
export function computePlanHash(input: PlanHashInput): string {
|
||||
const hash = createHash("sha256");
|
||||
const delimiter = Buffer.from([0x00]);
|
||||
|
||||
hash.update(PLAN_HASH_SCHEMA_PREFIX, "utf8");
|
||||
|
||||
hash.update(input.compositionHtml);
|
||||
hash.update(delimiter);
|
||||
hash.update(FIELD_DELIMITER);
|
||||
|
||||
const sortedAssets = [...input.assets].sort((a, b) =>
|
||||
a.path < b.path ? -1 : a.path > b.path ? 1 : 0,
|
||||
);
|
||||
for (const asset of sortedAssets) {
|
||||
hash.update(asset.path, "utf8");
|
||||
hash.update(delimiter);
|
||||
hash.update(FIELD_DELIMITER);
|
||||
hash.update(asset.sha256, "utf8");
|
||||
hash.update(delimiter);
|
||||
hash.update(FIELD_DELIMITER);
|
||||
}
|
||||
|
||||
hash.update(input.fontSnapshotSha, "utf8");
|
||||
hash.update(delimiter);
|
||||
hash.update(FIELD_DELIMITER);
|
||||
hash.update(input.encoderConfigCanonicalJson, "utf8");
|
||||
hash.update(delimiter);
|
||||
hash.update(FIELD_DELIMITER);
|
||||
hash.update(input.producerVersion, "utf8");
|
||||
hash.update(delimiter);
|
||||
hash.update(FIELD_DELIMITER);
|
||||
hash.update(input.ffmpegVersion, "utf8");
|
||||
hash.update(delimiter);
|
||||
hash.update(FIELD_DELIMITER);
|
||||
|
||||
const d = input.dimensions;
|
||||
hash.update(`${d.fpsNum}/${d.fpsDen}x${d.width}x${d.height}x${d.format}`, "utf8");
|
||||
|
||||
Reference in New Issue
Block a user