fix: Refuse to walk an invalid SHAMap in getMissingNodes - #8086
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens SHAMap::getMissingNodes() against invalid tree shapes by refusing to descend into an inner node at a depth only leaves may occupy, preventing a std::logic_error from escaping worker threads and terminating the process. It also adds per-sanitizer compile defines so tests can reliably detect the active sanitizer.
Changes:
- Add a depth guard in the missing-node walk that marks the map
Invalidand safely aborts further descent while still draining deferred reads. - Expand the SHAMap sync test suite with targeted cases (including a TSAN-only concurrency test) and extend
DeepChainto generate “decoy” children for async-read coverage. - Add
XRPL_ASAN/XRPL_TSAN/XRPL_UBSANcompile definitions incmake/XrplSanitizers.cmake.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/tests/libxrpl/shamap/SHAMapSync.cpp | Adds ChainFilter and multiple tests covering invalid-depth handling, deferred-read draining, cache interaction, and TSAN concurrency behavior. |
| src/tests/libxrpl/shamap/DeepChain.h | Adds DeepChain::withDecoys() to force async reads during descent for sanitizer-sensitive test coverage. |
| src/libxrpl/shamap/SHAMapSync.cpp | Implements the depth guard during traversal and adds invalid-map short-circuiting + safe draining semantics. |
| include/xrpl/shamap/SHAMap.h | Updates public API documentation for the new “empty result may mean Invalid” behavior and state transitions. |
| cmake/XrplSanitizers.cmake | Defines per-sanitizer preprocessor macros alongside the existing SANITIZERS string. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
1cbcded to
5f94b56
Compare
5f94b56 to
9802dc9
Compare
9802dc9 to
23315ab
Compare
23315ab to
fbfef83
Compare
4d19975 to
387e1ed
Compare
387e1ed to
2d147cc
Compare
A walk that reaches a position only a leaf may occupy now marks the map Invalid and abandons the descent instead of continuing: SHAMapNodeID::getChildNodeID() throws past kLeafDepth, uncaught, all the way to std::terminate(). It's reachable without going through addKnownNode() at all - InboundLedgers::gotStaleData() stores any parseable node from an unsolicited liAS_NODE reply into the fetch pack by its own hash with no relatedness check - making this a conditioned remote denial of service, not just a single bad packet. As in addKnownNode(), the depth check runs before the full-below cache lookup, for the same cache-doesn't-cover-depth reason. Callers must re-check isValid() before reading an empty result as nothing left to fetch, which getMissingNodes()'s docstring now says.
2d147cc to
ed3e87c
Compare
There was a problem hiding this comment.
This is a well-scoped, carefully documented fix that adds a depth guard to SHAMap::getMissingNodes()/gmnProcessNodes() to prevent walking past kLeafDepth (which previously threw std::logic_error and crashed the process via std::terminate()), plus a matching XRPL_ASAN/XRPL_TSAN/XRPL_UBSAN compile-define addition and seven new targeted tests. The guard is correctly ordered ahead of the full-below cache lookup (mirroring addKnownNode's existing rationale), the invalidation path falls through to drain in-flight deferred reads before discarding results, and isValid() is re-checked after the loop to avoid racing clearSynching() against a concurrent invalidation. I did not find a correctness or security bug in the added code; the only note is a minor, low-confidence observation about log volume on repeated calls to an already-invalid map.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Part 7/16 of a stack. Base: part 6 (
bthomee/shamap-invalid-06-refuse-immutable).High Level Overview of Change
SHAMap::getMissingNodes()now refuses to walk past a position only a leaf may occupy, instead ofletting
SHAMapNodeID::getChildNodeID()throwstd::logic_errorout of a job-queue worker thread andcrash the process via
std::terminate(). This position is reachable remotely, without any node everpassing through
addKnownNode()'s own guard (added in part 5), via an unsolicitedliAS_NODEreplyseeding the fetch pack — a conditioned but real remote denial-of-service. Also adds a per-sanitizer
XRPL_ASAN/XRPL_TSAN/XRPL_UBSANcompile define incmake/XrplSanitizers.cmake, alongside theexisting dot-joined
SANITIZERSstring, so test code can detect one specific sanitizer directly.Context of Change
A walk that reaches a position only a leaf may occupy now marks the map
Invalidand abandons thedescent. Reaching that position was otherwise fatal:
SHAMapNodeID::getChildNodeID()throwsstd::logic_errorpastkLeafDepth, and there is no try/catch aroundgetMissingNodes()inInboundLedger::trigger(), aroundjob.doJob()inJobQueue, or inWorkers::Worker::run(), so theexception leaves the thread function and reaches
std::terminate(). It is reachable without any nodepassing through
addKnownNode():InboundLedgers::gotStaleData()stores any parseable node from anunsolicited
liAS_NODEreply into the fetch pack keyed by its own hash, with no relatedness check, andgetFetchPack()re-verifies only that hash, so such a node canonicalizes into the tree and the walkdescends onto it. Steering which hash a node acquires needs it to have no trusted validations, which
makes this a conditioned remote denial of service rather than a single-packet one.
As in
addKnownNode(), the depth test precedes the full-below cache lookup, for the same reason: thatcache is keyed by node hash and shared across maps, and a hash does not cover depth, so a hit would
carry the whole branch past the guard. Four further properties of
isValid()bound what the walk doesonce the verdict lands: it short-circuits on entry rather than re-deriving a verdict already reached; it
breaks out of the descent but falls through to the deferred-read drain; it discards whatever was
collected; and it re-tests before
clearSynching(), since another thread'saddKnownNode()can writethe verdict after the loop's own test.
DeepChaingainswithDecoys(): the same chain, but with a second and unresolvable child at everylevel, so a backed map's
descendAsync()posts a real asynchronous read at every level — needed by theThreadSanitizer case in this same commit.
Seven cases cover this: five drive the walk through a
ChainFilter(standing in for a fetch pack); asixth pins that
addRootNode()cannot clear the synching flag on an invalid map; the seventh races awalk against
setImmutable()under ThreadSanitizer. That last case needed a way to ask the compilerwhether TSAN is active from within a test file — the
XRPL_TSANcompile define this branch adds tocmake/XrplSanitizers.cmakealongsideXRPL_ASAN/XRPL_UBSANis what it now checks, rather than alocal GCC/Clang feature-macro dance.
API Impact
libxrplchange (any change that may affectlibxrplor dependents oflibxrpl)