Skip to content

fix(utxopersister): bound allocations on untrusted UTXO count/script length + fuzz#1004

Merged
liam merged 1 commit into
bsv-blockchain:mainfrom
liam:liam/fuzz-utxopersister-deser
Jun 3, 2026
Merged

fix(utxopersister): bound allocations on untrusted UTXO count/script length + fuzz#1004
liam merged 1 commit into
bsv-blockchain:mainfrom
liam:liam/fuzz-utxopersister-deser

Conversation

@liam

@liam liam commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

First in a small series adding Go native fuzzing to teranode's untrusted/persisted deserializers. teranode currently has zero fuzz tests, despite ingesting untrusted bytes from peers and persisted files. This PR targets the UTXO-set wrapper reader and fixes the unbounded-allocation bug it immediately surfaces.

Bug

UTXOWrapper deserialization pre-allocated from length/count fields read straight out of the file, with no bounds check:

Site Allocation
FromReader (UTXO.go) make([]*UTXO, numUTXOs)
NewUTXOFromReader make([]byte, scriptLen)
NewUTXOValueFromReader make([]byte, scriptLen) (via reusableScript)

A corrupt or truncated .utxo-set whose header claims numUTXOs = 0xFFFFFFFF or scriptLen = 0xFFFFFFFF forces a multi-GB allocation before the follow-up read fails — a cheap OOM that takes down the core sidecar via the ServiceManager errgroup. This is the same crash class as the recently-fixed utxopersister read-path bugs (#969/#971/#985), and recover() does not save an OOM.

These readers are reachable from the consolidation loop, the seeder, cmd/utxovalidator, and cmd/filereader.

Fix

Bound the allocations to the bytes actually available, never to an untrusted field:

  • UTXO slice: cap the speculative capacity (min(numUTXOs, 1024)) and append as records are read, so a bogus count errors on the first missing record instead of pre-allocating.
  • Script bytes: read via io.CopyN, which streams in fixed-size chunks and stops at EOF — a huge claimed length on a truncated record errors out instead of allocating. The value-only path discards via io.CopyN(io.Discard, …) (allocation-free; io.Discard.ReadFrom uses a pooled buffer) and drops the now-unused reusableScript buffer.

Valid-file parsing is unchanged.

Tests

  • FuzzUTXOWrapperFromBytes — fuzzes NewUTXOWrapperFromBytes; asserts no panic (enforced by the engine) and a re-serialization round-trip invariant (a parsed wrapper's Bytes() must be a prefix of the input). Seeded with valid round-tripped wrappers + hostile headers. Runs as plain corpus replay in CI (no -fuzz flag needed).
  • TestUTXOWrapperFromBytes_HostileSizesAreBounded — deterministic regression: a hostile header allocates < 4 MB (was ~256 MB for the count case, ~32 MB for the script case) before erroring. Fails on the old code, passes on the fix.

Verification

  • Regression test fails before the fix (268 MB / 33 MB observed), passes after.
  • go test -fuzz=FuzzUTXOWrapperFromBytes -fuzztime=45s — 136K execs, no crashers.
  • go test -race ./services/utxopersister/ — pass (existing roundtrip tests green).
  • go vet, gofmt, gci — clean.

Follow-ups (separate PRs): fuzz model.NewBlockFromBytes and pkg/fileformat header parsing. Wire/tx parsing lives in the separate go-wire/go-bt modules.

@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

This PR adds critical fuzzing infrastructure and fixes an unbounded allocation vulnerability in UTXO deserialization. The fix correctly prevents OOM attacks from malicious/corrupted UTXO files.

Security Fix Verified:

  • Before: make([]*UTXO, numUTXOs) and make([]byte, scriptLen) pre-allocated from untrusted fields
  • After: Bounded allocation with min(numUTXOs, 1024) cap + append pattern, and io.CopyN with incremental buffer growth
  • Test confirms hostile 33MB claims now allocate < 4MB before erroring (was ~256MB / ~32MB)

Test Coverage:

  • Fuzz test with hostile seeds (0xFFFFFFFF counts/lengths) validates no panic + round-trip invariant
  • Regression test pins allocation bounds with runtime.MemStats measurement
  • Both tests directly target the bug class

Code Quality:
The implementation is well-documented with detailed comments explaining the security reasoning. The NewUTXOValueFromReader path correctly uses io.Discard for allocation-free skipping.

No issues found. The fix is minimal, correct, and well-tested.


History:

  • ✅ Resolved: Previous concern about bytes.Buffer pre-allocation was based on a misunderstanding — bytes.Buffer.ReadFrom grows incrementally in fixed chunks, not pre-sized to the limit. The test validates this.

return errors.NewStorageError("failed to read utxo script (%d bytes)", l, err)
}

utxo.Script = script.Bytes()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] Allocation concern with bytes.Buffer

