fix: Make NodePathStack fail closed when assertions are compiled out - #7944
Draft
bthomee wants to merge 1 commit into
Draft
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR hardens SHAMap traversal/path-stack logic so that release builds (where XRPL_ASSERT compiles out) fail closed instead of risking UB (e.g., std::stack::top()/pop() on empty) or inconsistent failure behavior between stack vs no-stack traversal modes.
Changes:
- Convert
NodePathStackpush operations (pushRoot/pushChild/pushNode) to[[nodiscard]] booland add explicit guards for invalid pushes (empty parent, null node, out-of-range branch, overly-deep inner child). - Update SHAMap traversal/iteration helpers to check push outcomes / empty-stack conditions and return
nullptror throwSHAMapMissingNoderather than continuing under invalid preconditions. - Tighten
walkTowardsKeydepth handling for the no-stack mode to mirrorpushChild’s “inner child depth” constraint and keep both modes aligned.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/libxrpl/shamap/SHAMap.cpp | Updates traversal/iteration code paths to handle push failures and empty stacks safely in release builds, and aligns depth-failure behavior between stack/no-stack modes. |
| include/xrpl/shamap/SHAMap.h | Hardens NodePathStack to avoid UB when assertions are compiled out by adding runtime guards and returning status from push operations. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
132
to
+136
| stack == nullptr || stack->empty(), "xrpl::SHAMap::walkTowardsKey : empty stack input"); | ||
| auto inNode = root_; | ||
| SHAMapNodeID nodeID; | ||
|
|
||
| // Every node on this walk lies on the path to `id`, so the stack can derive each ID from the | ||
| // branch `id` selects at the node above it. | ||
| auto pushCurrent = [&] { | ||
| if (stack != nullptr) | ||
| stack->pushNode(inNode, id); | ||
| // Without a caller-supplied stack, `nodeID` is the only record of position, so it is derived |
Comment on lines
537
to
541
| { | ||
| // An empty map leaves only the root behind; a failed walk leaves the path it got to. | ||
| stack.clear(); | ||
| return nullptr; | ||
| } |
bthomee
force-pushed
the
bthomee/shamap-stack-06-unify-upper-lower-bound
branch
from
August 3, 2026 23:25
cfd2adf to
c208afa
Compare
bthomee
force-pushed
the
bthomee/shamap-stack-07-nodepathstack-fail-closed
branch
from
August 3, 2026 23:25
d69ec58 to
fc4d854
Compare
bthomee
force-pushed
the
bthomee/shamap-stack-06-unify-upper-lower-bound
branch
from
August 4, 2026 00:12
c208afa to
dc9f746
Compare
bthomee
force-pushed
the
bthomee/shamap-stack-07-nodepathstack-fail-closed
branch
from
August 4, 2026 00:12
fc4d854 to
8fe795c
Compare
The stack asserted its preconditions and then went ahead regardless. Asserts expand to `assert`, so in a release build every one of those was a no-op in front of the operation it was guarding: reading or popping an empty `std::stack` is undefined, `pushChild`'s out-of-range branch check was missing entirely, and `getChildNodeID` throws `std::logic_error` at leaf depth. None of these conditions are reachable through any of `SHAMap`'s public entry points today, but the failure modes if they ever did happen would be disproportionate: an out-of-range branch would silently corrupt a node ID instead of failing loudly, and a `logic_error` reaching an unguarded call chain would abort the process, since nothing in this codebase catches it. Pushes now return false instead of throwing or silently corrupting the ID, and reads degrade to a null node rather than undefined behavior. `[[nodiscard]]` makes an unchecked push a compile error. Each new guard is marked `UNREACHABLE` rather than left implicitly untested, since no test fixture in this suite can reach these paths without building a deliberately corrupt map. `pushRoot` gets the same conversion as every sibling method; it was the one push still asserting instead of returning false. `walkTowardsKey`'s two modes (with and without a caller-supplied stack) must fail at the same node and leave the stack in a state every caller already knows how to handle; on failure the stack is now cleared via a restored `clear()`, and a restored `pushCurrent` lambda keeps the loop-entry and post-loop push-and-clear logic from being duplicated. It also stops deriving each node ID twice: a caller-supplied stack now reads the ID `pushNode` just computed off `stack->top().second`, instead of a redundant local copy that additionally went stale once the loop exited. `belowHelper` and `peekNextItem` finally get the fallback `top()`'s own docstring promises: both read `stack.top()` right after an assert-only emptiness check, with no fallback for release builds, so both now return early on an empty stack instead of dereferencing a null `SHAMapTreeNodePtr`. `pushChild`'s hard guard also only checked the parent's depth against `kLeafDepth`, one level too permissive for an inner child: a parent at 63 passed the check, then pushed an inner child at 64 with only a debug-only assert catching it, the exact gap this commit exists to close. Tightened to require depth + 1 below `kLeafDepth` for an inner child, with `walkTowardsKey`'s no-stack path given the identical tightening so a malformed map fails at the same node in both modes.
bthomee
force-pushed
the
bthomee/shamap-stack-06-unify-upper-lower-bound
branch
from
August 4, 2026 00:49
dc9f746 to
8022ede
Compare
bthomee
force-pushed
the
bthomee/shamap-stack-07-nodepathstack-fail-closed
branch
from
August 4, 2026 00:49
8fe795c to
a7dcbae
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part 7/8 of a stack. Base: part 6 (
bthomee/shamap-stack-06-unify-upper-lower-bound).The stack asserted its preconditions and then went ahead regardless. Asserts
expand to
assert, so in a release build every one of those was a no-op infront of the operation it was guarding: reading or popping an empty
std::stackis undefined,pushChild's out-of-range branch check was missingentirely, and
getChildNodeIDthrowsstd::logic_errorat leaf depth. Noneof these conditions are reachable through any of
SHAMap's public entrypoints today, but the failure modes if they ever did happen would be
disproportionate: an out-of-range branch would silently corrupt a node ID
instead of failing loudly, and a
logic_errorreaching an unguarded callchain would abort the process, since nothing in this codebase catches it.
Pushes now return false instead of throwing or silently corrupting the ID, and
reads degrade to a null node rather than undefined behavior.
[[nodiscard]]makes an unchecked push a compile error. Each new guard is marked
UNREACHABLErather than left implicitly untested, since no test fixture inthis suite can reach these paths without building a deliberately corrupt map.
pushRootgets the same conversion as every sibling method; it was the onepush still asserting instead of returning false.
walkTowardsKey's two modes (with and without a caller-supplied stack) mustfail at the same node and leave the stack in a state every caller already
knows how to handle; on failure the stack is now cleared via a restored
clear(), and a restoredpushCurrentlambda keeps the loop-entry andpost-loop push-and-clear logic from being duplicated. It also stops deriving
each node ID twice: a caller-supplied stack now reads the ID
pushNodejustcomputed off
stack->top().second, instead of a redundant local copy thatadditionally went stale once the loop exited.
belowHelperandpeekNextItemfinally get the fallbacktop()'s own docstring promises: bothread
stack.top()right after an assert-only emptiness check, with nofallback for release builds, so both now return early on an empty stack
instead of dereferencing a null
SHAMapTreeNodePtr.pushChild's hard guard also only checked the parent's depth againstkLeafDepth, one level too permissive for an inner child: a parent at 63passed the check, then pushed an inner child at 64 with only a debug-only
assert catching it, the exact gap this commit exists to close. Tightened to
require depth + 1 below
kLeafDepthfor an inner child, withwalkTowardsKey's no-stack path given the identical tightening so amalformed map fails at the same node in both modes.