Skip to content

Validate snapshots before publication - #4036

Merged
masih merged 6 commits into
mainfrom
masih/harden-snapshot-hash-check
Aug 28, 2026
Merged

Validate snapshots before publication#4036
masih merged 6 commits into
mainfrom
masih/harden-snapshot-hash-check

Conversation

@masih

@masih masih commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Reject malformed node hashes before writing snapshot records and validate
completed snapshots before updating the current symlink. Add regression
coverage for partial leaf records and corrupt snapshot publication.

Reject malformed node hashes before writing snapshot records and validate
completed snapshots before updating the current symlink. Add regression
coverage for partial leaf records and corrupt snapshot publication.
@masih
masih marked this pull request as ready for review August 27, 2026 17:05
@cursor

cursor Bot commented Aug 27, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes core state persistence: snapshot validation, symlink updates, and startup behavior can affect node recovery after crashes or partial writes; incorrect handling could strand or reset chain state.

Overview
Hardens MemIAVL snapshot publication and rewrite so the current symlink only moves after a loadable snapshot matches lastCommitInfo (multi-tree version, per-store version, and root hashes). Structural load failures are tagged with errCorruptedSnapshot; cancellation, permissions, and other environmental errors do not trigger deleting or replacing an existing snapshot directory.

Rewrite / publish flow: RewriteSnapshot validates an existing height directory before skipping; corrupt dirs are rewritten only after a validated temp write via publishSnapshot. Rename conflicts adopt a valid target or replace a corrupt one; failed writes use cleanupFailedSnapshotRewrite.

Load path: Metadata and mmap layout checks (readMetadata, OpenSnapshot, native NewNodes/NewLeaves) return corruption errors instead of panicking or nil deref. LoadMultiTree closes partially opened trees on failure. Startup: createDBIfNotExist refuses to initialize an empty DB when current points at a missing snapshot.

Write path: Snapshot writers reject leaf/branch hashes that are not 32 bytes. Regression tests cover corrupt publication, skewed stores, dangling current, and environmental vs corruption validation boundaries.

Reviewed by Cursor Bugbot for commit 1c45d68. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedAug 28, 2026, 2:20 PM

@masih
masih requested review from blindchaser and yzang2019 August 27, 2026 17:07
@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 71.92982% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.55%. Comparing base (17cac18) to head (1c45d68).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
sei-db/state_db/sc/memiavl/db.go 74.71% 12 Missing and 10 partials ⚠️
sei-db/state_db/sc/memiavl/multitree.go 61.53% 3 Missing and 2 partials ⚠️
sei-db/state_db/sc/memiavl/snapshot.go 64.28% 5 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4036      +/-   ##
==========================================
- Coverage   61.26%   60.55%   -0.72%     
==========================================
  Files        2153     2073      -80     
  Lines      188485   179854    -8631     
==========================================
- Hits       115477   108907    -6570     
+ Misses      62265    60930    -1335     
+ Partials    10743    10017     -726     
Flag Coverage Δ
sei-db 69.80% <ø> (ø)
sei-db-state-db ?
sei-db-state-db-pr 77.25% <71.92%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
sei-db/state_db/sc/memiavl/multitree.go 80.99% <61.53%> (+1.35%) ⬆️
sei-db/state_db/sc/memiavl/snapshot.go 67.84% <64.28%> (+2.08%) ⬆️
sei-db/state_db/sc/memiavl/db.go 70.26% <74.71%> (+4.26%) ⬆️

... and 110 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@masih masih added backport release/v6.6 Backport to release v6.6 backport release/v6.7 Backport to release v6.7 labels Aug 27, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Adds a structural validation pass before a snapshot is published via the current symlink, plus hash-length guards in the snapshot writer — a solid defensive change with reasonable regression coverage. No blockers; the notes below concern how far the validation actually goes and how an already-corrupt snapshot directory is recovered from.

Findings: 0 blocking | 4 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • [suggestion] The new tests cover only the temp-candidate path (publishSnapshot on a corrupted tmp dir) and the two hash-length guards. Neither of the branches that adopt an already-existing snapshot-<h> directory — the early skip in RewriteSnapshot (db.go:774) and the rename-conflict reuse in publishSnapshot (db.go:823) — is exercised. Those are the paths where validation changes behaviour from "return nil" to "return error", so they are the ones most worth pinning.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.
  • 1 non-blocking pre-existing issue(s) listed below under pre-existing issues.

