From 148f990f4c9f43a6588008f10cfbad96816f480e Mon Sep 17 00:00:00 2001 From: Claude Perrin Date: Sun, 17 May 2026 17:46:20 +0200 Subject: [PATCH] fix(utilities): lossless guard + unsigned BigInt parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `toCppIntegerB` called `BigInt::Int64Value(&lossless)` but never checked the `lossless` flag. A BigInt outside the int64_t range (e.g. bit 63 of a uint64 accidentally set on the JS side) silently wrapped — the helper documented as a parser was acting as a truncating cast. Throw on `!lossless`. `toCppIntegerB` is also the wrong parser for fields the C++ side stores as uint64 (bitsets, profile flags): a NEGATIVE BigInt (-1n) is lossless as int64 and wraps to 0xFFFFFFFFFFFFFFFF when assigned to a uint64 destination — the same high-bit injection the lossless check was supposed to block. Add a dedicated `toCppUnsignedIntegerB` (and matching optional wrapper `maybeNonemptyUintB`) that rejects negative BigInts via `BigInt::ToWords()` sign-bit inspection BEFORE the `Uint64Value` conversion. Callers that need to store into uint64 should migrate to the unsigned variant in a follow-up commit; this change only adds the helpers + tightens the existing int64 helper. No behaviour change on the existing call sites unless they pass a BigInt outside int64 range, in which case they were already storing the wrong value. Signed-off-by: Claude Perrin --- include/utilities.hpp | 14 ++++++++++--- src/utilities.cpp | 49 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/include/utilities.hpp b/include/utilities.hpp index 23ebb92..103f489 100644 --- a/include/utilities.hpp +++ b/include/utilities.hpp @@ -81,8 +81,18 @@ int64_t toCppInteger(Napi::Value x, const std::string& identifier, bool allowUnd */ int64_t toCppIntegerB(Napi::Value x, const std::string& identifier, bool allowUndefined = false); +/** + * Unsigned BigInt parser for fields the C++ side stores as uint64 + * (bitsets, profile flags, etc.). Rejects negative BigInts so a + * caller's `-1n` cannot silently wrap to `0xFFFFFFFFFFFFFFFF` on + * assignment to a `uint64_t` destination. + */ +uint64_t toCppUnsignedIntegerB( + Napi::Value x, const std::string& identifier, bool allowUndefined = false); + std::optional maybeNonemptyInt(Napi::Value x, const std::string& identifier); std::optional maybeNonemptyIntB(Napi::Value x, const std::string& identifier); +std::optional maybeNonemptyUintB(Napi::Value x, const std::string& identifier); std::optional maybeNonemptyBoolean(Napi::Value x, const std::string& identifier); std::optional maybeNonemptySysSeconds( @@ -455,9 +465,7 @@ std::vector from_base64_to_vector(std::string_view x); // Concept to match containers with a size() method template concept HasSize = requires(T t) { - { - t.size() - } -> std::convertible_to; + { t.size() } -> std::convertible_to; }; template diff --git a/src/utilities.cpp b/src/utilities.cpp index cdce84e..70cee28 100644 --- a/src/utilities.cpp +++ b/src/utilities.cpp @@ -151,12 +151,57 @@ int64_t toCppIntegerB(Napi::Value x, const std::string& identifier, bool allowUn auto lossless = true; if (allowUndefined && (x.IsNull() || x.IsUndefined())) return 0; - if (x.IsBigInt()) - return x.As().Int64Value(&lossless); + if (x.IsBigInt()) { + auto value = x.As().Int64Value(&lossless); + // Napi reports whether the conversion truncated. Without + // this check, a BigInt outside int64_t range silently wraps + // — e.g. bit 63 of a uint64 Pro feature bitset accidentally + // set on the JS side stores the wrong value here. + if (!lossless) + throw std::invalid_argument{"BigInt out of int64_t range for "s + identifier}; + return value; + } + + throw std::invalid_argument{"Unsupported type for "s + identifier + ": expected a bigint"}; +} + +uint64_t toCppUnsignedIntegerB(Napi::Value x, const std::string& identifier, bool allowUndefined) { + // Dedicated unsigned parser for fields the C++ side stores as + // uint64 (bitsets, profile flags, etc.). The lossless check on + // `toCppIntegerB` only catches BigInts outside int64_t range, but + // a NEGATIVE BigInt (e.g. -1n) is lossless as int64 and silently + // wraps to 0xFFFFFFFFFFFFFFFF when assigned to uint64 — exactly + // the high-bit injection the lossless check was meant to block. + // Use Uint64Value + explicit non-negative guard via BigInt::ToWords. + auto lossless = true; + if (allowUndefined && (x.IsNull() || x.IsUndefined())) + return 0; + if (x.IsBigInt()) { + auto bigint = x.As(); + int signBit = 0; + size_t wordCount = 0; + bigint.ToWords(&signBit, &wordCount, nullptr); + if (signBit != 0) + throw std::invalid_argument{"BigInt is negative for unsigned field "s + identifier}; + auto value = bigint.Uint64Value(&lossless); + if (!lossless) + throw std::invalid_argument{"BigInt out of uint64_t range for "s + identifier}; + return value; + } throw std::invalid_argument{"Unsupported type for "s + identifier + ": expected a bigint"}; } +std::optional maybeNonemptyUintB(Napi::Value x, const std::string& identifier) { + if (x.IsNull() || x.IsUndefined()) + return std::nullopt; + if (x.IsBigInt()) { + return toCppUnsignedIntegerB(x, identifier); + } + + throw std::invalid_argument{"maybeNonemptyUint with invalid type, called from " + identifier}; +} + std::optional maybeNonemptyIntB(Napi::Value x, const std::string& identifier) { if (x.IsNull() || x.IsUndefined()) return std::nullopt;