fix: Refuse to make an invalid SHAMap or Ledger immutable

SHAMap::setImmutable() now returns [[nodiscard]] bool and refuses a map
already proven impossible. Every state change goes through trySetState(),
the only writer of state_ past construction, so the order between the
states is stated once: its compare-exchange can't leave Invalid however it
interleaves with another thread's, and setInvalid() stores through the same
funnel rather than behind its back. Invalid is stored unconditionally
there, since only the map itself reaches that verdict and a walk that
reaches it has to win against a thread settling the map; refusing to
overwrite Immutable would leave a map proven impossible reporting itself
sound, which is what nothing downstream could recover from.

Ledger::setImmutable()/setAccepted() do the same one level up, checking
mapsValid() before touching anything and settling both maps independently
so neither is left mid-sync because the other refused. The map hashes are
read before the maps are settled, since getHash() can unshare a dirty
tree, but written to the header only once both maps have made it. A walk
that invalidates a map in between therefore leaves the header describing
what the ledger was built from rather than a map that has since been
abandoned.

Every call site now branches on the result. The two genesis paths and
buildLedgerImpl() call logicError(), since consensus can't tolerate an
invalid ledger; the load paths return early instead; InboundLedger and
TransactionAcquire withdraw complete_ alongside the failure, since for
them a refusal is an outcome a peer can produce. A test helper that cannot
reach BEAST_EXPECT throws instead, so a refusal cannot hand a broken
ledger to the assertions below.
This commit is contained in:
Bart
2026-08-22 22:26:03 -04:00
parent 20d3a9b488
commit 3ee3f3740c
15 changed files with 698 additions and 80 deletions

View File

@@ -257,13 +257,43 @@ public:
header_.validated = true;
}
void
/**
* Mark this ledger as accepted and attempt to make it immutable.
*
* The close-time fields are recorded before the maps are settled, since the
* ledger hash covers them.
*
* @param closeTime The consensus-agreed close time.
* @param closeResolution The close time resolution.
* @param correctCloseTime Whether consensus agreed on the close time; if
* false, kSLcfNoConsensusTime is recorded in closeFlags instead.
* @return What setImmutable() returned, so false means the ledger must be
* discarded rather than retried.
*/
[[nodiscard]] bool
setAccepted(
NetClock::time_point closeTime,
NetClock::duration closeResolution,
bool correctCloseTime);
void
/**
* Mark this ledger as immutable, so it can no longer be modified.
*
* A ledger built or loaded locally cannot have an invalid map, since only a
* map syncing against hashes from outside can be proven impossible (see
* SHAMap::addKnownNode), so a caller on such a path may treat a false return
* as a broken internal invariant. A caller assembling a ledger from peer data
* may not: for it, false is an outcome a peer can produce.
*
* @param rehash Whether to recompute the ledger hash from the header fields.
* The transaction and account hashes are recomputed from the maps too,
* but only the first time.
* @return false if either map is Invalid, leaving the immutable flag unset and
* the header untouched. A map invalidated partway through can still
* leave the other one immutable, so a false return means the ledger
* must be discarded rather than retried.
*/
[[nodiscard]] bool
setImmutable(bool rehash = true);
bool
@@ -272,16 +302,28 @@ public:
return immutable_;
}
/* Mark this ledger as "should be full".
/**
* Whether neither map has been found invalid.
*
* Read by whatever assembles the ledger, which cannot tell a map that
* is merely incomplete from one that has been abandoned by looking at
* what a walk returned. See SHAMap::isValid().
*
* @return Whether both maps can still be the maps the header names.
*/
[[nodiscard]] bool
mapsValid() const
{
return txMap_.isValid() && stateMap_.isValid();
}
"Full" is metadata property of the ledger, it indicates
that the local server wants all the corresponding nodes
in durable storage.
This is marked `const` because it reflects metadata
and not data that is in common with other nodes on the
network.
*/
/**
* Mark this ledger as "should be full", indicating that the local server
* wants all the corresponding nodes in durable storage.
*
* Const because it reflects metadata, not data this ledger shares with
* other nodes on the network.
*/
void
setFull() const
{
@@ -421,6 +463,24 @@ private:
static std::pair<std::shared_ptr<STTx const>, std::shared_ptr<STObject const>>
deserializeTxPlusMeta(SHAMapItem const& item);
/**
* Make both maps immutable, without short-circuiting.
*
* A concurrent walk can invalidate one map after the other is settled,
* so both calls are always made rather than one guarding the other:
* each map becomes Immutable or stays Invalid on its own, and neither
* is left mid-sync because the other refused.
*
* @return Whether both maps are immutable.
*/
[[nodiscard]] bool
setMapsImmutable()
{
bool const txImmutable = txMap_.setImmutable();
bool const stateImmutable = stateMap_.setImmutable();
return txImmutable && stateImmutable;
}
bool immutable_;
// A SHAMap containing the transactions associated with this ledger.

View File

@@ -2,6 +2,7 @@
#include <xrpl/basics/Blob.h>
#include <xrpl/basics/IntrusivePointer.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/SHAMapHash.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Journal.h>
@@ -180,7 +181,14 @@ public:
SHAMap&
operator=(SHAMap const&) = delete;
// Take a snapshot of the given map:
/**
* Take a snapshot of the given map.
*
* @param other The map to snapshot. An Invalid source yields an Invalid
* snapshot, since the two share the same node structure.
* @param isMutable Whether the snapshot may be modified. Ignored when other
* is Invalid, since that state outranks both alternatives.
*/
SHAMap(SHAMap const& other, bool isMutable);
// build new map
@@ -218,8 +226,15 @@ public:
//--------------------------------------------------------------------------
// Returns a new map that's a snapshot of this one.
// Handles copy on write for mutable snapshots.
/**
* Return a new map that is a snapshot of this one.
*
* Handles copy on write for mutable snapshots. An invalid map yields an
* invalid snapshot, since the two share the same node structure.
*
* @param isMutable Whether the snapshot may be modified.
* @return The snapshot.
*/
std::shared_ptr<SHAMap>
snapShot(bool isMutable) const;
@@ -408,7 +423,13 @@ public:
SHAMapTreeNodePtr treeNode,
SHAMapSyncFilter const* filter);
void
/**
* Mark this map as immutable, so it can no longer be modified.
*
* @return false if the map is Invalid and was left unchanged, true
* otherwise.
*/
[[nodiscard]] bool
setImmutable();
/**
@@ -418,8 +439,24 @@ public:
*/
[[nodiscard]] bool
isSynching() const;
/**
* Mark this map as syncing, fixing its hash while still allowing missing
* nodes to be added.
*
* Left unchanged if the map is Invalid, which is terminal - though that
* case is itself treated as unreachable (and asserts in a build with
* assertions enabled), since nothing should call this on a map that has
* already been judged.
*/
void
setSynching();
/**
* Mark this map as no longer syncing, so it can be modified again.
*
* Does nothing if the map is Invalid, which is terminal.
*/
void
clearSynching();
@@ -499,11 +536,27 @@ private:
* Record that the map is provably not the one it claims to be.
*
* Private because only the map itself can prove that, from a node that
* contradicts the hashes it is syncing against.
* contradicts the hashes it is syncing against. Cannot fail, since
* Invalid outranks every other state; see trySetState().
*/
void
setInvalid();
/**
* Move the map to a new state, atomically.
*
* The only writer of state_ past construction, so the order between
* the states lives in one place: Invalid outranks all of them and is
* always stored, while every other transition is refused once the map
* is Invalid, which is what makes that verdict terminal.
*
* @param desired The state to move to.
* @return false if the map is Invalid and the requested state is not,
* leaving it unchanged; true otherwise.
*/
bool
trySetState(SHAMapState desired);
// tree node cache operations
SHAMapTreeNodePtr
cacheLookup(SHAMapHash const& hash) const;
@@ -740,11 +793,41 @@ SHAMap::state() const
return state_.load(std::memory_order_acquire);
}
inline void
inline bool
SHAMap::trySetState(SHAMapState desired)
{
// Stored outright rather than exchanged, since Invalid outranks every other state: only the map
// itself reaches that verdict, from a node that contradicts the hashes it is syncing against,
// so a walk that reaches it has to win however it interleaves with a thread settling the map.
// An exchange that refused to overwrite Immutable would leave a map proven impossible reporting
// itself sound, which is the one thing nothing downstream could recover from.
if (desired == SHAMapState::Invalid)
{
state_.store(SHAMapState::Invalid, std::memory_order_release);
return true;
}
// Compare-exchange rather than check-then-store, so the refusal to leave Invalid holds no
// matter how this call interleaves with another thread's. Invalid is the only state this
// refuses to leave; the loop simply retries if another one is stored meanwhile. No load ahead
// of it, since a failed exchange both reports the state and refreshes expected.
auto expected = SHAMapState::Modifying;
while (expected != SHAMapState::Invalid)
{
if (state_.compare_exchange_weak(
expected, desired, std::memory_order_acq_rel, std::memory_order_acquire))
{
return true;
}
}
return false;
}
inline bool
SHAMap::setImmutable()
{
XRPL_ASSERT(isValid(), "xrpl::SHAMap::setImmutable : state is valid");
state_.store(SHAMapState::Immutable, std::memory_order_release);
SOMETIMES(!isValid(), "xrpl::SHAMap::setImmutable : map is invalid");
return trySetState(SHAMapState::Immutable);
}
inline bool
@@ -756,13 +839,32 @@ SHAMap::isSynching() const
inline void
SHAMap::setSynching()
{
state_.store(SHAMapState::Synching, std::memory_order_release);
// Guarded, so this is not a way out of Invalid, matching clearSynching().
if (!trySetState(SHAMapState::Synching))
{
// Unreachable today, though not because the map is Modifying: a ledger built from a header
// starts out with both maps Synching already. It is unreachable because this is only ever
// called on a map that has just been constructed, so nothing can have synced against it and
// reached a verdict on it yet.
// LCOV_EXCL_START
UNREACHABLE("xrpl::SHAMap::setSynching : map is invalid");
// LCOV_EXCL_STOP
}
}
inline void
SHAMap::clearSynching()
{
state_.store(SHAMapState::Modifying, std::memory_order_release);
// Guarded, so an invalid map stays invalid rather than being moved back to Modifying, which
// passes isValid(). Refusing is the contract rather than a broken invariant, so this reports
// instead of asserting: peer data produces the verdict, so an UNREACHABLE here would be an
// abort a peer could ask for.
SOMETIMES(!isValid(), "xrpl::SHAMap::clearSynching : map is invalid");
if (!trySetState(SHAMapState::Modifying))
{
JLOG(journal_.warn()) << "Refused to clear synching on an invalid map, root hash "
<< root_->getHash();
}
}
inline bool
@@ -774,7 +876,9 @@ SHAMap::isValid() const
inline void
SHAMap::setInvalid()
{
state_.store(SHAMapState::Invalid, std::memory_order_release);
// Through trySetState() like every other transition, so nothing writes state_ behind its back
// and the order between the states is stated once. Cannot fail: Invalid outranks all of them.
trySetState(SHAMapState::Invalid);
}
inline void