While io.CopyN prevents unbounded reads, bytes.Buffer still allocates the full l bytes internally when growing to accommodate the write. For a truncated file claiming a 32MB script, this will allocate ~32MB before the EOF error.

Consider adding an explicit size check before allocation:

  • Cap script size at a reasonable maximum based on consensus limits
  • Fail fast on unreasonable sizes before any allocation
  • Only allocate what is actually readable from the stream

Example approach: check l against a maxScriptSize constant, return an error if exceeded, then use io.ReadFull with a pre-allocated slice of exactly l bytes.

Impact: Minor - the fix significantly improves the situation, but a consensus-based limit would be more robust.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This concern appears to be based on a misunderstanding of bytes.Buffer.ReadFrom behavior.

bytes.Buffer.ReadFrom does NOT pre-allocate to the limit size. It grows incrementally in fixed chunks (typically 512 bytes) as data is actually read. The test TestUTXOWrapperFromBytes_HostileSizesAreBounded at line 77 validates exactly this — a truncated file claiming a 33MB script allocates < 4MB before erroring, which would be impossible if the buffer pre-allocated to the claimed size.

The fix is correct as implemented. Resolving.

@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-1004 (e18558a)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 144
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 1.619µ 1.653µ ~ 0.100
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 71.33n 71.13n ~ 0.200
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 71.13n 71.20n ~ 1.000
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 71.06n 71.22n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 32.45n 33.08n ~ 0.100
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 55.38n 56.39n ~ 0.200
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 129.4n 137.6n ~ 0.100
MiningCandidate_Stringify_Short-4 221.5n 218.7n ~ 0.200
MiningCandidate_Stringify_Long-4 1.648µ 1.643µ ~ 1.000
MiningSolution_Stringify-4 849.7n 859.4n ~ 0.100
BlockInfo_MarshalJSON-4 1.746µ 1.738µ ~ 1.000
NewFromBytes-4 126.1n 123.5n ~ 0.100
AddTxBatchColumnar_Validation-4 2.493µ 2.504µ ~ 1.000
OffsetValidationLoop-4 545.3n 555.2n ~ 0.700
Mine_EasyDifficulty-4 61.54µ 61.71µ ~ 1.000
Mine_WithAddress-4 7.197µ 7.202µ ~ 1.000
BlockAssembler_AddTx-4 0.03201n 0.02780n ~ 0.700
AddNode-4 11.29 12.51 ~ 0.100
AddNodeWithMap-4 12.47 12.76 ~ 0.100
DirectSubtreeAdd/4_per_subtree-4 56.55n 60.68n ~ 0.700
DirectSubtreeAdd/64_per_subtree-4 28.76n 29.36n ~ 0.100
DirectSubtreeAdd/256_per_subtree-4 28.17n 27.74n ~ 0.100
DirectSubtreeAdd/1024_per_subtree-4 26.51n 26.48n ~ 0.400
DirectSubtreeAdd/2048_per_subtree-4 26.02n 26.02n ~ 1.000
SubtreeProcessorAdd/4_per_subtree-4 290.5n 293.0n ~ 1.000
SubtreeProcessorAdd/64_per_subtree-4 284.3n 292.3n ~ 0.100
SubtreeProcessorAdd/256_per_subtree-4 287.1n 292.3n ~ 0.400
SubtreeProcessorAdd/1024_per_subtree-4 277.5n 280.6n ~ 0.100
SubtreeProcessorAdd/2048_per_subtree-4 280.2n 276.5n ~ 0.400
SubtreeProcessorRotate/4_per_subtree-4 287.5n 284.6n ~ 0.700
SubtreeProcessorRotate/64_per_subtree-4 279.7n 277.1n ~ 0.100
SubtreeProcessorRotate/256_per_subtree-4 279.1n 278.3n ~ 0.100
SubtreeProcessorRotate/1024_per_subtree-4 279.3n 278.5n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 55.09n 54.80n ~ 0.200
SubtreeNodeAddOnly/64_per_subtree-4 36.11n 36.01n ~ 0.400
SubtreeNodeAddOnly/256_per_subtree-4 35.11n 34.99n ~ 0.100
SubtreeNodeAddOnly/1024_per_subtree-4 34.53n 34.36n ~ 0.100
SubtreeCreationOnly/4_per_subtree-4 109.4n 110.5n ~ 0.400
SubtreeCreationOnly/64_per_subtree-4 348.0n 348.5n ~ 0.700
SubtreeCreationOnly/256_per_subtree-4 1.223µ 1.234µ ~ 0.100
SubtreeCreationOnly/1024_per_subtree-4 3.802µ 3.799µ ~ 0.700
SubtreeCreationOnly/2048_per_subtree-4 6.740µ 6.770µ ~ 0.100
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 276.5n 277.3n ~ 0.500
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 276.6n 278.4n ~ 0.100
ParallelGetAndSetIfNotExists/1k_nodes-4 1.993m 1.996m ~ 1.000
ParallelGetAndSetIfNotExists/10k_nodes-4 5.194m 5.291m ~ 0.100
ParallelGetAndSetIfNotExists/50k_nodes-4 7.083m 7.246m ~ 0.200
ParallelGetAndSetIfNotExists/100k_nodes-4 9.678m 9.717m ~ 1.000
SequentialGetAndSetIfNotExists/1k_nodes-4 1.765m 1.764m ~ 0.400
SequentialGetAndSetIfNotExists/10k_nodes-4 4.401m 4.437m ~ 0.700
SequentialGetAndSetIfNotExists/50k_nodes-4 13.43m 13.64m ~ 0.100
SequentialGetAndSetIfNotExists/100k_nodes-4 24.91m 25.12m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 2.057m 2.040m ~ 1.000
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 8.404m 8.414m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 13.21m 13.23m ~ 0.400
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 1.806m 1.800m ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 8.036m 8.037m ~ 1.000
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 43.44m 43.44m ~ 1.000
DiskTxMap_SetIfNotExists-4 3.835µ 3.773µ ~ 0.700
DiskTxMap_SetIfNotExists_Parallel-4 3.543µ 3.489µ ~ 0.700
DiskTxMap_ExistenceOnly-4 326.7n 341.1n ~ 0.100
Queue-4 185.7n 190.5n ~ 0.700
AtomicPointer-4 3.727n 3.672n ~ 1.000
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 843.9µ 880.7µ ~ 0.200
ReorgOptimizations/DedupFilterPipeline/New/10K-4 782.6µ 805.3µ ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/10K-4 106.5µ 106.0µ ~ 1.000
ReorgOptimizations/AllMarkFalse/New/10K-4 64.05µ 64.34µ ~ 0.700
ReorgOptimizations/HashSlicePool/Old/10K-4 55.88µ 59.44µ ~ 0.700
ReorgOptimizations/HashSlicePool/New/10K-4 11.28µ 10.91µ ~ 0.100
ReorgOptimizations/NodeFlags/Old/10K-4 4.506µ 4.553µ ~ 1.000
ReorgOptimizations/NodeFlags/New/10K-4 1.498µ 1.518µ ~ 0.200
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 9.750m 9.758m ~ 0.700
ReorgOptimizations/DedupFilterPipeline/New/100K-4 10.54m 10.05m ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.215m 1.171m ~ 0.700
ReorgOptimizations/AllMarkFalse/New/100K-4 706.6µ 706.5µ ~ 0.700
ReorgOptimizations/HashSlicePool/Old/100K-4 560.4µ 576.3µ ~ 0.700
ReorgOptimizations/HashSlicePool/New/100K-4 215.5µ 213.0µ ~ 0.700
ReorgOptimizations/NodeFlags/Old/100K-4 48.45µ 50.27µ ~ 0.200
ReorgOptimizations/NodeFlags/New/100K-4 17.06µ 17.61µ ~ 0.100
TxMapSetIfNotExists-4 49.67n 49.62n ~ 0.700
TxMapSetIfNotExistsDuplicate-4 41.31n 41.19n ~ 0.100
ChannelSendReceive-4 632.0n 608.2n ~ 0.100
CalcBlockWork-4 509.0n 516.1n ~ 0.700
CalculateWork-4 702.0n 714.1n ~ 0.200
BuildBlockLocatorString_Helpers/Size_10-4 1.487µ 1.627µ ~ 1.000
BuildBlockLocatorString_Helpers/Size_100-4 13.17µ 12.95µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_1000-4 128.1µ 128.2µ ~ 1.000
CatchupWithHeaderCache-4 104.4m 104.6m ~ 0.700
_prepareTxsPerLevel-4 406.8m 409.3m ~ 1.000
_prepareTxsPerLevelOrdered-4 3.671m 3.417m ~ 0.400
_prepareTxsPerLevel_Comparison/Original-4 413.1m 411.5m ~ 0.700
_prepareTxsPerLevel_Comparison/Optimized-4 3.532m 3.420m ~ 0.100
SubtreeSizes/10k_tx_4_per_subtree-4 1.367m 1.360m ~ 0.700
SubtreeSizes/10k_tx_16_per_subtree-4 321.4µ 323.0µ ~ 1.000
SubtreeSizes/10k_tx_64_per_subtree-4 77.48µ 76.55µ ~ 0.400
SubtreeSizes/10k_tx_256_per_subtree-4 19.50µ 19.23µ ~ 0.400
SubtreeSizes/10k_tx_512_per_subtree-4 9.650µ 9.517µ ~ 0.600
SubtreeSizes/10k_tx_1024_per_subtree-4 4.786µ 4.755µ ~ 0.400
SubtreeSizes/10k_tx_2k_per_subtree-4 2.391µ 2.363µ ~ 0.100
BlockSizeScaling/10k_tx_64_per_subtree-4 77.59µ 75.56µ ~ 0.100
BlockSizeScaling/10k_tx_256_per_subtree-4 19.35µ 19.10µ ~ 0.100
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.779µ 4.750µ ~ 0.400
BlockSizeScaling/50k_tx_64_per_subtree-4 398.8µ 395.4µ ~ 0.100
BlockSizeScaling/50k_tx_256_per_subtree-4 96.42µ 96.20µ ~ 0.400
BlockSizeScaling/50k_tx_1024_per_subtree-4 23.77µ 23.44µ ~ 0.100
SubtreeAllocations/small_subtrees_exists_check-4 162.3µ 158.4µ ~ 0.100
SubtreeAllocations/small_subtrees_data_fetch-4 168.4µ 167.4µ ~ 0.400
SubtreeAllocations/small_subtrees_full_validation-4 332.5µ 327.3µ ~ 0.100
SubtreeAllocations/medium_subtrees_exists_check-4 9.621µ 9.377µ ~ 0.200
SubtreeAllocations/medium_subtrees_data_fetch-4 10.029µ 9.787µ ~ 0.100
SubtreeAllocations/medium_subtrees_full_validation-4 19.35µ 19.21µ ~ 0.700
SubtreeAllocations/large_subtrees_exists_check-4 2.298µ 2.257µ ~ 0.100
SubtreeAllocations/large_subtrees_data_fetch-4 2.439µ 2.386µ ~ 0.100
SubtreeAllocations/large_subtrees_full_validation-4 4.866µ 4.788µ ~ 0.100
_BufferPoolAllocation/16KB-4 4.086µ 4.041µ ~ 0.200
_BufferPoolAllocation/32KB-4 8.788µ 10.111µ ~ 1.000
_BufferPoolAllocation/64KB-4 17.75µ 16.63µ ~ 0.100
_BufferPoolAllocation/128KB-4 37.91µ 33.61µ ~ 0.100
_BufferPoolAllocation/512KB-4 143.1µ 116.0µ ~ 0.100
_BufferPoolConcurrent/32KB-4 23.68µ 20.12µ ~ 0.100
_BufferPoolConcurrent/64KB-4 33.58µ 32.49µ ~ 0.700
_BufferPoolConcurrent/512KB-4 150.3µ 157.8µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/16KB-4 652.8µ 726.5µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 645.1µ 658.5µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/64KB-4 641.1µ 740.0µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/128KB-4 645.1µ 678.7µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 633.3µ 605.8µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 37.08m 37.43m ~ 0.700
_SubtreeDataDeserializationWithBufferSizes/32KB-4 37.40m 37.25m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/64KB-4 37.23m 37.28m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/128KB-4 37.07m 36.66m ~ 0.200
_SubtreeDataDeserializationWithBufferSizes/512KB-4 37.07m 36.94m ~ 0.400
_PooledVsNonPooled/Pooled-4 833.8n 830.7n ~ 0.400
_PooledVsNonPooled/NonPooled-4 8.962µ 8.074µ ~ 0.100
_MemoryFootprint/Current_512KB_32concurrent-4 8.295µ 7.635µ ~ 0.100
_MemoryFootprint/Proposed_32KB_32concurrent-4 11.26µ 10.05µ ~ 0.100
_MemoryFootprint/Alternative_64KB_32concurrent-4 10.736µ 9.381µ ~ 0.100
StoreBlock_Sequential/BelowCSVHeight-4 334.7µ 325.7µ ~ 0.100
StoreBlock_Sequential/AboveCSVHeight-4 325.3µ 326.6µ ~ 0.700
GetUtxoHashes-4 274.1n 283.2n ~ 0.400
GetUtxoHashes_ManyOutputs-4 45.61µ 46.86µ ~ 0.700
_NewMetaDataFromBytes-4 213.7n 216.7n ~ 0.100
_Bytes-4 402.4n 400.4n ~ 0.800
_MetaBytes-4 140.6n 140.4n ~ 0.700