Pre-existing issues

  • [suggestion] LoadMultiTree (sei-db/state_db/sc/memiavl/multitree.go:103) returns immediately when OpenSnapshot fails for one store, without closing the snapshots already opened for earlier stores. OpenSnapshot cleans up its own partial handles, but the completed ones in treeMap are leaked (mmaps + fds) for the process lifetime. This existed before the PR, but validateSnapshot now routes a recurring background operation through that error path, so a persistently corrupt multi-store snapshot leaks a mapping set per rewrite attempt rather than once at startup.

Comment thread sei-db/state_db/sc/memiavl/db.go Outdated
Comment thread sei-db/state_db/sc/memiavl/db.go Outdated
func (db *DB) validateSnapshot(ctx context.Context, path string) error {
opts := db.opts
opts.SnapshotPrefetchThreshold = 0
mtree, err := LoadMultiTree(ctx, path, opts)

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.

LoadMultiTree leaks mmaps and fds on partial failure. multitree.go:103 returns nil, err without closing the snapshots it already opened for earlier stores. This is a pre-existing issue, but this PR makes it newly reachable: every validation failure now leaks fds

if !ok {
// channel was closed without sending a result
// Still prune old snapshots to prevent accumulation
go db.pruneSnapshots()

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.

I think we added that because there were edge causes causing snapshot not being pruned properly and getting accumulated, causing disk fililng up

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.

In the latest main, I think we already made the change so that we will make sure to keep at least 2 memIAVL snapshots

@masih

masih commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

@seidroid review

seidroid[bot]
seidroid Bot previously requested changes Aug 28, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The PR closes both notes from the previous review — validateSnapshot now compares version and per-store root hashes against lastCommitInfo, the adoption branches are covered by tests, and the LoadMultiTree handle leak is fixed. Two problems remain in the new recovery path: any validation failure (including a cancelled context at shutdown) now deletes an existing snapshot directory, and an unparseable-but-protobuf-valid __metadata panics instead of failing validation.

Findings: 2 blocking | 1 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.
  • 2 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • [suggestion] validateSnapshot compares the multi-tree metadata version but not each loaded store's own snapshot version (entry.Version()). A directory whose __metadata says version N while a store subdirectory carries an older version with an unchanged root hash would validate clean, and the resulting per-tree version skew would corrupt subsequent version progression. The snapshot writer always advances every tree together, so this is only reachable through external tampering or filesystem-level mixing — but the check is a one-line addition to the loop that already walks mtree.Trees().

Comment thread sei-db/state_db/sc/memiavl/db.go Outdated
Comment thread sei-db/state_db/sc/memiavl/db.go
@masih

masih commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

@seidroid review

seidroid[bot]
seidroid Bot previously requested changes Aug 28, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The PR closes both blockers from the previous round — the deletion path is now gated on errCorruptedSnapshot and readMetadata rejects nil CommitInfo — but the new self-heal in RewriteSnapshot deletes an existing (possibly current-referenced) snapshot before its replacement exists, and the nil-CommitInfo guard leaves an adjacent negative-InitialVersion panic reachable from the same commit path.

Findings: 3 blocking | 3 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.
  • 2 blocking issue(s) flagged inline on specific lines.
  • 1 critical pre-existing blocker(s) listed below under pre-existing issues.

Non-blocking

  • [suggestion] errCorruptedSnapshot is now load-bearing — it decides between "delete and regenerate" and "halt the node" — but it is applied ad hoc across three files, and some structural errors on the same load path are still untagged: NewNodes/NewLeaves return bare errors.New("input buffer is not aligned") / "input buffer length is not correct" (layout_native.go:30, 34, 85, 89) and reach validateSnapshot as environmental failures. Worth one sweep over OpenSnapshot/readMetadata/NewNodes/NewLeaves so every "this data is bad" error carries the sentinel, and a note on the sentinel's declaration that new structural checks must join it.
  • [suggestion] The non-corruption branch in publishSnapshot (db.go:845-851) — an existing target whose validation fails for an environmental reason must be kept, not deleted — has no test, while the equivalent branch in RewriteSnapshot does (TestRewriteSnapshotKeepsSnapshotOnCancelledValidation). Since the two removals are duplicated logic, pinning only one leaves the other free to drift.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Pre-existing issues

  • [blocker] createDBIfNotExist (sei-db/state_db/sc/memiavl/db.go:1412) stats current/__metadata and, on any IsNotExist, calls initEmptyDB, which writes a fresh empty snapshot-0 and repoints current at it. A dangling current symlink is indistinguishable from a never-initialized directory, so a node whose snapshot target went missing silently comes up with an empty state-commitment store at version 0 instead of failing to start. Production uses CreateIfMissing: true (store.go:29). Distinguishing "no current link at all" from "link present but target missing" would turn this into a loud startup error.

Comment thread sei-db/state_db/sc/memiavl/db.go Outdated
Comment thread sei-db/state_db/sc/memiavl/multitree.go
Comment thread sei-db/state_db/sc/memiavl/multitree.go Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1c45d68. Configure here.

logger.Debug("temporary snapshot directory cleaned up successfully",
"tmpDir", tmpDir,
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Replace path can drop snapshot

Medium Severity

Leaving a corrupted snapshot in place until rewrite now routes replacement through publishSnapshot, which still RemoveAlls the target before renaming the validated temp. If that rename fails, cleanupFailedSnapshotRewrite deletes the temp too. When current pointed at the removed directory, the new dangling-link check then refuses to open the db, so the node cannot start.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1c45d68. Configure here.

@masih

masih commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

@seidroid review

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All three unresolved findings from the previous round are genuinely closed: the premature deletion in RewriteSnapshot is gone and pinned by a test, readMetadata now rejects negative InitialVersion, and the structural errors on the load path all carry errCorruptedSnapshot. One narrowing remains in publishSnapshot's corrupt-target replacement ordering, plus two pre-existing gaps in paths that publish current without validation.

Findings: 0 blocking | 3 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • None at the file/PR level.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.
  • 2 non-blocking pre-existing issue(s) listed below under pre-existing issues.

Pre-existing issues

  • [suggestion] RewriteSnapshot's adopt-and-skip branch (sei-db/state_db/sc/memiavl/db.go:789-793) returns nil without calling updateCurrentSymlink, while the equivalent adoption branch in publishSnapshot (db.go:867-873) does update it. If a prior rewrite renamed the temp into snapshot-<V> but died before the symlink update, a retry at the same version validates the directory, skips, and leaves current on the older snapshot — and the background goroutine then reloads that older snapshot from currentPath. Impact is limited because the next rewrite triggers at a different version, but the two adoption branches now sit side by side with different endings.
  • [suggestion] MultiTreeImporter.Close (sei-db/state_db/sc/memiavl/import.go:125-148) adopts a pre-existing snapshot-<h> directory and then publishes it via updateCurrentSymlink, with no validation. That is the same "adopt a directory this call did not write" shape the PR added validateSnapshot for on the rewrite path, so the state-sync restore path still updates current to point at an unverified directory. Routing it through an equivalent check would close the remaining unvalidated publication path.

"path", targetPath,
"error", validationErr,
)
if rmErr := os.RemoveAll(targetPath); rmErr != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The corrupt target is removed before the validated temp takes its place, so targetPath does not exist for the duration of the RemoveAll — which, for a mainnet-sized store, is the time it takes to unlink every file in the snapshot. If the process dies in that window, or the os.Rename on line 862 fails, nothing remains at targetPath: the caller's cleanupFailedSnapshotRewrite then drops the validated temp as well, and removeTmpDirs would have cleared it at the next startup regardless. When current pointed at targetPath, it is left dangling, which createDBIfNotExist now turns into a hard startup failure requiring manual restore.

This is narrower than it looks — the branch only runs when the target already failed validation as corrupt, and on the base branch that same directory was silently adopted and published, so the PR is still a clear improvement here. But the atomic ordering is nearly free: rename the corrupt directory aside first, rename the temp into place, then remove the quarantined copy. That reduces the exposure to the gap between two instantaneous same-directory renames. Note that atomicRemoveDir cannot be reused as-is, since its path + "-tmp" quarantine name collides with path; a distinct -tmp-suffixed name still gets cleaned up by removeTmpDirs on the next open.

@seidroid
seidroid Bot dismissed stale reviews from themself August 28, 2026 14:45

Superseded: latest AI review found no blocking issues.

@masih
masih added this pull request to the merge queue Aug 28, 2026
Merged via the queue into main with commit f28da4c Aug 28, 2026
85 checks passed
@masih
masih deleted the masih/harden-snapshot-hash-check branch August 28, 2026 16:08
@seidroid

seidroid Bot commented Aug 28, 2026

Copy link
Copy Markdown

Created backport PR for release/v6.6:

Please cherry-pick the changes locally and resolve any conflicts.

git fetch origin backport-4036-to-release/v6.6
git worktree add --checkout .worktree/backport-4036-to-release/v6.6 backport-4036-to-release/v6.6
cd .worktree/backport-4036-to-release/v6.6
git reset --hard HEAD^
git cherry-pick -x f28da4c23213808d751969e99a2ee3ccf41f8473
git push --force-with-lease

@seidroid

seidroid Bot commented Aug 28, 2026

Copy link
Copy Markdown

Successfully created backport PR for release/v6.7:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport release/v6.6 Backport to release v6.6 backport release/v6.7 Backport to release v6.7 non-app-hash-breaking

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants