From c3c76d76cbd2c9e71d535b490a046e4b243b0263 Mon Sep 17 00:00:00 2001 From: J8k3 Date: Wed, 20 May 2026 19:19:01 -0400 Subject: [PATCH] Fix CBOR Encode for cbor v9: use streaming Encoder with pre-sorted Maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cbor v9 changed Encoder.encode() and encodeCanonical() from sync to async stream-based, but the static sync wrappers still exist and silently return only the first chunk (~1 byte) of the output. Fix: drive a streaming Encoder directly — - Pre-sort object keys into Maps using RFC 7049 canonical order (length-first, then lexicographic byte comparison) before encoding, so insertion order gives the canonical result. - Register a custom Map semantic type that bypasses the encoder's own canonical sort (which has the same single-byte bug in v9). - Buffer all output chunks, then return the correctly sliced ArrayBuffer (avoiding Node.js Buffer pool aliasing via byteOffset/byteLength). The run() method becomes async; the framework already awaits run(). Co-Authored-By: Claude Sonnet 4.6 --- src/core/operations/CBOREncode.mjs | 72 +++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/src/core/operations/CBOREncode.mjs b/src/core/operations/CBOREncode.mjs index c6e094a9..d302774b 100644 --- a/src/core/operations/CBOREncode.mjs +++ b/src/core/operations/CBOREncode.mjs @@ -7,6 +7,73 @@ import Operation from "../Operation.mjs"; import Cbor from "cbor"; +// cbor v9: Encoder.encode/encodeCanonical return only the first byte. +// Pre-sort map keys ourselves and use a custom Map semantic type so the +// encoder writes keys in insertion order without re-sorting internally. + +/** + * Returns the byte-length of a CBOR-encoded text string key (header + payload). + * Used to implement RFC 7049 canonical map key ordering. + * + * @param {string} s + * @returns {number} + */ +function cborKeyEncodedLen(s) { + const n = Buffer.byteLength(s, "utf8"); + if (n < 24) return 1 + n; + if (n < 0x100) return 2 + n; + if (n < 0x10000) return 3 + n; + return 5 + n; +} + +/** + * Recursively converts plain objects to pre-sorted Maps so that the CBOR + * encoder emits keys in canonical (length-first, then lexicographic) order + * without relying on the cbor library's own canonical sort, which is broken + * in cbor v9 for streamed output. + * + * @param {*} val + * @returns {*} + */ +function prepareCBOR(val) { + if (Array.isArray(val)) return val.map(prepareCBOR); + if (val !== null && typeof val === "object" && !(val instanceof Map)) { + const sorted = Object.keys(val).sort((a, b) => { + const la = cborKeyEncodedLen(a), lb = cborKeyEncodedLen(b); + if (la !== lb) return la - lb; + return Buffer.from(a, "utf8").compare(Buffer.from(b, "utf8")); + }); + return new Map(sorted.map(k => [k, prepareCBOR(val[k])])); + } + return val; +} + +/** + * Encodes a value as canonical CBOR using a streaming Encoder. + * Returns a Promise that resolves to a Buffer containing the full encoding. + * + * @param {*} input + * @returns {Promise} + */ +function cborEncodeCanonical(input) { + return new Promise((resolve, reject) => { + const enc = new Cbor.Encoder({canonical: true}); + enc.addSemanticType(Map, (e, m) => { + if (!e._pushInt(m.size, 5)) return false; + for (const [k, v] of m) { + if (!e.pushAny(k) || !e.pushAny(v)) return false; + } + return true; + }); + const bufs = []; + enc.on("data", b => bufs.push(b)); + enc.on("error", reject); + enc.on("finish", () => resolve(Buffer.concat(bufs))); + enc.pushAny(prepareCBOR(input)); + enc.end(); + }); +} + /** * CBOR Encode operation */ @@ -32,8 +99,9 @@ class CBOREncode extends Operation { * @param {Object[]} args * @returns {ArrayBuffer} */ - run(input, args) { - return new Uint8Array(Cbor.encodeCanonical(input)).buffer; + async run(input, args) { + const buf = await cborEncodeCanonical(input); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); } }