Threshold: >10% with p < 0.05 | Generated: 2026-06-01 15:37 UTC

…length

The UTXO-set wrapper deserializer pre-allocated from length/count fields
read straight out of the file, with no bounds check:

  - FromReader:           make([]*UTXO, numUTXOs)
  - NewUTXOFromReader:    make([]byte, scriptLen)
  - NewUTXOValueFromReader: make([]byte, scriptLen) (via reusableScript)

A corrupt or truncated .utxo-set whose header claims numUTXOs=0xFFFFFFFF
or scriptLen=0xFFFFFFFF forces a multi-GB allocation before the follow-up
read fails — a cheap OOM that takes down the core sidecar via the
ServiceManager errgroup, the same crash class as the recently-fixed
read-path bugs. recover() does not save an OOM.

Bound the allocations to the bytes actually available:
  - cap the speculative []*UTXO capacity (min(numUTXOs, 1024)) and append
    as records are read, so a bogus count errors on the first missing
    record instead of pre-allocating;
  - read the script via io.CopyN, which streams in fixed-size chunks and
    stops at EOF, so a huge claimed length on a truncated record errors
    out instead of allocating; the value-only path discards via
    io.CopyN(io.Discard, ...) (allocation-free) and drops the now-unused
    reusableScript buffer.

Add a fuzz harness (FuzzUTXOWrapperFromBytes) that checks no panic and a
re-serialization round-trip invariant, plus a deterministic regression
test asserting a hostile header allocates < 4 MB (was ~256 MB / ~32 MB)
before erroring. Valid-file parsing is unchanged (existing roundtrip
tests still pass).
@liam liam force-pushed the liam/fuzz-utxopersister-deser branch from afbb971 to 03c3641 Compare June 1, 2026 15:22
@liam

liam commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — I dug into this, and the allocation is already bounded to the readable input; bytes.Buffer does not pre-allocate l.

io.CopyN(&buf, r, l) resolves to bytes.Buffer.ReadFrom(io.LimitReader(r, l)), and ReadFrom grows the buffer in fixed-size (512-byte MinRead) increments sized to the bytes actually read, returning as soon as the underlying reader hits EOF. It never sizes itself to the limit l.

Measured directly — a 56-byte input whose header claims a 0xFFFFFFFF (~4 GB) script:

input=56 bytes, claimed script len=0xFFFFFFFF (~4 GB),
err=STORAGE_ERROR (69): failed to read utxo script (4294967295 bytes) -> EOF,
allocated=1392 bytes

So ~1.4 KB, not 4 GB (or 32 MB) — which is exactly "only allocate what is actually readable from the stream." The deterministic TestUTXOWrapperFromBytes_HostileSizesAreBounded pins this (< 4 MB). The only case that allocates ~l is a script that genuinely contains l bytes, which is bounded by the real file size, not by the claim.

On the consensus-based maxScriptSize cap: BSV doesn't really have one to anchor to — post-Genesis removed the 520-byte push limit, the 10 KB script limit, MAX_OPS, etc., so script size is bounded only by configurable tx/block policy and can be very large. A fixed cap would risk rejecting valid large UTXOs. If we ever want defense-in-depth against a fully-materialized huge script, the right ceiling is the size of the containing blob (knowable where the reader is opened), not a magic constant — a separate change.

I've pushed a comment on the io.CopyN line documenting the ReadFrom growth behavior so this doesn't trip up future readers. Holding off on a cap unless you'd prefer otherwise.

@sonarqubecloud

sonarqubecloud Bot commented Jun 1, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
74.1% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@liam liam requested review from ordishs and oskarszoon June 1, 2026 16:06

@ordishs ordishs left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve. Correct, minimally scoped fix for a real OOM/DoS vector in the .utxo-set deserializer, with strong test coverage.

Verified against the base code and I/O semantics:

  • UTXO slice: make([]*UTXO, 0, min(numUTXOs, 1024)) + append errors on the first missing record instead of pre-sizing.
  • Script read: io.CopyN(&bytes.Buffer, r, l) dispatches to bytes.Buffer.ReadFrom, which grows incrementally and stops at EOF — never pre-sizes to l; truncation yields a wrapped StorageError.
  • Value path: io.CopyN(io.Discard, r, l) is allocation-free via the pooled ReaderFrom; reusableScript cleanly removed (no other refs).
  • EOF-marker stream semantics and callers unaffected by the error-type change.

Tests are solid: the regression test fails on the old code (~268MB/~33MB) and passes after, runs deterministically in CI without -fuzz; the round-trip-prefix fuzz invariant genuinely holds.

Non-blocking notes:

  1. Read path retains bytes.Buffer's backing array (ReadFrom may grow ~2x and won't trim) vs the old exact make([]byte, l) — bounded by real file size, but a slices.Clip would keep peak RSS tight on full-set loads.
  2. totalAllocDelta reads process-global runtime.TotalAlloc, not goroutine-scoped; safe here given the 4MB headroom.

@liam liam requested a review from sugh01 June 2, 2026 08:49
@liam liam merged commit 2ec45a6 into bsv-blockchain:main Jun 3, 2026
46 of 47 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants