Fix CBOR Encode for cbor v9: use streaming Encoder with pre-sorted Maps

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 <noreply@anthropic.com>
This commit is contained in:
J8k3 2026-05-20 19:19:01 -04:00
parent ad20c91f5b
commit c3c76d76cb

View File

@ -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<Buffer>}
*/
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);
}
}