From a0d88381b5286b700865175e74181f14db8c4f29 Mon Sep 17 00:00:00 2001 From: "Leo Zhang (zhangchiqing)" Date: Wed, 24 Jun 2026 09:56:05 -0700 Subject: [PATCH 1/5] add checkpoint iterate nodes function and util --- cmd/util/cmd/checkpoint-iterate-nodes/cmd.go | 133 ++++ cmd/util/cmd/root.go | 2 + .../complete/wal/checkpoint_node_iterator.go | 585 ++++++++++++++++++ .../wal/checkpoint_node_iterator_test.go | 206 ++++++ 4 files changed, 926 insertions(+) create mode 100644 cmd/util/cmd/checkpoint-iterate-nodes/cmd.go create mode 100644 ledger/complete/wal/checkpoint_node_iterator.go create mode 100644 ledger/complete/wal/checkpoint_node_iterator_test.go diff --git a/cmd/util/cmd/checkpoint-iterate-nodes/cmd.go b/cmd/util/cmd/checkpoint-iterate-nodes/cmd.go new file mode 100644 index 00000000000..7ad963ea42a --- /dev/null +++ b/cmd/util/cmd/checkpoint-iterate-nodes/cmd.go @@ -0,0 +1,133 @@ +package checkpoint_iterate_nodes + +import ( + "errors" + "fmt" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + + "github.com/onflow/flow-go/ledger/complete/wal" +) + +var ( + flagCheckpointDir string + flagCheckpoint string +) + +// Cmd streams every node of a checkpoint (V6 or V7) in descendants-first (DFS) +// order without loading the whole checkpoint into memory, reports node-type +// counts and total payload size, and verifies the trie structural integrity. +var Cmd = &cobra.Command{ + Use: "checkpoint-iterate-nodes", + Short: "Stream a checkpoint node-by-node, report node-type counts, and verify trie integrity.", + Long: `Stream a checkpoint (V6 or V7) node-by-node in depth-first order without loading +the whole checkpoint into memory. + +It reports: + - the number of leaf nodes and interim nodes, + - the number of interim nodes that effectively have a single child (one child + is nil, or both children are present but one is a default/empty node), + - the total payload size across leaf nodes (V6 only; V7 stores no payloads). + +While streaming it verifies trie structural integrity: every interim node must +reference only already-seen children, and every node must be referenced by some +parent or trie root. On any integrity violation the command exits fatally.`, + Run: run, +} + +func init() { + Cmd.Flags().StringVar(&flagCheckpointDir, "checkpoint-dir", "", + "directory containing the checkpoint files (required)") + _ = Cmd.MarkFlagRequired("checkpoint-dir") + + Cmd.Flags().StringVar(&flagCheckpoint, "checkpoint", "", + "checkpoint header filename, e.g. \"checkpoint.00000100\" or \"checkpoint.00000100.v7\" (required)") + _ = Cmd.MarkFlagRequired("checkpoint") +} + +func run(*cobra.Command, []string) { + log.Info(). + Str("checkpoint_dir", flagCheckpointDir). + Str("checkpoint", flagCheckpoint). + Msg("iterating checkpoint nodes") + + res, err := iterateCheckpoint(flagCheckpointDir, flagCheckpoint, log.Logger) + if err != nil { + // An integrity violation (or any read error) is fatal: the checkpoint + // cannot be trusted. + if errors.Is(err, wal.ErrCheckpointIntegrity) { + log.Fatal().Err(err).Msg("checkpoint failed integrity verification") + } + log.Fatal().Err(err).Msg("fail to iterate checkpoint nodes") + } + + log.Info(). + Uint64("TotalNodes", res.totalNodes). + Uint64("LeafNodes", res.leafNodes). + Uint64("InterimNodes", res.interimNodes). + Uint64("InterimWithSingleChild", res.interimSingleChild). + Uint64("InterimWithDefaultChild", res.interimDefaultChild). + Uint64("EffectivelySingleChild", res.interimSingleChild+res.interimDefaultChild). + Uint64("LeavesWithPayload", res.leavesWithPayload). + Uint64("TotalPayloadSize", res.totalPayloadSize). + Msgf("successfully iterated checkpoint %v", flagCheckpoint) +} + +// result accumulates the statistics reported over the whole checkpoint forest. +type result struct { + totalNodes uint64 + leafNodes uint64 + interimNodes uint64 + // interimSingleChild counts interim nodes with exactly one non-nil child + // (the other child index is 0). + interimSingleChild uint64 + // interimDefaultChild counts interim nodes with two non-nil children where + // exactly one of them is a default (empty) node — effectively a single child. + interimDefaultChild uint64 + // leavesWithPayload counts leaf nodes carrying a non-empty payload (V6). + leavesWithPayload uint64 + // totalPayloadSize is the sum of encoded payload sizes across leaf nodes (V6). + totalPayloadSize uint64 +} + +func iterateCheckpoint(dir string, fileName string, logger zerolog.Logger) (result, error) { + var res result + + err := wal.IterateCheckpointNodes(logger, dir, fileName, func(n *wal.CheckpointNode) error { + res.totalNodes++ + + if n.IsLeaf { + res.leafNodes++ + if n.PayloadSize > 0 { + res.leavesWithPayload++ + res.totalPayloadSize += uint64(n.PayloadSize) + } + return nil + } + + res.interimNodes++ + + leftNil := n.LeftChildIndex == 0 + rightNil := n.RightChildIndex == 0 + + switch { + case leftNil != rightNil: + // exactly one child is nil + res.interimSingleChild++ + case !leftNil && !rightNil: + // both children present: effectively single child if exactly one is a default node + if n.LeftChildIsDefault != n.RightChildIsDefault { + res.interimDefaultChild++ + } + } + + return nil + }) + if err != nil { + return result{}, fmt.Errorf("error while iterating checkpoint: %w", err) + } + + return res, nil +} diff --git a/cmd/util/cmd/root.go b/cmd/util/cmd/root.go index 511ba5f75ce..cf832d5c781 100644 --- a/cmd/util/cmd/root.go +++ b/cmd/util/cmd/root.go @@ -16,6 +16,7 @@ import ( check_storage "github.com/onflow/flow-go/cmd/util/cmd/check-storage" checkpoint_collect_stats "github.com/onflow/flow-go/cmd/util/cmd/checkpoint-collect-stats" checkpoint_convert_v7 "github.com/onflow/flow-go/cmd/util/cmd/checkpoint-convert-v7" + checkpoint_iterate_nodes "github.com/onflow/flow-go/cmd/util/cmd/checkpoint-iterate-nodes" checkpoint_list_tries "github.com/onflow/flow-go/cmd/util/cmd/checkpoint-list-tries" checkpoint_trie_stats "github.com/onflow/flow-go/cmd/util/cmd/checkpoint-trie-stats" compact_execution_state "github.com/onflow/flow-go/cmd/util/cmd/compact-execution-state" @@ -112,6 +113,7 @@ func addCommands() { rootCmd.AddCommand(checkpoint_trie_stats.Cmd) rootCmd.AddCommand(checkpoint_collect_stats.Cmd) rootCmd.AddCommand(checkpoint_convert_v7.Cmd) + rootCmd.AddCommand(checkpoint_iterate_nodes.Cmd) rootCmd.AddCommand(read_badger.RootCmd) rootCmd.AddCommand(read_protocol_state.RootCmd) rootCmd.AddCommand(ledger_json_exporter.Cmd) diff --git a/ledger/complete/wal/checkpoint_node_iterator.go b/ledger/complete/wal/checkpoint_node_iterator.go new file mode 100644 index 00000000000..ec4a873275f --- /dev/null +++ b/ledger/complete/wal/checkpoint_node_iterator.go @@ -0,0 +1,585 @@ +package wal + +import ( + "bufio" + "encoding/binary" + "errors" + "fmt" + "io" + "os" + + "github.com/rs/zerolog" + + "github.com/onflow/flow-go/ledger" + "github.com/onflow/flow-go/ledger/common/hash" + "github.com/onflow/flow-go/ledger/complete/mtrie/flattener" + "github.com/onflow/flow-go/ledger/complete/payloadless" +) + +// encLeafHashFlagSize is the size of the V7 leaf-hash presence flag (1 byte). +// The remaining node field sizes are shared with [checkpoint_v7_convert_stream.go] +// (encNodeTypeSize, encHeightSize, encHashSize, encPathSize, encNodeIndexSize, +// encPayloadLengthSize, fixedNodePrefixSize, leafNodeTypeByte, interimNodeTypeByte). +const encLeafHashFlagSize = 1 + +// ErrCheckpointIntegrity indicates that a checkpoint's trie structure is corrupt: +// either an interim node references a child that has not been seen yet (a forward +// or out-of-range reference, violating the descendants-first ordering), or a node +// is not referenced by any parent interim node or trie root (an orphan node). +var ErrCheckpointIntegrity = errors.New("checkpoint integrity violation") + +// CheckpointNode carries the decoded, per-node information passed to an +// [IterateNodeFunc] during a streaming iteration of a checkpoint. It is a +// lightweight view: no child pointers and no payload bytes are retained, so the +// caller can process arbitrarily large checkpoints without materializing the +// trie forest in memory. +type CheckpointNode struct { + // Index is the 1-based global index of this node in the checkpoint's + // descendants-first node sequence. It matches the index scheme used to + // reference children: index 0 is reserved for the nil (empty) child. + Index uint64 + + // Height is the node's height in the trie. + Height uint16 + + // Hash is the node's hash. + Hash hash.Hash + + // IsLeaf is true for leaf nodes and false for interim nodes. + IsLeaf bool + + // IsDefault is true iff this node's hash equals the default hash for its + // height, i.e. the sub-trie rooted at this node is completely unallocated. + IsDefault bool + + // Path is the register storage path. Only meaningful for leaf nodes. + Path ledger.Path + + // PayloadSize is the encoded payload size (in bytes) recorded in a V6 leaf + // node's on-disk length prefix. It is 0 for interim nodes and for V7 + // (payloadless) leaf nodes, which do not store payloads. + PayloadSize int + + // LeftChildIndex and RightChildIndex are the global indices of an interim + // node's children; 0 means a nil (empty) child. Both are 0 for leaf nodes. + LeftChildIndex uint64 + RightChildIndex uint64 + + // LeftChildIsDefault and RightChildIsDefault report whether the referenced + // child is a default node (a completely unallocated sub-trie). They are false + // when the corresponding child index is 0 (nil child) and for leaf nodes. + LeftChildIsDefault bool + RightChildIsDefault bool +} + +// IterateNodeFunc processes a single node during a checkpoint iteration. Nodes +// are delivered in descendants-first (post-order DFS) order, so every child of a +// node is delivered before the node itself. +// +// Returning an error aborts the iteration and the error is propagated out of +// [IterateCheckpointNodes]. +type IterateNodeFunc func(*CheckpointNode) error + +// IterateCheckpointNodes streams every node of a checkpoint (V6 or V7), invoking +// fn once per node in descendants-first (post-order DFS) order, the same order in +// which nodes are written to disk. The whole checkpoint is never loaded into +// memory: each node is decoded from the raw byte stream and handed to fn without +// retaining child pointers or payloads. +// +// The version is detected from the checkpoint header file's version bytes; each +// part file's magic+version bytes are additionally validated while reading. +// Per-part-file CRC32 checksums are verified, matching the regular checkpoint +// readers. +// +// Counts produced by fn are over the unique nodes of the whole checkpoint forest +// (nodes shared between tries are stored, and therefore delivered, exactly once). +// +// While streaming, the trie structure is verified: +// - every interim node must reference only already-seen, in-range children +// (descendants-first ordering); and +// - every node must be referenced by some parent interim node or trie root. +// +// To perform these checks without retaining nodes, the iterator keeps two bits +// per node (a "default node" bit and a "referenced" bit), i.e. O(nodeCount) bits +// of memory — far smaller than the nodes themselves, but not constant. +// +// Expected error returns during normal operation: +// - [ErrCheckpointIntegrity]: when an interim node references an unknown/forward +// child, or when a node is not referenced by any parent or trie root. +// - [os.ErrNotExist] (wrapped): when a checkpoint part file is missing. +func IterateCheckpointNodes(logger zerolog.Logger, dir string, fileName string, fn IterateNodeFunc) error { + headerPath := filePathCheckpointHeader(dir, fileName) + + version, err := readCheckpointHeaderVersion(headerPath) + if err != nil { + return fmt.Errorf("could not read checkpoint header version: %w", err) + } + isV7 := version == VersionV7 + + var subtrieChecksums []uint32 + if isV7 { + subtrieChecksums, _, err = readCheckpointHeaderV7(headerPath, logger) + } else { + subtrieChecksums, _, err = readCheckpointHeader(headerPath, logger) + } + if err != nil { + return fmt.Errorf("could not read checkpoint header: %w", err) + } + + if err := allPartFileExist(dir, fileName, len(subtrieChecksums)); err != nil { + return fmt.Errorf("fail to check all checkpoint part file exist: %w", err) + } + + // First pass: read only the part-file footers (at the file tails) to learn each + // subtrie's node count. This yields the per-subtrie global-index offsets and the + // total node count needed to size the integrity bitsets before streaming. + offsets := make([]uint64, len(subtrieChecksums)) + var totalSub uint64 + for i := range subtrieChecksums { + count, err := readSubtrieNodeCountFromFooter(logger, dir, fileName, i) + if err != nil { + return fmt.Errorf("could not read subtrie %d footer: %w", i, err) + } + offsets[i] = totalSub + totalSub += count + } + + topLevelNodesCount, err := readTopTrieNodeCountFromFooter(logger, dir, fileName) + if err != nil { + return fmt.Errorf("could not read top trie footer: %w", err) + } + + total := totalSub + topLevelNodesCount + + logger.Info(). + Uint64("subtrie_nodes", totalSub). + Uint64("top_level_nodes", topLevelNodesCount). + Uint64("total_nodes", total). + Msg("starting checkpoint node iteration") + + it := &checkpointIterator{ + fn: fn, + isDefault: newBitset(total + 1), + referenced: newBitset(total + 1), + totalSub: totalSub, + total: total, + logProgress: logProgress( + "iterating checkpoint nodes", int(total), logger), + } + + // Second pass: stream the subtrie part files (sequentially), then the top-trie + // part file. processCheckpointSubTrie(V7) validates the file header and verifies + // the CRC32 checksum around the node stream we consume. + for i := range subtrieChecksums { + offset := offsets[i] + process := func(reader *Crc32Reader, nodesCount uint64) error { + scratch := make([]byte, 1024*4) + for localIndex := uint64(1); localIndex <= nodesCount; localIndex++ { + meta, err := readNodeMeta(reader, scratch, isV7) + if err != nil { + return fmt.Errorf("cannot read subtrie %d node %d: %w", i, localIndex, err) + } + globalIndex := offset + localIndex + // Within a subtrie file, child indices are local to that file. + lGlobal := subtrieChildToGlobal(meta.lChild, offset) + rGlobal := subtrieChildToGlobal(meta.rChild, offset) + if err := it.emit(meta, globalIndex, lGlobal, rGlobal); err != nil { + return err + } + } + return nil + } + + if isV7 { + err = processCheckpointSubTrieV7(dir, fileName, i, subtrieChecksums[i], logger, process) + } else { + err = processCheckpointSubTrie(dir, fileName, i, subtrieChecksums[i], logger, process) + } + if err != nil { + return fmt.Errorf("could not iterate subtrie %d: %w", i, err) + } + } + + if err := it.iterateTopTrie(dir, fileName, isV7, logger); err != nil { + return fmt.Errorf("could not iterate top trie: %w", err) + } + + logger.Info().Uint64("total_nodes", total).Msg("finished streaming checkpoint nodes, verifying every node is referenced") + + // Every node must be referenced by a parent interim node or a trie root. + for idx := uint64(1); idx <= total; idx++ { + if !it.referenced.get(idx) { + return fmt.Errorf("%w: node at global index %d is not referenced by any parent or trie root (orphan node)", + ErrCheckpointIntegrity, idx) + } + } + + return nil +} + +// checkpointIterator holds the shared state for a single streaming iteration: the +// caller's callback, the two integrity bitsets, and the global-index layout. +type checkpointIterator struct { + fn IterateNodeFunc + isDefault *bitset // isDefault[i] set iff node i is a default node + referenced *bitset // referenced[i] set iff node i is referenced by a parent or trie root + totalSub uint64 // total number of subtrie nodes; top-level node global indices start at totalSub+1 + total uint64 // total number of nodes in the checkpoint + logProgress func(uint64) // called once per node to log streaming progress (percentage + ETA) +} + +// emit verifies and records a fully-decoded node at the given global index (with +// child indices already converted to global indices, 0 meaning a nil child), then +// invokes the caller's callback. +// +// Expected error returns during normal operation: +// - [ErrCheckpointIntegrity]: when an interim node references a child whose +// global index does not strictly precede this node (forward/unknown reference). +func (it *checkpointIterator) emit(meta nodeMeta, globalIndex, lGlobal, rGlobal uint64) error { + if !meta.isLeaf { + // Descendants-first ordering: both children must have been seen already. + // A nil child (index 0) trivially satisfies 0 < globalIndex. + if lGlobal >= globalIndex || rGlobal >= globalIndex { + return fmt.Errorf("%w: interim node at global index %d references an unknown/forward child (left=%d, right=%d)", + ErrCheckpointIntegrity, globalIndex, lGlobal, rGlobal) + } + if lGlobal != 0 { + it.referenced.set(lGlobal) + } + if rGlobal != 0 { + it.referenced.set(rGlobal) + } + } + + isDef := meta.hash == ledger.GetDefaultHashForHeight(int(meta.height)) + if isDef { + it.isDefault.set(globalIndex) + } + + cn := CheckpointNode{ + Index: globalIndex, + Height: meta.height, + Hash: meta.hash, + IsLeaf: meta.isLeaf, + IsDefault: isDef, + } + if meta.isLeaf { + cn.Path = meta.path + cn.PayloadSize = meta.payloadSize + } else { + cn.LeftChildIndex = lGlobal + cn.RightChildIndex = rGlobal + cn.LeftChildIsDefault = lGlobal != 0 && it.isDefault.get(lGlobal) + cn.RightChildIsDefault = rGlobal != 0 && it.isDefault.get(rGlobal) + } + + it.logProgress(globalIndex) + + return it.fn(&cn) +} + +// iterateTopTrie streams the top-trie part file: the subtrie-node count, then the +// top-level nodes (whose child indices are global), then the trie root records +// (each referencing its root node by global index). It mirrors readTopLevelTries +// (V6) / readTopLevelTriesV7 (V7) but extracts only per-node metadata and verifies +// the CRC32 checksum. +// +// Expected error returns during normal operation: +// - [ErrCheckpointIntegrity]: see [checkpointIterator.emit] and trie-root range checks. +func (it *checkpointIterator) iterateTopTrie(dir string, fileName string, isV7 bool, logger zerolog.Logger) error { + version := VersionV6 + if isV7 { + version = VersionV7 + } + + topPath, _ := filePathTopTries(dir, fileName) + return withFile(logger, topPath, func(file *os.File) error { + if err := validateFileHeader(MagicBytesCheckpointToptrie, version, file); err != nil { + return err + } + + topLevelNodesCount, triesCount, expectedSum, err := readTopTriesFooter(file) + if err != nil { + return fmt.Errorf("could not read top tries footer: %w", err) + } + + if _, err := file.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("could not seek to start of top trie file: %w", err) + } + + reader := NewCRC32Reader(bufio.NewReaderSize(file, defaultBufioReadSize)) + if _, _, err := readFileHeader(reader); err != nil { + return fmt.Errorf("could not read version for top trie: %w", err) + } + + // Read and validate the subtrie node count carried in the top-trie file. + buf := make([]byte, encNodeCountSize) + if _, err := io.ReadFull(reader, buf); err != nil { + return fmt.Errorf("could not read subtrie node count: %w", err) + } + readSubtrieNodeCount, err := decodeNodeCount(buf) + if err != nil { + return fmt.Errorf("could not decode subtrie node count: %w", err) + } + if readSubtrieNodeCount != it.totalSub { + return fmt.Errorf("mismatch subtrie node count, top trie file has %v, but subtrie footers sum to %v", + readSubtrieNodeCount, it.totalSub) + } + + scratch := make([]byte, 1024*4) + + // Top-level nodes: child indices are already global (0 = nil child). + for j := uint64(1); j <= topLevelNodesCount; j++ { + meta, err := readNodeMeta(reader, scratch, isV7) + if err != nil { + return fmt.Errorf("cannot read top-level node %d: %w", j, err) + } + globalIndex := it.totalSub + j + if err := it.emit(meta, globalIndex, meta.lChild, meta.rChild); err != nil { + return err + } + } + + // Trie root records: each references its root node by global index. + for i := uint16(0); i < triesCount; i++ { + var rootIndex uint64 + if isV7 { + enc, err := payloadless.ReadEncodedTrie(reader, scratch) + if err != nil { + return fmt.Errorf("cannot read trie root record %d: %w", i, err) + } + rootIndex = enc.RootIndex + } else { + enc, err := flattener.ReadEncodedTrie(reader, scratch) + if err != nil { + return fmt.Errorf("cannot read trie root record %d: %w", i, err) + } + rootIndex = enc.RootIndex + } + if rootIndex > it.total { + return fmt.Errorf("%w: trie root record %d references out-of-range node index %d (total %d)", + ErrCheckpointIntegrity, i, rootIndex, it.total) + } + if rootIndex != 0 { + it.referenced.set(rootIndex) + } + } + + // Consume the footer (node count + trie count) so the CRC covers it, then verify. + if _, err := io.ReadFull(reader, scratch[:encNodeCountSize+encTrieCountSize]); err != nil { + return fmt.Errorf("cannot read top trie footer: %w", err) + } + + actualSum := reader.Crc32() + if actualSum != expectedSum { + return fmt.Errorf("invalid checksum in top level trie, expected %v, actual %v", expectedSum, actualSum) + } + + if _, err := io.ReadFull(reader, scratch[:crc32SumSize]); err != nil { + return fmt.Errorf("could not read checksum from top trie file: %w", err) + } + + if err := ensureReachedEOF(reader); err != nil { + return fmt.Errorf("fail to read top trie file: %w", err) + } + + return nil + }) +} + +// nodeMeta holds the per-node fields decoded from the raw checkpoint byte stream. +// For interim nodes, lChild/rChild are the child indices exactly as stored (local +// to the subtrie file, or global in the top-trie file); the caller converts them +// as needed. For leaf nodes, lChild/rChild are 0. +type nodeMeta struct { + isLeaf bool + height uint16 + hash hash.Hash + path ledger.Path + payloadSize int + lChild uint64 + rChild uint64 +} + +// readNodeMeta decodes one node from reader, extracting only the fields needed for +// iteration and integrity checking. It does NOT construct a node or resolve child +// references. Leaf payload bytes (V6) and optional leaf hashes (V7) are consumed +// from the reader — so the wrapping CRC32 reader still sees them — but discarded. +// +// scratch is a reusable buffer; if it is smaller than 1024 bytes a new buffer is +// allocated. The same scratch may be reused across calls. +// +// No error returns are expected during normal operation; all error returns indicate +// a malformed input stream or an IO failure. +func readNodeMeta(reader io.Reader, scratch []byte, isV7 bool) (nodeMeta, error) { + const minBufSize = 1024 + if len(scratch) < minBufSize { + scratch = make([]byte, minBufSize) + } + + if _, err := io.ReadFull(reader, scratch[:fixedNodePrefixSize]); err != nil { + return nodeMeta{}, fmt.Errorf("cannot read node prefix: %w", err) + } + + nType := scratch[0] + height := binary.BigEndian.Uint16(scratch[encNodeTypeSize:]) + nodeHash, err := hash.ToHash(scratch[encNodeTypeSize+encHeightSize : fixedNodePrefixSize]) + if err != nil { + return nodeMeta{}, fmt.Errorf("failed to decode node hash: %w", err) + } + + switch nType { + case interimNodeTypeByte: + if _, err := io.ReadFull(reader, scratch[:2*encNodeIndexSize]); err != nil { + return nodeMeta{}, fmt.Errorf("cannot read interim node child indices: %w", err) + } + return nodeMeta{ + isLeaf: false, + height: height, + hash: nodeHash, + lChild: binary.BigEndian.Uint64(scratch[:encNodeIndexSize]), + rChild: binary.BigEndian.Uint64(scratch[encNodeIndexSize : 2*encNodeIndexSize]), + }, nil + + case leafNodeTypeByte: + if _, err := io.ReadFull(reader, scratch[:encPathSize]); err != nil { + return nodeMeta{}, fmt.Errorf("cannot read leaf path: %w", err) + } + path, err := ledger.ToPath(scratch[:encPathSize]) + if err != nil { + return nodeMeta{}, fmt.Errorf("failed to decode leaf path: %w", err) + } + + meta := nodeMeta{isLeaf: true, height: height, hash: nodeHash, path: path} + + if isV7 { + // V7 leaf: 1-byte leaf-hash flag, then an optional 32-byte leaf hash. + if _, err := io.ReadFull(reader, scratch[:encLeafHashFlagSize]); err != nil { + return nodeMeta{}, fmt.Errorf("cannot read leaf hash flag: %w", err) + } + switch scratch[0] { + case 0: // leaf hash absent + case 1: // leaf hash present: consume and discard 32 bytes + if _, err := io.ReadFull(reader, scratch[:encHashSize]); err != nil { + return nodeMeta{}, fmt.Errorf("cannot read leaf hash: %w", err) + } + default: + return nodeMeta{}, fmt.Errorf("invalid leaf hash flag: %d", scratch[0]) + } + // V7 leaves store no payload; payloadSize stays 0. + } else { + // V6 leaf: 4-byte encoded payload length, then that many payload bytes. + if _, err := io.ReadFull(reader, scratch[:encPayloadLengthSize]); err != nil { + return nodeMeta{}, fmt.Errorf("cannot read leaf payload length: %w", err) + } + size := binary.BigEndian.Uint32(scratch[:encPayloadLengthSize]) + meta.payloadSize = int(size) + // Consume the payload through the reader (so the CRC sees it) without retaining it. + if _, err := io.CopyN(io.Discard, reader, int64(size)); err != nil { + return nodeMeta{}, fmt.Errorf("cannot read leaf payload: %w", err) + } + } + + return meta, nil + + default: + return nodeMeta{}, fmt.Errorf("failed to decode node type %d", nType) + } +} + +// subtrieChildToGlobal converts a subtrie-file-local child index into the global +// index used by the integrity bitsets. A local index of 0 (nil child) maps to the +// global nil index 0. +func subtrieChildToGlobal(localChild uint64, offset uint64) uint64 { + if localChild == 0 { + return 0 + } + return offset + localChild +} + +// readCheckpointHeaderVersion opens the checkpoint header file and reads its +// magic+version bytes, returning the checkpoint version. It validates the magic +// bytes but performs no checksum verification (the per-version header reader does +// that during the main pass). +// +// No error returns are expected during normal operation. +func readCheckpointHeaderVersion(headerPath string) (uint16, error) { + f, err := os.Open(headerPath) + if err != nil { + return 0, fmt.Errorf("could not open header file: %w", err) + } + defer f.Close() + + magic, version, err := readFileHeader(f) + if err != nil { + return 0, fmt.Errorf("could not read header magic and version: %w", err) + } + if magic != MagicBytesCheckpointHeader { + return 0, fmt.Errorf("wrong magic bytes for checkpoint header, expect %#x, got %#x", + MagicBytesCheckpointHeader, magic) + } + return version, nil +} + +// readSubtrieNodeCountFromFooter opens the subtrie part file at the given index and +// reads its node count from the footer at the file tail (without scanning the nodes). +// +// No error returns are expected during normal operation. +func readSubtrieNodeCountFromFooter(logger zerolog.Logger, dir string, fileName string, index int) (uint64, error) { + filepath, _, err := filePathSubTries(dir, fileName, index) + if err != nil { + return 0, err + } + var count uint64 + err = withFile(logger, filepath, func(f *os.File) error { + c, _, err := readSubTriesFooter(f) + if err != nil { + return err + } + count = c + return nil + }) + return count, err +} + +// readTopTrieNodeCountFromFooter opens the top-trie part file and reads its +// top-level node count from the footer at the file tail. +// +// No error returns are expected during normal operation. +func readTopTrieNodeCountFromFooter(logger zerolog.Logger, dir string, fileName string) (uint64, error) { + filepath, _ := filePathTopTries(dir, fileName) + var count uint64 + err := withFile(logger, filepath, func(f *os.File) error { + c, _, _, err := readTopTriesFooter(f) + if err != nil { + return err + } + count = c + return nil + }) + return count, err +} + +// bitset is a compact fixed-size set of bit flags indexed by node global index. +// It uses one bit per element (8x smaller than a []bool), which matters when the +// element count is the checkpoint's node count. +// +// NOT CONCURRENCY SAFE! +type bitset struct { + words []uint64 +} + +// newBitset returns a bitset able to hold indices in the range [0, n). +func newBitset(n uint64) *bitset { + return &bitset{words: make([]uint64, (n+63)/64)} +} + +// set marks the bit at index i. +func (b *bitset) set(i uint64) { + b.words[i>>6] |= 1 << (i & 63) +} + +// get reports whether the bit at index i is set. +func (b *bitset) get(i uint64) bool { + return b.words[i>>6]&(1<<(i&63)) != 0 +} diff --git a/ledger/complete/wal/checkpoint_node_iterator_test.go b/ledger/complete/wal/checkpoint_node_iterator_test.go new file mode 100644 index 00000000000..f94fe27a343 --- /dev/null +++ b/ledger/complete/wal/checkpoint_node_iterator_test.go @@ -0,0 +1,206 @@ +package wal + +import ( + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + + "github.com/onflow/flow-go/ledger" + "github.com/onflow/flow-go/ledger/complete/mtrie/flattener" + "github.com/onflow/flow-go/ledger/complete/mtrie/node" + "github.com/onflow/flow-go/ledger/complete/mtrie/trie" + "github.com/onflow/flow-go/ledger/complete/payloadless" + "github.com/onflow/flow-go/utils/unittest" +) + +// iterateStats accumulates the statistics produced by IterateCheckpointNodes for testing. +type iterateStats struct { + total uint64 + leaf uint64 + interim uint64 + payloadSize uint64 +} + +func collectIterateStats(t *testing.T, dir, fileName string) iterateStats { + var s iterateStats + seen := make(map[uint64]struct{}) + err := IterateCheckpointNodes(zerolog.Nop(), dir, fileName, func(n *CheckpointNode) error { + // Every node is delivered exactly once with a unique global index. + _, dup := seen[n.Index] + require.False(t, dup, "node index %d delivered more than once", n.Index) + seen[n.Index] = struct{}{} + + s.total++ + if n.IsLeaf { + s.leaf++ + s.payloadSize += uint64(n.PayloadSize) + require.Zero(t, n.LeftChildIndex) + require.Zero(t, n.RightChildIndex) + } else { + s.interim++ + // descendants-first: children precede the node + require.Less(t, n.LeftChildIndex, n.Index) + require.Less(t, n.RightChildIndex, n.Index) + } + return nil + }) + require.NoError(t, err) + return s +} + +// oracleStatsV6 computes the expected statistics by loading the checkpoint into +// memory and iterating the unique nodes of the whole forest (matching how the +// checkpoint dedups shared subtries when storing). +func oracleStatsV6(t *testing.T, tries []*trie.MTrie) iterateStats { + var s iterateStats + visited := make(map[*node.Node]uint64) + visited[nil] = 0 + for _, tr := range tries { + for itr := flattener.NewUniqueNodeIterator(tr.RootNode(), visited); itr.Next(); { + n := itr.Value() + visited[n] = uint64(len(visited)) + s.total++ + if n.IsLeaf() { + s.leaf++ + s.payloadSize += uint64(ledger.EncodedPayloadLengthWithoutPrefix(n.Payload(), payloadEncodingVersion)) + } else { + s.interim++ + } + } + } + return s +} + +// oracleStatsV7 mirrors oracleStatsV6 for payloadless tries. Payloadless leaves +// store no payload, so payloadSize is always 0. +func oracleStatsV7(t *testing.T, tries []*payloadless.MTrie) iterateStats { + var s iterateStats + visited := make(map[*payloadless.Node]uint64) + visited[nil] = 0 + for _, tr := range tries { + for itr := payloadless.NewUniqueNodeIterator(tr.RootNode(), visited); itr.Next(); { + n := itr.Value() + visited[n] = uint64(len(visited)) + s.total++ + if n.IsLeaf() { + s.leaf++ + } else { + s.interim++ + } + } + } + return s +} + +func TestIterateCheckpointNodesV6(t *testing.T) { + logger := zerolog.Nop() + + t.Run("simple trie", func(t *testing.T) { + unittest.RunWithTempDir(t, func(dir string) { + tries := createSimpleTrie(t) + fileName := "checkpoint-iterate-v6-simple" + require.NoError(t, StoreCheckpointV6Concurrently(tries, dir, fileName, logger)) + + got := collectIterateStats(t, dir, fileName) + want := oracleStatsV6(t, tries) + require.Equal(t, want, got) + }) + }) + + t.Run("multiple random tries", func(t *testing.T) { + unittest.RunWithTempDir(t, func(dir string) { + tries := createMultipleRandomTries(t) + fileName := "checkpoint-iterate-v6-multi" + require.NoError(t, StoreCheckpointV6Concurrently(tries, dir, fileName, logger)) + + got := collectIterateStats(t, dir, fileName) + want := oracleStatsV6(t, tries) + require.Equal(t, want, got) + require.Positive(t, got.leaf) + require.Positive(t, got.interim) + require.Positive(t, got.payloadSize) + }) + }) +} + +func TestIterateCheckpointNodesV7(t *testing.T) { + logger := zerolog.Nop() + + t.Run("simple trie", func(t *testing.T) { + unittest.RunWithTempDir(t, func(dir string) { + tries := createSimplePayloadlessTrie(t) + fileName := "checkpoint-iterate-v7-simple" + require.NoError(t, StoreCheckpointV7Concurrently(tries, dir, fileName, logger)) + + got := collectIterateStats(t, dir, fileName) + want := oracleStatsV7(t, tries) + require.Equal(t, want, got) + require.Zero(t, got.payloadSize, "v7 leaves store no payload") + }) + }) + + t.Run("multiple random tries", func(t *testing.T) { + unittest.RunWithTempDir(t, func(dir string) { + tries := createMultiplePayloadlessTries(t) + fileName := "checkpoint-iterate-v7-multi" + require.NoError(t, StoreCheckpointV7Concurrently(tries, dir, fileName, logger)) + + got := collectIterateStats(t, dir, fileName) + want := oracleStatsV7(t, tries) + require.Equal(t, want, got) + require.Positive(t, got.leaf) + require.Positive(t, got.interim) + }) + }) +} + +func TestCheckpointIteratorForwardReference(t *testing.T) { + it := &checkpointIterator{ + fn: func(*CheckpointNode) error { return nil }, + isDefault: newBitset(16), + referenced: newBitset(16), + total: 15, + } + + // An interim node at global index 3 referencing a child at index 5 violates + // the descendants-first ordering (the child has not been seen yet). + err := it.emit(nodeMeta{isLeaf: false, height: 1}, 3, 5, 0) + require.ErrorIs(t, err, ErrCheckpointIntegrity) + + // A node referencing only already-seen children is accepted and marks them + // as referenced. + require.NoError(t, it.emit(nodeMeta{isLeaf: false, height: 2}, 6, 2, 4)) + require.True(t, it.referenced.get(2)) + require.True(t, it.referenced.get(4)) + require.False(t, it.referenced.get(6)) +} + +func TestBitset(t *testing.T) { + b := newBitset(130) + require.False(t, b.get(0)) + require.False(t, b.get(64)) + require.False(t, b.get(129)) + + b.set(0) + b.set(64) + b.set(129) + require.True(t, b.get(0)) + require.True(t, b.get(64)) + require.True(t, b.get(129)) + require.False(t, b.get(1)) + require.False(t, b.get(63)) + require.False(t, b.get(65)) +} + +func TestIterateCheckpointNodesEmptyTrie(t *testing.T) { + logger := zerolog.Nop() + unittest.RunWithTempDir(t, func(dir string) { + tries := []*trie.MTrie{trie.NewEmptyMTrie()} + fileName := "checkpoint-iterate-v6-empty" + require.NoError(t, StoreCheckpointV6Concurrently(tries, dir, fileName, logger)) + + got := collectIterateStats(t, dir, fileName) + require.Equal(t, iterateStats{}, got, "empty trie has no stored nodes") + }) +} From ce7d2d9b8f7815b5b6c95bb71540b1ca090a53c0 Mon Sep 17 00:00:00 2001 From: "Leo Zhang (zhangchiqing)" Date: Wed, 24 Jun 2026 20:28:32 -0700 Subject: [PATCH 2/5] handle interim node with default node as child --- cmd/util/cmd/checkpoint-iterate-nodes/cmd.go | 27 +++++---------- .../complete/wal/checkpoint_node_iterator.go | 25 +++++++++----- .../wal/checkpoint_node_iterator_test.go | 33 ++++++++++++++++--- 3 files changed, 54 insertions(+), 31 deletions(-) diff --git a/cmd/util/cmd/checkpoint-iterate-nodes/cmd.go b/cmd/util/cmd/checkpoint-iterate-nodes/cmd.go index 7ad963ea42a..cc70b17e58b 100644 --- a/cmd/util/cmd/checkpoint-iterate-nodes/cmd.go +++ b/cmd/util/cmd/checkpoint-iterate-nodes/cmd.go @@ -27,13 +27,13 @@ the whole checkpoint into memory. It reports: - the number of leaf nodes and interim nodes, - - the number of interim nodes that effectively have a single child (one child - is nil, or both children are present but one is a default/empty node), + - the number of interim nodes that have a single (non-nil) child, - the total payload size across leaf nodes (V6 only; V7 stores no payloads). While streaming it verifies trie structural integrity: every interim node must -reference only already-seen children, and every node must be referenced by some -parent or trie root. On any integrity violation the command exits fatally.`, +reference only already-seen, non-default children, and every node must be +referenced by some parent or trie root. On any integrity violation the command +exits fatally.`, Run: run, } @@ -68,8 +68,6 @@ func run(*cobra.Command, []string) { Uint64("LeafNodes", res.leafNodes). Uint64("InterimNodes", res.interimNodes). Uint64("InterimWithSingleChild", res.interimSingleChild). - Uint64("InterimWithDefaultChild", res.interimDefaultChild). - Uint64("EffectivelySingleChild", res.interimSingleChild+res.interimDefaultChild). Uint64("LeavesWithPayload", res.leavesWithPayload). Uint64("TotalPayloadSize", res.totalPayloadSize). Msgf("successfully iterated checkpoint %v", flagCheckpoint) @@ -83,9 +81,6 @@ type result struct { // interimSingleChild counts interim nodes with exactly one non-nil child // (the other child index is 0). interimSingleChild uint64 - // interimDefaultChild counts interim nodes with two non-nil children where - // exactly one of them is a default (empty) node — effectively a single child. - interimDefaultChild uint64 // leavesWithPayload counts leaf nodes carrying a non-empty payload (V6). leavesWithPayload uint64 // totalPayloadSize is the sum of encoded payload sizes across leaf nodes (V6). @@ -109,18 +104,14 @@ func iterateCheckpoint(dir string, fileName string, logger zerolog.Logger) (resu res.interimNodes++ + // An interim node with exactly one nil child is legitimate in a compactified + // trie (the present child is itself an interim node). Both-nil cannot occur, + // and a non-nil default child is rejected as an integrity violation by the + // iterator, so the only remaining case to count here is the single-child one. leftNil := n.LeftChildIndex == 0 rightNil := n.RightChildIndex == 0 - - switch { - case leftNil != rightNil: - // exactly one child is nil + if leftNil != rightNil { res.interimSingleChild++ - case !leftNil && !rightNil: - // both children present: effectively single child if exactly one is a default node - if n.LeftChildIsDefault != n.RightChildIsDefault { - res.interimDefaultChild++ - } } return nil diff --git a/ledger/complete/wal/checkpoint_node_iterator.go b/ledger/complete/wal/checkpoint_node_iterator.go index ec4a873275f..3989ea02ccc 100644 --- a/ledger/complete/wal/checkpoint_node_iterator.go +++ b/ledger/complete/wal/checkpoint_node_iterator.go @@ -64,12 +64,6 @@ type CheckpointNode struct { // node's children; 0 means a nil (empty) child. Both are 0 for leaf nodes. LeftChildIndex uint64 RightChildIndex uint64 - - // LeftChildIsDefault and RightChildIsDefault report whether the referenced - // child is a default node (a completely unallocated sub-trie). They are false - // when the corresponding child index is 0 (nil child) and for leaf nodes. - LeftChildIsDefault bool - RightChildIsDefault bool } // IterateNodeFunc processes a single node during a checkpoint iteration. Nodes @@ -234,7 +228,8 @@ type checkpointIterator struct { // // Expected error returns during normal operation: // - [ErrCheckpointIntegrity]: when an interim node references a child whose -// global index does not strictly precede this node (forward/unknown reference). +// global index does not strictly precede this node (forward/unknown reference), +// or references a default (completely unallocated) child. func (it *checkpointIterator) emit(meta nodeMeta, globalIndex, lGlobal, rGlobal uint64) error { if !meta.isLeaf { // Descendants-first ordering: both children must have been seen already. @@ -243,6 +238,20 @@ func (it *checkpointIterator) emit(meta nodeMeta, globalIndex, lGlobal, rGlobal return fmt.Errorf("%w: interim node at global index %d references an unknown/forward child (left=%d, right=%d)", ErrCheckpointIntegrity, globalIndex, lGlobal, rGlobal) } + // A correctly compactified trie never stores a default (completely unallocated) + // sub-trie as a referenced child: such children are collapsed to nil during + // construction (see node.NewInterimCompactifiedNode). Because children are + // emitted before their parent, their default status is already recorded in + // it.isDefault. A referenced default child therefore indicates a malformed + // (non-compactified) checkpoint trie. + if lGlobal != 0 && it.isDefault.get(lGlobal) { + return fmt.Errorf("%w: interim node at global index %d references a default (unallocated) left child %d", + ErrCheckpointIntegrity, globalIndex, lGlobal) + } + if rGlobal != 0 && it.isDefault.get(rGlobal) { + return fmt.Errorf("%w: interim node at global index %d references a default (unallocated) right child %d", + ErrCheckpointIntegrity, globalIndex, rGlobal) + } if lGlobal != 0 { it.referenced.set(lGlobal) } @@ -269,8 +278,6 @@ func (it *checkpointIterator) emit(meta nodeMeta, globalIndex, lGlobal, rGlobal } else { cn.LeftChildIndex = lGlobal cn.RightChildIndex = rGlobal - cn.LeftChildIsDefault = lGlobal != 0 && it.isDefault.get(lGlobal) - cn.RightChildIsDefault = rGlobal != 0 && it.isDefault.get(rGlobal) } it.logProgress(globalIndex) diff --git a/ledger/complete/wal/checkpoint_node_iterator_test.go b/ledger/complete/wal/checkpoint_node_iterator_test.go index f94fe27a343..96cab3cc374 100644 --- a/ledger/complete/wal/checkpoint_node_iterator_test.go +++ b/ledger/complete/wal/checkpoint_node_iterator_test.go @@ -157,10 +157,11 @@ func TestIterateCheckpointNodesV7(t *testing.T) { func TestCheckpointIteratorForwardReference(t *testing.T) { it := &checkpointIterator{ - fn: func(*CheckpointNode) error { return nil }, - isDefault: newBitset(16), - referenced: newBitset(16), - total: 15, + fn: func(*CheckpointNode) error { return nil }, + isDefault: newBitset(16), + referenced: newBitset(16), + total: 15, + logProgress: func(uint64) {}, } // An interim node at global index 3 referencing a child at index 5 violates @@ -176,6 +177,30 @@ func TestCheckpointIteratorForwardReference(t *testing.T) { require.False(t, it.referenced.get(6)) } +func TestCheckpointIteratorDefaultChild(t *testing.T) { + it := &checkpointIterator{ + fn: func(*CheckpointNode) error { return nil }, + isDefault: newBitset(16), + referenced: newBitset(16), + total: 15, + logProgress: func(uint64) {}, + } + + // Emit a node at index 2 whose hash equals the default hash for its height: it + // is recorded as a default (completely unallocated) sub-trie. + const height = 1 + require.NoError(t, it.emit( + nodeMeta{isLeaf: true, height: height, hash: ledger.GetDefaultHashForHeight(height)}, + 2, 0, 0, + )) + require.True(t, it.isDefault.get(2)) + + // An interim node referencing the default child is an integrity violation: a + // compactified trie collapses default children to nil rather than storing them. + err := it.emit(nodeMeta{isLeaf: false, height: height + 1}, 3, 2, 0) + require.ErrorIs(t, err, ErrCheckpointIntegrity) +} + func TestBitset(t *testing.T) { b := newBitset(130) require.False(t, b.get(0)) From f096e1d25ca5bd98ce7dd7c6a644e3f583750e1c Mon Sep 17 00:00:00 2001 From: "Leo Zhang (zhangchiqing)" Date: Thu, 25 Jun 2026 16:52:07 -0700 Subject: [PATCH 3/5] update comments --- ledger/complete/wal/checkpoint_node_iterator.go | 6 ------ ledger/complete/wal/checkpoint_v7_convert_stream.go | 5 +++++ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/ledger/complete/wal/checkpoint_node_iterator.go b/ledger/complete/wal/checkpoint_node_iterator.go index 3989ea02ccc..7b41cc6bf08 100644 --- a/ledger/complete/wal/checkpoint_node_iterator.go +++ b/ledger/complete/wal/checkpoint_node_iterator.go @@ -16,12 +16,6 @@ import ( "github.com/onflow/flow-go/ledger/complete/payloadless" ) -// encLeafHashFlagSize is the size of the V7 leaf-hash presence flag (1 byte). -// The remaining node field sizes are shared with [checkpoint_v7_convert_stream.go] -// (encNodeTypeSize, encHeightSize, encHashSize, encPathSize, encNodeIndexSize, -// encPayloadLengthSize, fixedNodePrefixSize, leafNodeTypeByte, interimNodeTypeByte). -const encLeafHashFlagSize = 1 - // ErrCheckpointIntegrity indicates that a checkpoint's trie structure is corrupt: // either an interim node references a child that has not been seen yet (a forward // or out-of-range reference, violating the descendants-first ordering), or a node diff --git a/ledger/complete/wal/checkpoint_v7_convert_stream.go b/ledger/complete/wal/checkpoint_v7_convert_stream.go index 6318acff648..d5475ac4a35 100644 --- a/ledger/complete/wal/checkpoint_v7_convert_stream.go +++ b/ledger/complete/wal/checkpoint_v7_convert_stream.go @@ -29,6 +29,11 @@ const ( encNodeIndexSize = 8 encPayloadLengthSize = 4 + // encLeafHashFlagSize is the size of the V7 leaf-hash presence flag (1 byte). + // This must match the (unexported) encLeafHashFlagSize in + // ledger/complete/payloadless/flattener.go, which writes this flag. + encLeafHashFlagSize = 1 + // fixedNodePrefixSize is the size of the leading bytes shared by every // encoded node (leaf or interim): node type + height + node hash. fixedNodePrefixSize = encNodeTypeSize + encHeightSize + encHashSize From 0ded30fa1d89b9061d20aee81611bff2be82c658 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:38:46 +0000 Subject: [PATCH 4/5] Fix tidy failure in checkpoint iterator Co-authored-by: zhangchiqing <811374+zhangchiqing@users.noreply.github.com> --- ledger/complete/wal/checkpoint_node_iterator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ledger/complete/wal/checkpoint_node_iterator.go b/ledger/complete/wal/checkpoint_node_iterator.go index 7b41cc6bf08..9033e90f76a 100644 --- a/ledger/complete/wal/checkpoint_node_iterator.go +++ b/ledger/complete/wal/checkpoint_node_iterator.go @@ -342,7 +342,7 @@ func (it *checkpointIterator) iterateTopTrie(dir string, fileName string, isV7 b } // Trie root records: each references its root node by global index. - for i := uint16(0); i < triesCount; i++ { + for i := range triesCount { var rootIndex uint64 if isV7 { enc, err := payloadless.ReadEncodedTrie(reader, scratch) From 37a80ff51504a96ab15fe413d9d1228030d29021 Mon Sep 17 00:00:00 2001 From: "Leo Zhang (zhangchiqing)" Date: Mon, 14 Sep 2026 15:24:13 -0700 Subject: [PATCH 5/5] address review: validate node height, cross-check top-trie checksum, bound footer node count, add iterator tests --- cmd/util/cmd/checkpoint-iterate-nodes/cmd.go | 7 +- .../complete/wal/checkpoint_node_iterator.go | 135 ++++++++++++++---- .../wal/checkpoint_node_iterator_test.go | 79 ++++++++++ 3 files changed, 187 insertions(+), 34 deletions(-) diff --git a/cmd/util/cmd/checkpoint-iterate-nodes/cmd.go b/cmd/util/cmd/checkpoint-iterate-nodes/cmd.go index cc70b17e58b..77ddb79b50f 100644 --- a/cmd/util/cmd/checkpoint-iterate-nodes/cmd.go +++ b/cmd/util/cmd/checkpoint-iterate-nodes/cmd.go @@ -105,9 +105,10 @@ func iterateCheckpoint(dir string, fileName string, logger zerolog.Logger) (resu res.interimNodes++ // An interim node with exactly one nil child is legitimate in a compactified - // trie (the present child is itself an interim node). Both-nil cannot occur, - // and a non-nil default child is rejected as an integrity violation by the - // iterator, so the only remaining case to count here is the single-child one. + // trie (the present child is itself an interim node). Both-nil does not occur + // in a valid checkpoint, and a non-nil default child is rejected as an + // integrity violation by the iterator, so the only remaining case to count + // here is the single-child one. leftNil := n.LeftChildIndex == 0 rightNil := n.RightChildIndex == 0 if leftNil != rightNil { diff --git a/ledger/complete/wal/checkpoint_node_iterator.go b/ledger/complete/wal/checkpoint_node_iterator.go index 9033e90f76a..9ba30ecc2ba 100644 --- a/ledger/complete/wal/checkpoint_node_iterator.go +++ b/ledger/complete/wal/checkpoint_node_iterator.go @@ -17,9 +17,10 @@ import ( ) // ErrCheckpointIntegrity indicates that a checkpoint's trie structure is corrupt: -// either an interim node references a child that has not been seen yet (a forward -// or out-of-range reference, violating the descendants-first ordering), or a node -// is not referenced by any parent interim node or trie root (an orphan node). +// an interim node references a child that has not been seen yet (a forward or +// out-of-range reference, violating the descendants-first ordering), a node is not +// referenced by any parent interim node or trie root (an orphan node), or a node +// declares a height outside the valid range. var ErrCheckpointIntegrity = errors.New("checkpoint integrity violation") // CheckpointNode carries the decoded, per-node information passed to an @@ -93,22 +94,24 @@ type IterateNodeFunc func(*CheckpointNode) error // // Expected error returns during normal operation: // - [ErrCheckpointIntegrity]: when an interim node references an unknown/forward -// child, or when a node is not referenced by any parent or trie root. +// child, when a node is not referenced by any parent or trie root, or when a +// node declares an out-of-range height. // - [os.ErrNotExist] (wrapped): when a checkpoint part file is missing. func IterateCheckpointNodes(logger zerolog.Logger, dir string, fileName string, fn IterateNodeFunc) error { headerPath := filePathCheckpointHeader(dir, fileName) - version, err := readCheckpointHeaderVersion(headerPath) + version, err := readCheckpointHeaderVersion(logger, headerPath) if err != nil { return fmt.Errorf("could not read checkpoint header version: %w", err) } isV7 := version == VersionV7 var subtrieChecksums []uint32 + var topTrieChecksum uint32 if isV7 { - subtrieChecksums, _, err = readCheckpointHeaderV7(headerPath, logger) + subtrieChecksums, topTrieChecksum, err = readCheckpointHeaderV7(headerPath, logger) } else { - subtrieChecksums, _, err = readCheckpointHeader(headerPath, logger) + subtrieChecksums, topTrieChecksum, err = readCheckpointHeader(headerPath, logger) } if err != nil { return fmt.Errorf("could not read checkpoint header: %w", err) @@ -188,20 +191,28 @@ func IterateCheckpointNodes(logger zerolog.Logger, dir string, fileName string, } } - if err := it.iterateTopTrie(dir, fileName, isV7, logger); err != nil { + if err := it.iterateTopTrie(dir, fileName, isV7, topTrieChecksum, logger); err != nil { return fmt.Errorf("could not iterate top trie: %w", err) } logger.Info().Uint64("total_nodes", total).Msg("finished streaming checkpoint nodes, verifying every node is referenced") - // Every node must be referenced by a parent interim node or a trie root. - for idx := uint64(1); idx <= total; idx++ { + return it.verifyAllReferenced() +} + +// verifyAllReferenced enforces that every node of the checkpoint is referenced by +// a parent interim node or a trie root, returning [ErrCheckpointIntegrity] +// otherwise. +// +// Expected error returns during normal operation: +// - [ErrCheckpointIntegrity]: when an orphan node is found. +func (it *checkpointIterator) verifyAllReferenced() error { + for idx := uint64(1); idx <= it.total; idx++ { if !it.referenced.get(idx) { return fmt.Errorf("%w: node at global index %d is not referenced by any parent or trie root (orphan node)", ErrCheckpointIntegrity, idx) } } - return nil } @@ -223,7 +234,8 @@ type checkpointIterator struct { // Expected error returns during normal operation: // - [ErrCheckpointIntegrity]: when an interim node references a child whose // global index does not strictly precede this node (forward/unknown reference), -// or references a default (completely unallocated) child. +// references a default (completely unallocated) child, or declares an +// out-of-range height. func (it *checkpointIterator) emit(meta nodeMeta, globalIndex, lGlobal, rGlobal uint64) error { if !meta.isLeaf { // Descendants-first ordering: both children must have been seen already. @@ -254,6 +266,13 @@ func (it *checkpointIterator) emit(meta nodeMeta, globalIndex, lGlobal, rGlobal } } + // meta.height is an unvalidated uint16 from disk; [ledger.GetDefaultHashForHeight] + // indexes a 257-entry table, so an out-of-range height would panic. Reject it first. + if int(meta.height) > ledger.NodeMaxHeight { + return fmt.Errorf("%w: node at global index %d declares height %d, exceeding the maximum of %d", + ErrCheckpointIntegrity, globalIndex, meta.height, ledger.NodeMaxHeight) + } + isDef := meta.hash == ledger.GetDefaultHashForHeight(int(meta.height)) if isDef { it.isDefault.set(globalIndex) @@ -285,9 +304,13 @@ func (it *checkpointIterator) emit(meta nodeMeta, globalIndex, lGlobal, rGlobal // (V6) / readTopLevelTriesV7 (V7) but extracts only per-node metadata and verifies // the CRC32 checksum. // +// topTrieChecksum is the checksum recorded for the top-trie part file in the +// checkpoint header; it is cross-checked against the top-trie file footer, the +// same way processCheckpointSubTrie cross-checks each subtrie's header checksum. +// // Expected error returns during normal operation: // - [ErrCheckpointIntegrity]: see [checkpointIterator.emit] and trie-root range checks. -func (it *checkpointIterator) iterateTopTrie(dir string, fileName string, isV7 bool, logger zerolog.Logger) error { +func (it *checkpointIterator) iterateTopTrie(dir string, fileName string, isV7 bool, topTrieChecksum uint32, logger zerolog.Logger) error { version := VersionV6 if isV7 { version = VersionV7 @@ -304,6 +327,11 @@ func (it *checkpointIterator) iterateTopTrie(dir string, fileName string, isV7 b return fmt.Errorf("could not read top tries footer: %w", err) } + if topTrieChecksum != expectedSum { + return fmt.Errorf("mismatch top trie checksum, header file has %v, toptrie file has %v", + topTrieChecksum, expectedSum) + } + if _, err := file.Seek(0, io.SeekStart); err != nil { return fmt.Errorf("could not seek to start of top trie file: %w", err) } @@ -357,12 +385,8 @@ func (it *checkpointIterator) iterateTopTrie(dir string, fileName string, isV7 b } rootIndex = enc.RootIndex } - if rootIndex > it.total { - return fmt.Errorf("%w: trie root record %d references out-of-range node index %d (total %d)", - ErrCheckpointIntegrity, i, rootIndex, it.total) - } - if rootIndex != 0 { - it.referenced.set(rootIndex) + if err := it.markTrieRoot(i, rootIndex); err != nil { + return err } } @@ -388,6 +412,23 @@ func (it *checkpointIterator) iterateTopTrie(dir string, fileName string, isV7 b }) } +// markTrieRoot records the node reference carried by trie root record recordIndex. +// A root index of 0 denotes the empty (all-default) trie and references no stored +// node. Any index above the checkpoint's node count is rejected. +// +// Expected error returns during normal operation: +// - [ErrCheckpointIntegrity]: when the root index is out of range. +func (it *checkpointIterator) markTrieRoot(recordIndex uint16, rootIndex uint64) error { + if rootIndex > it.total { + return fmt.Errorf("%w: trie root record %d references out-of-range node index %d (total %d)", + ErrCheckpointIntegrity, recordIndex, rootIndex, it.total) + } + if rootIndex != 0 { + it.referenced.set(rootIndex) + } + return nil +} + // nodeMeta holds the per-node fields decoded from the raw checkpoint byte stream. // For interim nodes, lChild/rChild are the child indices exactly as stored (local // to the subtrie file, or global in the top-trie file); the caller converts them @@ -504,22 +545,40 @@ func subtrieChildToGlobal(localChild uint64, offset uint64) uint64 { // that during the main pass). // // No error returns are expected during normal operation. -func readCheckpointHeaderVersion(headerPath string) (uint16, error) { - f, err := os.Open(headerPath) +func readCheckpointHeaderVersion(logger zerolog.Logger, headerPath string) (uint16, error) { + var version uint16 + err := withFile(logger, headerPath, func(f *os.File) error { + magic, v, err := readFileHeader(f) + if err != nil { + return fmt.Errorf("could not read header magic and version: %w", err) + } + if magic != MagicBytesCheckpointHeader { + return fmt.Errorf("wrong magic bytes for checkpoint header, expect %#x, got %#x", + MagicBytesCheckpointHeader, magic) + } + version = v + return nil + }) if err != nil { - return 0, fmt.Errorf("could not open header file: %w", err) + return 0, err } - defer f.Close() + return version, nil +} - magic, version, err := readFileHeader(f) - if err != nil { - return 0, fmt.Errorf("could not read header magic and version: %w", err) - } - if magic != MagicBytesCheckpointHeader { - return 0, fmt.Errorf("wrong magic bytes for checkpoint header, expect %#x, got %#x", - MagicBytesCheckpointHeader, magic) +// validateFooterNodeCount rejects a footer-declared node count that cannot possibly +// fit in the file it was read from. Every stored node occupies at least one byte on +// disk, so a count exceeding the file size is necessarily corrupt. This guards the +// O(nodeCount) integrity bitsets against a corrupt footer declaring an absurd count +// (which would otherwise panic with an out-of-memory error). +// +// Expected error returns during normal operation: +// - [ErrCheckpointIntegrity]: when the declared count exceeds the file size. +func validateFooterNodeCount(count uint64, fileSize int64, fileDesc string) error { + if count > uint64(fileSize) { + return fmt.Errorf("%w: %s footer declares %d nodes, which cannot fit in a %d-byte file", + ErrCheckpointIntegrity, fileDesc, count, fileSize) } - return version, nil + return nil } // readSubtrieNodeCountFromFooter opens the subtrie part file at the given index and @@ -537,6 +596,13 @@ func readSubtrieNodeCountFromFooter(logger zerolog.Logger, dir string, fileName if err != nil { return err } + info, err := f.Stat() + if err != nil { + return fmt.Errorf("could not stat subtrie file: %w", err) + } + if err := validateFooterNodeCount(c, info.Size(), fmt.Sprintf("subtrie file %d", index)); err != nil { + return err + } count = c return nil }) @@ -555,6 +621,13 @@ func readTopTrieNodeCountFromFooter(logger zerolog.Logger, dir string, fileName if err != nil { return err } + info, err := f.Stat() + if err != nil { + return fmt.Errorf("could not stat top trie file: %w", err) + } + if err := validateFooterNodeCount(c, info.Size(), "top trie file"); err != nil { + return err + } count = c return nil }) diff --git a/ledger/complete/wal/checkpoint_node_iterator_test.go b/ledger/complete/wal/checkpoint_node_iterator_test.go index 96cab3cc374..0e1f483109c 100644 --- a/ledger/complete/wal/checkpoint_node_iterator_test.go +++ b/ledger/complete/wal/checkpoint_node_iterator_test.go @@ -1,6 +1,7 @@ package wal import ( + "errors" "testing" "github.com/rs/zerolog" @@ -201,6 +202,84 @@ func TestCheckpointIteratorDefaultChild(t *testing.T) { require.ErrorIs(t, err, ErrCheckpointIntegrity) } +func TestCheckpointIteratorHeightValidation(t *testing.T) { + it := &checkpointIterator{ + fn: func(*CheckpointNode) error { return nil }, + isDefault: newBitset(16), + referenced: newBitset(16), + total: 15, + logProgress: func(uint64) {}, + } + + // A height above the trie's maximum would index past the default-hash table. + err := it.emit(nodeMeta{isLeaf: true, height: ledger.NodeMaxHeight + 1}, 1, 0, 0) + require.ErrorIs(t, err, ErrCheckpointIntegrity) + + // The maximum valid height is accepted. + require.NoError(t, it.emit(nodeMeta{isLeaf: true, height: ledger.NodeMaxHeight}, 2, 0, 0)) +} + +func TestCheckpointIteratorOrphanNode(t *testing.T) { + it := &checkpointIterator{ + referenced: newBitset(4), + total: 3, + } + + // Nodes 1 and 2 are referenced, node 3 is not: the orphan pass must report it. + it.referenced.set(1) + it.referenced.set(2) + err := it.verifyAllReferenced() + require.ErrorIs(t, err, ErrCheckpointIntegrity) + require.Contains(t, err.Error(), "orphan node") + + // Referencing the last node clears the violation. + it.referenced.set(3) + require.NoError(t, it.verifyAllReferenced()) +} + +func TestCheckpointIteratorTrieRootRange(t *testing.T) { + it := &checkpointIterator{ + referenced: newBitset(16), + total: 15, + } + + // A root index past the node count is rejected. + err := it.markTrieRoot(0, 16) + require.ErrorIs(t, err, ErrCheckpointIntegrity) + + // An in-range root index is recorded; 0 references no stored node. + require.NoError(t, it.markTrieRoot(1, 15)) + require.True(t, it.referenced.get(15)) + require.NoError(t, it.markTrieRoot(2, 0)) +} + +func TestCheckpointIteratorCallbackError(t *testing.T) { + logger := zerolog.Nop() + unittest.RunWithTempDir(t, func(dir string) { + tries := createSimpleTrie(t) + fileName := "checkpoint-iterate-callback-error" + require.NoError(t, StoreCheckpointV6Concurrently(tries, dir, fileName, logger)) + + sentinel := errors.New("callback aborted") + invocations := 0 + err := IterateCheckpointNodes(logger, dir, fileName, func(*CheckpointNode) error { + invocations++ + return sentinel + }) + require.ErrorIs(t, err, sentinel) + require.Equal(t, 1, invocations, "iteration must abort on the first callback error") + }) +} + +func TestValidateFooterNodeCount(t *testing.T) { + // A count that cannot fit in the file is rejected. + err := validateFooterNodeCount(1<<40, 1024, "subtrie file 0") + require.ErrorIs(t, err, ErrCheckpointIntegrity) + + // A count within the file size is accepted. + require.NoError(t, validateFooterNodeCount(1024, 1024, "subtrie file 0")) +} + func TestBitset(t *testing.T) { b := newBitset(130) require.False(t, b.get(0))