From cc4226f16fa6964f58e707f4c6b4c6714c2df3ef Mon Sep 17 00:00:00 2001 From: Enoch Groot Date: Sat, 19 Sep 2026 07:55:50 +0000 Subject: [PATCH 1/2] feat: optional lazy unpack wrappers (#40) - unpack(buf, { lazy: true }) wraps maps/arrays as accessors - keep the msgpack zone and source Buffer alive with the wrapper - toJSON fully materializes; util.inspect does not hang --- CHANGELOG.md | 20 +++- COVERAGE.md | 15 ++- README.md | 17 ++- index.d.ts | 11 +- lib/msgpack.js | 4 +- package-lock.json | 4 +- package.json | 4 +- src/msgpack.cc | 267 +++++++++++++++++++++++++++++++++++++++++++++- test/lazy.test.js | 190 +++++++++++++++++++++++++++++++++ 9 files changed, 513 insertions(+), 19 deletions(-) create mode 100644 test/lazy.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b8258e0..ad756ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.2.0] - 2026-09-19 + +Optional second-argument unpack option `{ lazy: true }` wraps maps and arrays +as accessors so nested values are not converted until they are read. See `#40`. + +### Added + +- `unpack(buf, { lazy: true })` keeps the decoder zone alive and returns maps + as objects with accessor own-properties and arrays as array-likes with + indexed accessors (`length`, `in`, `Object.keys`). Nested maps and arrays + stay lazy until a property is read. +- `toJSON` and `util.inspect.custom` materialize through the eager converter, + so `JSON.stringify` and `util.inspect` match eager unpack. `pack()` of a + lazy value also round-trips because it calls `toJSON`. +- Primitives, incomplete buffers, trailing `bytes_remaining`, and the DoS + limits are unchanged. `__proto__` / `constructor` stay own properties. + ## [3.1.0] - 2026-09-19 Optional second-argument pack hints force a MessagePack wire type or family @@ -108,7 +125,8 @@ GitHub Actions tests Node 18/20/22 on Ubuntu, macOS, and Windows 2022. - Pack throw paths free or return pooled sbuffers on every exit. - msgpack-c c-7.0.2 includes unpacker buffer-expansion overflow checks. -[Unreleased]: https://github.com/msgpack/msgpack-node/compare/v3.1.0...HEAD +[Unreleased]: https://github.com/msgpack/msgpack-node/compare/v3.2.0...HEAD +[3.2.0]: https://github.com/msgpack/msgpack-node/compare/v3.1.0...v3.2.0 [3.1.0]: https://github.com/msgpack/msgpack-node/compare/v3.0.0...v3.1.0 [3.0.0]: https://github.com/msgpack/msgpack-node/compare/e04c9b55f98d64512174d6e859b8294b729659a2...HEAD [2.0.0]: https://github.com/msgpack/msgpack-node/commit/e04c9b55f98d64512174d6e859b8294b729659a2 diff --git a/COVERAGE.md b/COVERAGE.md index c95e0f8..15ec3eb 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -1,4 +1,4 @@ -# Coverage — msgpack 3.1.0 +# Coverage — msgpack 3.2.0 `npm run coverage` runs both halves and fails the build under 95%. @@ -8,9 +8,9 @@ | `lib/` + `bin/` (c8) | branches | **100%** | ≥ 95% | | `lib/` + `bin/` (c8) | functions | **100%** | ≥ 95% | | `lib/` + `bin/` (c8) | lines | **100%** | ≥ 95% | -| `src/` (gcovr) | lines | **95.2%** (902/947) | ≥ 95% | -| `src/` (gcovr) | branches | **95.4%** (836/876) | ≥ 95% | -| `src/` (gcovr) | functions | 100% (59/59) | — | +| `src/` (gcovr) | lines | **95.7%** (1002/1047) | ≥ 95% | +| `src/` (gcovr) | branches | **95.5%** (976/1022) | ≥ 95% | +| `src/` (gcovr) | functions | 100% (66/66) | — | `deps/` is excluded from the native report; the vendored msgpack-c is not our code. `build/` is rebuilt without instrumentation at the end of @@ -146,6 +146,13 @@ gcovr --root . --filter src/ --exclude deps/ --no-markers --txt-metric branch -- `kMaxPackDepth`) are marked `GCOVR_EXCL_*`, not deleted. Native overall stays above the 95% gate (`pack_hints.inc` itself is 91% branches because switch `default:` edges sit on the same line as covered cases). +- `test/lazy.test.js` — `#40` `unpack(buf, { lazy: true })`: one-arg eager + identity, nested `o.c[1]` without reading siblings, `__proto__` / + `constructor` as own properties, oversized headers still throw, incomplete + buffers still return `null`, `toJSON` / `JSON.stringify` / `util.inspect` + match eager unpack, nested BigInt, non-object second args, and toJSON + `this` checks. Lazy OOM / empty-Maybe / ObjectTemplate-failure arms are + marked `GCOVR_EXCL_*`, not deleted. - `test/cli.test.js` (12 tests) — the exit-1 paths of both CLIs: invalid JSON, empty stdin, a pack rejection reachable from real JSON, an unparseable byte, an oversized header, incomplete input both alone and after a good frame, and diff --git a/README.md b/README.md index 67a737e..3865a86 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ and de-serializes JavaScript values with [MessagePack](https://msgpack.org). Packed output is a `Buffer` and is typically much smaller than JSON. -Version 3.1 requires **Node.js 18+**, vendors **msgpack-c c-7.0.2**, unpacks -64-bit integers outside `Number.MAX_SAFE_INTEGER` as `bigint`, and accepts -optional pack type/family hints. See [`SECURITY.md`](SECURITY.md). +Version 3.2 requires **Node.js 18+**, vendors **msgpack-c c-7.0.2**, unpacks +64-bit integers outside `Number.MAX_SAFE_INTEGER` as `bigint`, accepts +optional pack type/family hints, and can unpack maps and arrays lazily +(`unpack(buf, { lazy: true })`). See [`SECURITY.md`](SECURITY.md). ### Usage @@ -79,6 +80,16 @@ is that same `bigint`. `unpack.bytes_remaining` is the number of unused trailing bytes after the last successful (or attempted) unpack. Stream uses that to splice leftover data. +`unpack(buf, { lazy: true })` wraps maps as objects with accessor +own-properties and arrays as array-likes with indexed accessors. Nested +values are not converted until they are read, which is useful for large +payloads when only a few keys are needed. `JSON.stringify` and +`util.inspect` materialize via `toJSON` / `inspect.custom`. Lazy arrays are +not real `Array`s (`Array.isArray` is false); `pack()` still round-trips +them because it calls `toJSON`. Primitives unpack eagerly even when `lazy` +is set. `__proto__` and `constructor` keys stay own properties, same as +eager unpack. + ### Pack type hints (3.1) `pack(value, options)` takes an optional last-argument options object when diff --git a/index.d.ts b/index.d.ts index d253e74..d0faf3f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,4 +1,4 @@ -// Type definitions for msgpack 3.1.0 +// Type definitions for msgpack 3.2.0 // Project: https://github.com/msgpack/msgpack-node /// @@ -74,8 +74,15 @@ export function pack(...values: any[]): Buffer; * Returns `null` when the buffer holds an incomplete value, in which case * `unpack.bytes_remaining` equals `buf.length`. Throws on malformed input or * when a container/string/bin header exceeds the decoder's limits. + * + * Pass `{ lazy: true }` to wrap maps as objects with accessor own-properties + * and arrays as array-likes with indexed accessors. Nested values are not + * converted until read. `JSON.stringify` and `util.inspect` materialize via + * `toJSON` / `inspect.custom`. Lazy arrays are not real `Array`s + * (`Array.isArray` is false); `pack()` still round-trips them because it + * calls `toJSON`. Primitives unpack eagerly even when `lazy` is set. */ -export function unpack(buf: Buffer): any; +export function unpack(buf: Buffer, opts?: { lazy?: boolean }): any; export namespace unpack { /** diff --git a/lib/msgpack.js b/lib/msgpack.js index a8ec5ef..81fae7f 100644 --- a/lib/msgpack.js +++ b/lib/msgpack.js @@ -18,8 +18,8 @@ function pack() { return bpack.apply(null, arguments); } -function unpack(buf) { - const result = rawUnpack(buf); +function unpack(buf, opts) { + const result = arguments.length < 2 ? rawUnpack(buf) : rawUnpack(buf, opts); unpack.bytes_remaining = mpBindings.bytesRemaining(); return result; } diff --git a/package-lock.json b/package-lock.json index 5ce342f..bc2946c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "msgpack", - "version": "3.1.0", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "msgpack", - "version": "3.1.0", + "version": "3.2.0", "license": "BSD-3-Clause", "dependencies": { "nan": "^2.23.1" diff --git a/package.json b/package.json index 37d0d62..88a2a1e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "msgpack", "description": "A space-efficient object serialization library for Node.js", - "version": "3.1.0", + "version": "3.2.0", "homepage": "https://github.com/msgpack/msgpack-node", "author": "Peter Griess ", "contributors": [ @@ -34,7 +34,7 @@ "nan": "^2.23.1" }, "scripts": { - "test": "node --test test/bigint.test.js test/cli.test.js test/coverage-native.test.js test/msgpack.test.js test/pack-hints.test.js test/regression.test.js test/security.test.js test/worker.test.js", + "test": "node --test test/bigint.test.js test/cli.test.js test/coverage-native.test.js test/lazy.test.js test/msgpack.test.js test/pack-hints.test.js test/regression.test.js test/security.test.js test/worker.test.js", "bench": "node test/benchmark/benchmark.js", "rebuild": "node-gyp rebuild", "coverage": "npm run coverage:js && npm run coverage:native", diff --git a/src/msgpack.cc b/src/msgpack.cc index 0836489..4822549 100644 --- a/src/msgpack.cc +++ b/src/msgpack.cc @@ -677,6 +677,257 @@ static v8::Local MsgpackToJs(const msgpack_object* mo) { } } +/* + * Lazy unpack: keep the msgpack zone (and the source Buffer) alive, and wrap + * maps/arrays as JS objects whose values are accessors. Nested containers are + * not converted until a property is read. toJSON / inspect.custom materialize + * through MsgpackToJs so JSON.stringify and util.inspect match eager unpack. + */ +class LazySession : public Nan::ObjectWrap { + public: + msgpack_unpacked unpacked; + Nan::Persistent buffer; + + static NAN_METHOD(New) { + LazySession* session = new LazySession(); + msgpack_unpacked_init(&session->unpacked); + session->Wrap(info.This()); + info.GetReturnValue().Set(info.This()); + } + + static v8::Local Create(v8::Local buf, + msgpack_unpacked* src) { + v8::Local cons = Nan::New(ctor); + v8::Local inst = Nan::NewInstance(cons).ToLocalChecked(); + LazySession* session = Nan::ObjectWrap::Unwrap(inst); + msgpack_unpacked_destroy(&session->unpacked); + session->unpacked = *src; + src->zone = NULL; + session->buffer.Reset(buf); + return inst; + } + + ~LazySession() { + msgpack_unpacked_destroy(&unpacked); + buffer.Reset(); + } + + static thread_local Nan::Persistent ctor; + + private: + LazySession() {} +}; + +thread_local Nan::Persistent LazySession::ctor; + +static thread_local Nan::Persistent lazy_tojson_fn; +static thread_local Nan::Persistent lazy_array_tmpl; +static thread_local Nan::Persistent lazy_map_tmpl; +static thread_local Nan::Persistent lazy_session_key; +static thread_local Nan::Persistent lazy_mo_key; + +static void AttachLazy(v8::Local obj, + v8::Local session, + const msgpack_object* mo) { + Nan::SetPrivate(obj, Nan::New(lazy_session_key), session); + Nan::SetPrivate(obj, Nan::New(lazy_mo_key), + Nan::New(const_cast(mo))); +} + +static v8::Local LazySessionOf(v8::Local obj) { + Nan::MaybeLocal v = Nan::GetPrivate(obj, Nan::New(lazy_session_key)); + /* GCOVR_EXCL_BR_START: only missing if a getter is applied to a foreign object. */ + if (v.IsEmpty() || !v.ToLocalChecked()->IsObject()) { + return Nan::New(); + } + /* GCOVR_EXCL_BR_STOP */ + return v.ToLocalChecked().As(); +} + +static const msgpack_object* LazyMoOf(v8::Local obj) { + Nan::MaybeLocal v = Nan::GetPrivate(obj, Nan::New(lazy_mo_key)); + /* GCOVR_EXCL_BR_START: same as LazySessionOf. */ + if (v.IsEmpty() || !v.ToLocalChecked()->IsExternal()) { + return NULL; + } + /* GCOVR_EXCL_BR_STOP */ + return static_cast( + v.ToLocalChecked().As()->Value()); +} + +static v8::Local MsgpackToJsLazy(const msgpack_object* mo, + v8::Local session); + +static void InstallLazyMethods(v8::Local obj) { + v8::Local fn = Nan::New(lazy_tojson_fn); + v8::PropertyAttribute hidden = + static_cast(v8::ReadOnly | v8::DontEnum); + Nan::DefineOwnProperty(obj, Nan::New("toJSON").ToLocalChecked(), fn, hidden); + v8::Local inspect = v8::Symbol::For( + v8::Isolate::GetCurrent(), + Nan::New("nodejs.util.inspect.custom").ToLocalChecked()); + obj->DefineOwnProperty(Nan::GetCurrentContext(), inspect, fn, hidden) + .FromMaybe(false); +} + +NAN_METHOD(LazyToJSON) { + /* NAN_METHOD is sloppy: null/undefined This is boxed to the global. */ + if (!info.This()->IsObject()) { /* GCOVR_EXCL_BR_LINE */ + return Nan::ThrowTypeError("invalid lazy object"); /* GCOVR_EXCL_LINE */ + } + v8::Local self = info.This(); + const msgpack_object* mo = LazyMoOf(self); + if (mo == NULL) { + return Nan::ThrowTypeError("invalid lazy object"); + } + try { + info.GetReturnValue().Set(MsgpackToJs(mo)); + } catch (const MsgpackException& e) { /* GCOVR_EXCL_BR_LINE: MsgpackToJs throws nothing else */ + Nan::ThrowError(e.value()); + } +} + +static v8::Local WrapLazyArray(const msgpack_object* mo, + v8::Local session) { + v8::Local obj = + Nan::New(lazy_array_tmpl)->NewInstance(Nan::GetCurrentContext()).ToLocalChecked(); + AttachLazy(obj, session, mo); + Nan::DefineOwnProperty( + obj, + Nan::New("length").ToLocalChecked(), + Nan::New(static_cast(mo->via.array.size)), + static_cast(v8::ReadOnly | v8::DontEnum)); + InstallLazyMethods(obj); + return obj; +} + +static void LazyMapNameGetter(v8::Local /*property*/, + const v8::PropertyCallbackInfo& info) { + const msgpack_object* val = + static_cast(info.Data().As()->Value()); + v8::Local session = LazySessionOf(info.Holder()); + try { + info.GetReturnValue().Set(MsgpackToJsLazy(val, session)); + } catch (const MsgpackException& e) { /* GCOVR_EXCL_BR_LINE: MsgpackToJsLazy throws nothing else */ + Nan::ThrowError(e.value()); + } +} + +static v8::Local WrapLazyMap(const msgpack_object* mo, + v8::Local session) { + v8::Local ctx = Nan::GetCurrentContext(); + v8::Local obj = + Nan::New(lazy_map_tmpl)->NewInstance(ctx).ToLocalChecked(); + /* ObjectTemplate instances get a hidden prototype. Eager maps are + ordinary objects whose [[Prototype]] is Object.prototype. */ + Nan::SetPrototype(obj, Nan::New()->GetPrototype()); + AttachLazy(obj, session, mo); + for (uint32_t i = 0; i < mo->via.map.size; i++) { + const msgpack_object_kv* kv = &mo->via.map.ptr[i]; + v8::Local key = MsgpackToJs(&kv->key); + Nan::MaybeLocal name = Nan::To(key); + /* GCOVR_EXCL_BR_START: same as eager map keys. */ + if (name.IsEmpty()) { + throw MsgpackException(Error("cannot unpack map key")); + } + /* GCOVR_EXCL_BR_STOP */ + if (!obj->SetNativeDataProperty( + ctx, + name.ToLocalChecked(), + LazyMapNameGetter, + 0, + Nan::New(const_cast(&kv->val))) + .FromMaybe(false)) { /* GCOVR_EXCL_BR_LINE: OOM / rejected name */ + throw MsgpackException(Error("cannot unpack map key")); /* GCOVR_EXCL_LINE */ + } + } + InstallLazyMethods(obj); + return obj; +} + +static v8::Local MsgpackToJsLazy(const msgpack_object* mo, + v8::Local session) { + switch (mo->type) { + case MSGPACK_OBJECT_ARRAY: + return WrapLazyArray(mo, session); + case MSGPACK_OBJECT_MAP: + return WrapLazyMap(mo, session); + default: + return MsgpackToJs(mo); + } +} + +static void LazyIndexGet(uint32_t index, + const v8::PropertyCallbackInfo& info) { + const msgpack_object* mo = LazyMoOf(info.Holder()); + if (mo == NULL || mo->type != MSGPACK_OBJECT_ARRAY || index >= mo->via.array.size) { + return; + } + v8::Local session = LazySessionOf(info.Holder()); + try { + info.GetReturnValue().Set(MsgpackToJsLazy(&mo->via.array.ptr[index], session)); + } catch (const MsgpackException& e) { /* GCOVR_EXCL_BR_LINE: MsgpackToJsLazy throws nothing else */ + Nan::ThrowError(e.value()); + } +} + +static void LazyIndexQuery(uint32_t index, + const v8::PropertyCallbackInfo& info) { + const msgpack_object* mo = LazyMoOf(info.Holder()); + if (mo == NULL || mo->type != MSGPACK_OBJECT_ARRAY || index >= mo->via.array.size) { + return; + } + info.GetReturnValue().Set(v8::None); +} + +static void LazyIndexEnum(const v8::PropertyCallbackInfo& info) { + const msgpack_object* mo = LazyMoOf(info.Holder()); + uint32_t n = 0; + if (mo != NULL && mo->type == MSGPACK_OBJECT_ARRAY) { + n = static_cast(mo->via.array.size); + } + v8::Local names = Nan::New(n); + for (uint32_t i = 0; i < n; i++) { + Nan::Set(names, i, Nan::New(i)); + } + info.GetReturnValue().Set(names); +} + +static void InitLazy() { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + + v8::Local stpl = Nan::New(LazySession::New); + stpl->SetClassName(Nan::New("MsgpackLazySession").ToLocalChecked()); + stpl->InstanceTemplate()->SetInternalFieldCount(1); + LazySession::ctor.Reset(Nan::GetFunction(stpl).ToLocalChecked()); + + lazy_tojson_fn.Reset( + Nan::GetFunction(Nan::New(LazyToJSON)).ToLocalChecked()); + + v8::Local arr = v8::ObjectTemplate::New(isolate); + arr->SetIndexedPropertyHandler(LazyIndexGet, 0, LazyIndexQuery, 0, LazyIndexEnum); + lazy_array_tmpl.Reset(arr); + + v8::Local map = v8::ObjectTemplate::New(isolate); + map->SetIndexedPropertyHandler(LazyIndexGet, 0, LazyIndexQuery, 0, LazyIndexEnum); + lazy_map_tmpl.Reset(map); + + lazy_session_key.Reset(Nan::New("msgpack:lazySession").ToLocalChecked()); + lazy_mo_key.Reset(Nan::New("msgpack:lazyMo").ToLocalChecked()); +} + +static bool UnpackLazyRequested(const Nan::FunctionCallbackInfo& info) { + if (info.Length() < 2 || !info[1]->IsObject() || info[1]->IsArray()) { + return false; + } + Nan::MaybeLocal maybe = + Nan::Get(info[1].As(), Nan::New("lazy").ToLocalChecked()); + if (maybe.IsEmpty()) { /* GCOVR_EXCL_LINE */ + return false; /* GCOVR_EXCL_LINE */ + } + return maybe.ToLocalChecked()->IsTrue(); +} + struct SbufPool { msgpack_sbuffer* list[kSbufferPoolMax]; size_t length; @@ -840,11 +1091,20 @@ NAN_METHOD(Unpack) { * msgpack_unpack_next never returns EXTRA_BYTES. */ if (ret == MSGPACK_UNPACK_SUCCESS || ret == MSGPACK_UNPACK_EXTRA_BYTES) { /* GCOVR_EXCL_BR_LINE */ try { - v8::Local v = MsgpackToJs(&result.data); - msgpack_unpacked_destroy(&result); + v8::Local v; + if (UnpackLazyRequested(info) && + (result.data.type == MSGPACK_OBJECT_ARRAY || + result.data.type == MSGPACK_OBJECT_MAP)) { + v8::Local session = LazySession::Create(buf, &result); + LazySession* hold = Nan::ObjectWrap::Unwrap(session); + v = MsgpackToJsLazy(&hold->unpacked.data, session); + } else { + v = MsgpackToJs(&result.data); + msgpack_unpacked_destroy(&result); + } info.GetReturnValue().Set(v); return; - } catch (const MsgpackException& e) { /* GCOVR_EXCL_BR_LINE: MsgpackToJs throws nothing else */ + } catch (const MsgpackException& e) { /* GCOVR_EXCL_BR_LINE: convert throws nothing else */ msgpack_unpacked_destroy(&result); return Nan::ThrowError(e.value()); } @@ -863,6 +1123,7 @@ NAN_METHOD(Unpack) { NAN_MODULE_INIT(Init) { stack_key.Reset(Nan::New("_msgpack_stack").ToLocalChecked()); + InitLazy(); Nan::Set(target, Nan::New("pack").ToLocalChecked(), Nan::GetFunction(Nan::New(Pack)).ToLocalChecked()); Nan::Set(target, Nan::New("unpack").ToLocalChecked(), diff --git a/test/lazy.test.js b/test/lazy.test.js new file mode 100644 index 0000000..b528ce7 --- /dev/null +++ b/test/lazy.test.js @@ -0,0 +1,190 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const util = require('node:util'); +const msgpack = require('../lib/msgpack'); + +describe('unpack({ lazy: true })', () => { + it('matches the issue #40 example', () => { + const o = msgpack.unpack(msgpack.pack({ a: 1, b: 2, c: [1, 2, 3] }), { lazy: true }); + assert.equal(o.a, 1); + assert.equal(o.c[1], 2); + assert.deepEqual(JSON.parse(JSON.stringify(o)), { a: 1, b: 2, c: [1, 2, 3] }); + }); + + it('does not convert nested values until they are read', () => { + const o = msgpack.unpack(msgpack.pack({ a: { b: 1 }, c: [1, 2, 3] }), { lazy: true }); + assert.equal(Array.isArray(o.c), false); + assert.equal(o.c.length, 3); + assert.equal(o.a.b, 1); + assert.equal(typeof o.a.toJSON, 'function'); + }); + + it('materializes via toJSON for JSON.stringify and pack()', () => { + const src = { a: 1, b: 2, c: [1, 2, 3] }; + const o = msgpack.unpack(msgpack.pack(src), { lazy: true }); + assert.deepEqual(msgpack.unpack(msgpack.pack(o)), src); + assert.match(util.inspect(o), /a: 1/); + }); + + it('unpacks top-level arrays as array-likes', () => { + const o = msgpack.unpack(msgpack.pack([1, 2, 3]), { lazy: true }); + assert.equal(Array.isArray(o), false); + assert.equal(o.length, 3); + assert.equal(o[0], 1); + assert.equal(o[2], 3); + assert.equal(o[99], undefined); + assert.equal(1 in o, true); + assert.equal(99 in o, false); + assert.deepEqual(Object.keys(o), ['0', '1', '2']); + assert.deepEqual(JSON.parse(JSON.stringify(o)), [1, 2, 3]); + }); + + it('unpacks empty maps and arrays', () => { + assert.deepEqual( + JSON.parse(JSON.stringify(msgpack.unpack(msgpack.pack({}), { lazy: true }))), + {} + ); + assert.deepEqual( + JSON.parse(JSON.stringify(msgpack.unpack(msgpack.pack([]), { lazy: true }))), + [] + ); + }); + + it('still unpacks primitives eagerly', () => { + assert.equal(msgpack.unpack(msgpack.pack(1), { lazy: true }), 1); + assert.equal(msgpack.unpack(msgpack.pack(null), { lazy: true }), null); + assert.equal(msgpack.unpack(msgpack.pack(true), { lazy: true }), true); + assert.equal(msgpack.unpack(msgpack.pack('hi'), { lazy: true }), 'hi'); + }); + + it('keeps bigint, Buffer, and integer keys', () => { + const big = 18446464814936021036n; + const o = msgpack.unpack( + msgpack.pack({ n: big, b: Buffer.from('hi'), 1: 'a' }), + { lazy: true } + ); + assert.equal(o.n, big); + assert.ok(Buffer.isBuffer(o.b)); + assert.equal(o.b.toString(), 'hi'); + assert.equal(o[1], 'a'); + }); + + it('does not treat a second-arg array as options', () => { + const packed = msgpack.pack({ a: 1 }); + const o = msgpack.unpack(packed, [{ lazy: true }]); + assert.deepEqual(o, { a: 1 }); + assert.equal(typeof o.toJSON, 'undefined'); + }); + + it('does not treat a non-object second argument as options', () => { + const packed = msgpack.pack({ a: 1 }); + const o = msgpack.unpack(packed, 0); + assert.deepEqual(o, { a: 1 }); + assert.equal(typeof o.toJSON, 'undefined'); + }); + + it('ignores lazy when it is not boolean true', () => { + const o = msgpack.unpack(msgpack.pack({ a: 1 }), { lazy: 1 }); + assert.deepEqual(o, { a: 1 }); + assert.equal(typeof o.toJSON, 'undefined'); + }); + + it('returns null for incomplete input even with lazy', () => { + assert.equal(msgpack.unpack(Buffer.from([0x81]), { lazy: true }), null); + assert.equal(msgpack.unpack.bytes_remaining, 1); + }); + + it('still reports trailing bytes after a lazy unpack', () => { + const first = msgpack.pack({ a: 1 }); + const second = msgpack.pack(2); + const buf = Buffer.concat([first, second]); + const o = msgpack.unpack(buf, { lazy: true }); + assert.equal(o.a, 1); + assert.equal(msgpack.unpack.bytes_remaining, second.length); + }); + + it('throws on nested ext only when the value is read', () => { + /* map1, str "x", fixext1 type 0 data 0 */ + const wire = Buffer.from([0x81, 0xa1, 0x78, 0xd4, 0x00, 0x00]); + const o = msgpack.unpack(wire, { lazy: true }); + assert.throws(() => o.x, /cannot unpack ext type/); + assert.throws(() => JSON.stringify(o), /cannot unpack ext type/); + }); + + it('throws on nested ext in a lazy array when the index is read', () => { + /* array1 of fixext1 */ + const wire = Buffer.from([0x91, 0xd4, 0x00, 0x00]); + const o = msgpack.unpack(wire, { lazy: true }); + assert.throws(() => o[0], /cannot unpack ext type/); + assert.throws(() => JSON.stringify(o), /cannot unpack ext type/); + }); + + it('does not pollute Object.prototype via a lazy __proto__ key', () => { + const wire = Buffer.from('81a95f5f70726f746f5f5f81a7697341646d696ec3', 'hex'); + const decoded = msgpack.unpack(wire, { lazy: true }); + assert.equal(Object.getPrototypeOf(decoded), Object.prototype); + assert.equal({}.isAdmin, undefined); + assert.deepEqual(Object.keys(decoded), ['__proto__']); + const proto = decoded.__proto__; + assert.equal(proto.isAdmin, true); + assert.equal({}.isAdmin, undefined); + }); + + it('keeps constructor as an own accessor, not Function.prototype', () => { + const wire = Buffer.concat([ + Buffer.from([0x81]), + msgpack.pack('constructor'), + msgpack.pack(1) + ]); + const decoded = msgpack.unpack(wire, { lazy: true }); + assert.equal(decoded.constructor, 1); + assert.equal(Object.getPrototypeOf(decoded), Object.prototype); + }); + + it('still unpacks eagerly with one argument', () => { + const src = { a: 1, b: 2, c: [1, 2, 3] }; + const o = msgpack.unpack(msgpack.pack(src)); + assert.deepEqual(o, src); + assert.equal(typeof o.toJSON, 'undefined'); + }); + + it('materializes one nested index without reading sibling keys', () => { + const o = msgpack.unpack(msgpack.pack({ a: 1, b: 2, c: [1, 2, 3] }), { lazy: true }); + assert.equal(o.c[1], 2); + }); + + it('JSON.stringify of lazy unpack matches eager unpack', () => { + const src = { a: 1, b: 2, c: [1, 2, 3] }; + const packed = msgpack.pack(src); + assert.equal( + JSON.stringify(msgpack.unpack(packed, { lazy: true })), + JSON.stringify(msgpack.unpack(packed)) + ); + }); + + it('rejects toJSON when this is not a lazy object', () => { + const o = msgpack.unpack(msgpack.pack({ a: 1 }), { lazy: true }); + assert.throws(() => o.toJSON.call({}), /invalid lazy object/); + assert.throws(() => o.toJSON.call(null), /invalid lazy object/); + }); + + it('still throws on an oversized array header in lazy mode', () => { + const buf = Buffer.from([0xdd, 0xff, 0x00, 0x00, 0x00]); + assert.throws(() => msgpack.unpack(buf, { lazy: true }), /limit exceeded/); + }); + + it('still throws on an oversized map header in lazy mode', () => { + const buf = Buffer.from([0xdf, 0xff, 0x00, 0x00, 0x00]); + assert.throws(() => msgpack.unpack(buf, { lazy: true }), /limit exceeded/); + }); + + it('indexed lookup on a lazy map falls through to named keys', () => { + const o = msgpack.unpack(msgpack.pack({ 1: 'a', b: 2 }), { lazy: true }); + assert.equal(o[1], 'a'); + assert.equal(o[99], undefined); + assert.equal(99 in o, false); + assert.equal(1 in o, true); + }); +}); From 917be5ab616394fa89b07fb2e19b10805f865ef6 Mon Sep 17 00:00:00 2001 From: Enoch Groot Date: Sat, 19 Sep 2026 08:33:06 +0000 Subject: [PATCH 2/2] fix: copy lazy unpack input so transferred buffers cannot dangle - Copy the caller Buffer before msgpack_unpack_next when lazy is set - str/bin aliases session-owned bytes, not the transferable backing store - Add a transfer test for str and bin after structuredClone detach --- CHANGELOG.md | 3 +++ COVERAGE.md | 7 ++++--- README.md | 13 +++++++------ src/msgpack.cc | 31 +++++++++++++++++++++++++++---- test/lazy.test.js | 19 +++++++++++++++++++ 5 files changed, 60 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad756ca..5ff5e8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ as accessors so nested values are not converted until they are read. See `#40`. lazy value also round-trips because it calls `toJSON`. - Primitives, incomplete buffers, trailing `bytes_remaining`, and the DoS limits are unchanged. `__proto__` / `constructor` stay own properties. +- Lazy unpack copies the input before decode so str/bin do not alias the + caller's Buffer. Transferring that Buffer after unpack cannot dangle + later property reads. ## [3.1.0] - 2026-09-19 diff --git a/COVERAGE.md b/COVERAGE.md index 15ec3eb..4ee8128 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -150,9 +150,10 @@ gcovr --root . --filter src/ --exclude deps/ --no-markers --txt-metric branch -- identity, nested `o.c[1]` without reading siblings, `__proto__` / `constructor` as own properties, oversized headers still throw, incomplete buffers still return `null`, `toJSON` / `JSON.stringify` / `util.inspect` - match eager unpack, nested BigInt, non-object second args, and toJSON - `this` checks. Lazy OOM / empty-Maybe / ObjectTemplate-failure arms are - marked `GCOVR_EXCL_*`, not deleted. + match eager unpack, nested BigInt, non-object second args, toJSON + `this` checks, and str/bin reads after the caller Buffer is transferred. + Lazy OOM / empty-Maybe / ObjectTemplate-failure / CopyBuffer-failure arms + are marked `GCOVR_EXCL_*`, not deleted. - `test/cli.test.js` (12 tests) — the exit-1 paths of both CLIs: invalid JSON, empty stdin, a pack rejection reachable from real JSON, an unparseable byte, an oversized header, incomplete input both alone and after a good frame, and diff --git a/README.md b/README.md index 3865a86..ec017e8 100644 --- a/README.md +++ b/README.md @@ -83,12 +83,13 @@ successful (or attempted) unpack. Stream uses that to splice leftover data. `unpack(buf, { lazy: true })` wraps maps as objects with accessor own-properties and arrays as array-likes with indexed accessors. Nested values are not converted until they are read, which is useful for large -payloads when only a few keys are needed. `JSON.stringify` and -`util.inspect` materialize via `toJSON` / `inspect.custom`. Lazy arrays are -not real `Array`s (`Array.isArray` is false); `pack()` still round-trips -them because it calls `toJSON`. Primitives unpack eagerly even when `lazy` -is set. `__proto__` and `constructor` keys stay own properties, same as -eager unpack. +payloads when only a few keys are needed. The decoder copies `buf` so later +reads do not depend on the caller's backing store (transfer / detach is +safe). `JSON.stringify` and `util.inspect` materialize via `toJSON` / +`inspect.custom`. Lazy arrays are not real `Array`s (`Array.isArray` is +false); `pack()` still round-trips them because it calls `toJSON`. +Primitives unpack eagerly even when `lazy` is set. `__proto__` and +`constructor` keys stay own properties, same as eager unpack. ### Pack type hints (3.1) diff --git a/src/msgpack.cc b/src/msgpack.cc index 4822549..79f92cb 100644 --- a/src/msgpack.cc +++ b/src/msgpack.cc @@ -678,10 +678,13 @@ static v8::Local MsgpackToJs(const msgpack_object* mo) { } /* - * Lazy unpack: keep the msgpack zone (and the source Buffer) alive, and wrap - * maps/arrays as JS objects whose values are accessors. Nested containers are - * not converted until a property is read. toJSON / inspect.custom materialize - * through MsgpackToJs so JSON.stringify and util.inspect match eager unpack. + * Lazy unpack: keep the msgpack zone (and a session-owned copy of the source + * bytes) alive, and wrap maps/arrays as JS objects whose values are accessors. + * Nested containers are not converted until a property is read. toJSON / + * inspect.custom materialize through MsgpackToJs so JSON.stringify and + * util.inspect match eager unpack. The copy is required because msgpack-c + * aliases str/bin into the input; a Persistent on the caller's Buffer does + * not survive ArrayBuffer transfer. */ class LazySession : public Nan::ObjectWrap { public: @@ -1075,6 +1078,26 @@ NAN_METHOD(Unpack) { return Nan::ThrowError("Encountered error unpacking buffer"); } + /* Copy before unpack_next so via.str/via.bin alias session-owned bytes. + * Nan::Persistent on the caller's Buffer does not keep the backing store + * through structuredClone / postMessage transfer (CWE-416). */ + if (UnpackLazyRequested(info)) { + /* GCOVR_EXCL_BR_START: node Buffers are smaller than UINT32_MAX. */ + if (len > static_cast(UINT32_MAX)) { + return Nan::ThrowError("Error copying buffer"); + } + /* GCOVR_EXCL_BR_STOP */ + Nan::MaybeLocal copied = + Nan::CopyBuffer(data, static_cast(len)); + /* GCOVR_EXCL_BR_START: CopyBuffer fails only when V8 is out of memory. */ + if (copied.IsEmpty()) { + return Nan::ThrowError("Error copying buffer"); + } + /* GCOVR_EXCL_BR_STOP */ + buf = copied.ToLocalChecked(); + data = node::Buffer::Data(buf); + } + msgpack_unpacked result; msgpack_unpacked_init(&result); size_t off = 0; diff --git a/test/lazy.test.js b/test/lazy.test.js index b528ce7..6af418d 100644 --- a/test/lazy.test.js +++ b/test/lazy.test.js @@ -187,4 +187,23 @@ describe('unpack({ lazy: true })', () => { assert.equal(99 in o, false); assert.equal(1 in o, true); }); + + it('still reads str and bin after the caller Buffer is transferred', () => { + const packed = msgpack.pack({ + s: 'hello-lazy-uaf-marker-ABCDEFGH', + t: 'second-string-XXXXYYYY', + n: 7, + b: Buffer.from('bin-payload-1234') + }); + const buf = Buffer.allocUnsafeSlow(packed.length); + packed.copy(buf); + const o = msgpack.unpack(buf, { lazy: true }); + structuredClone(buf.buffer, { transfer: [buf.buffer] }); + assert.equal(buf.buffer.byteLength, 0); + assert.equal(o.n, 7); + assert.equal(o.s, 'hello-lazy-uaf-marker-ABCDEFGH'); + assert.equal(o.t, 'second-string-XXXXYYYY'); + assert.equal(Buffer.isBuffer(o.b), true); + assert.equal(o.b.toString(), 'bin-payload-1234'); + }); });