diff --git a/.github/workflows/build-rocksdb.yml b/.github/workflows/build-rocksdb.yml index 3d54d1f5..32228ad4 100644 --- a/.github/workflows/build-rocksdb.yml +++ b/.github/workflows/build-rocksdb.yml @@ -18,6 +18,42 @@ env: DOCKERFILE: Dockerfile.debank-rocksdb jobs: + build-statediff-rewriter: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Free disk space + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 + with: + tool-cache: true + android: true + dotnet: true + haskell: true + large-packages: false + swap-storage: false + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Build rewriter image without pushing + uses: docker/build-push-action@v6 + with: + file: Dockerfile.statediff-rewriter + context: . + push: false + load: true + platforms: linux/amd64 + tags: cronos-statediff-rewriter:pr-${{ github.event.pull_request.number }} + build-args: | + GIT_COMMIT=${{ github.sha }} + provenance: false + - name: Verify rewriter runtime and provenance + run: | + image="cronos-statediff-rewriter:pr-${{ github.event.pull_request.number }}" + test "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "${image}")" = "${{ github.sha }}" + test "$(docker run --rm "${image}" --version)" = "${{ github.sha }}" + docker run --rm "${image}" --help + build-rocksdb: strategy: fail-fast: false @@ -33,7 +69,7 @@ jobs: id-token: write steps: - name: Free disk space - uses: jlumbroso/free-disk-space@main + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 with: tool-cache: true android: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e6d41fe..5ee83fdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Improvements +* [Chaintable/cronos#3](https://github.com/Chaintable/cronos/pull/3) feat: add fail-closed tooling for canonical historical state-diff recovery. * [#2095](https://github.com/crypto-org-chain/cronos/pull/2095) feat: add hardcoded unblockable address list that is filtered out of both the mempool and consensus blocklists. ### Bug fixes diff --git a/Dockerfile.statediff-rewriter b/Dockerfile.statediff-rewriter new file mode 100644 index 00000000..87cae8f9 --- /dev/null +++ b/Dockerfile.statediff-rewriter @@ -0,0 +1,35 @@ +FROM golang:1.25-bookworm AS go-builder + +RUN apt-get update && \ + apt-get install -y libgflags-dev libsnappy-dev zlib1g-dev libbz2-dev liblz4-dev libzstd-dev && \ + rm -rf /var/lib/apt/lists/* + +RUN git clone https://github.com/facebook/rocksdb.git /tmp/rocksdb && \ + cd /tmp/rocksdb && \ + git checkout v10.5.1 && \ + PORTABLE=1 DISABLE_WARNING_AS_ERROR=1 make -j$(nproc) install-shared && \ + ldconfig && \ + rm -rf /tmp/rocksdb + +WORKDIR /code +COPY . /code/ +ARG GIT_COMMIT +RUN test -n "${GIT_COMMIT}" && \ + GOPRIVATE='github.com/Chaintable/*' make build-statediff-rewriter COMMIT="${GIT_COMMIT}" + +FROM ubuntu:22.04 + +ARG GIT_COMMIT +LABEL org.opencontainers.image.revision="${GIT_COMMIT}" + +RUN apt-get update && \ + apt-get install -y libsnappy1v5 libgflags2.2 zlib1g libbz2-1.0 liblz4-1 libzstd1 ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=go-builder /usr/local/lib/ /usr/local/lib/ +RUN ldconfig +COPY --from=go-builder /code/build/statediff-rewriter /usr/bin/statediff-rewriter + +WORKDIR /work +ENTRYPOINT ["/usr/bin/statediff-rewriter"] +CMD ["--help"] diff --git a/Makefile b/Makefile index c8f7885f..83939c35 100644 --- a/Makefile +++ b/Makefile @@ -93,26 +93,32 @@ build_tags_comma_sep := $(subst $(whitespace),$(comma),$(build_tags)) ldflags += -X github.com/cosmos/cosmos-sdk/version.Name=cronos \ -X github.com/cosmos/cosmos-sdk/version.AppName=cronosd \ -X github.com/cosmos/cosmos-sdk/version.Version=$(VERSION) \ - -X github.com/cosmos/cosmos-sdk/version.Commit=$(COMMIT) \ - -X github.com/cosmos/cosmos-sdk/version.BuildTags=$(build_tags_comma_sep) + -X github.com/cosmos/cosmos-sdk/version.Commit=$(COMMIT) -BUILD_FLAGS := -tags "$(build_tags)" -ldflags '$(ldflags)' +BUILD_FLAGS := -tags "$(build_tags)" -ldflags '$(ldflags) -X github.com/cosmos/cosmos-sdk/version.BuildTags=$(build_tags_comma_sep)' +statediff_rewriter_build_tags := $(strip $(filter-out rocksdb grocksdb_clean_link,$(build_tags)) rocksdb grocksdb_clean_link) +statediff_rewriter_build_tags_comma_sep := $(subst $(whitespace),$(comma),$(statediff_rewriter_build_tags)) +STATEDIFF_REWRITER_BUILD_FLAGS := -tags "$(statediff_rewriter_build_tags)" -ldflags '$(ldflags) -X github.com/cosmos/cosmos-sdk/version.BuildTags=$(statediff_rewriter_build_tags_comma_sep)' # check for nostrip option ifeq (,$(findstring nostrip,$(COSMOS_BUILD_OPTIONS))) BUILD_FLAGS += -trimpath + STATEDIFF_REWRITER_BUILD_FLAGS += -trimpath endif all: build build: check-network print-ledger go.sum @go build -mod=readonly $(BUILD_FLAGS) -o $(BUILDDIR)/cronosd ./cmd/cronosd +build-statediff-rewriter: check-network go.sum + @CGO_ENABLED=1 go build -mod=readonly $(STATEDIFF_REWRITER_BUILD_FLAGS) -o $(BUILDDIR)/statediff-rewriter ./cmd/statediff-rewriter + install: check-network print-ledger go.sum @go install -mod=readonly $(BUILD_FLAGS) ./cmd/cronosd test: @go test -tags=objstore -v -mod=readonly $(PACKAGES) -coverprofile=$(COVERAGE) -covermode=atomic -.PHONY: all clean build install test +.PHONY: all clean build build-statediff-rewriter install test clean: rm -rf $(BUILDDIR)/ diff --git a/cmd/cronosd/cmd/rollback_to_version.go b/cmd/cronosd/cmd/rollback_to_version.go index de9f0e38..15c4bb3c 100644 --- a/cmd/cronosd/cmd/rollback_to_version.go +++ b/cmd/cronosd/cmd/rollback_to_version.go @@ -5,11 +5,11 @@ import ( "strconv" dbm "github.com/cosmos/cosmos-db" - "github.com/cosmos/cosmos-sdk/server" - "github.com/spf13/cobra" - "github.com/crypto-org-chain/cronos/app" "github.com/crypto-org-chain/cronos/cmd/cronosd/opendb" + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/server" ) // RollbackToVersionCmd force-rolls the application multistore back to an explicit diff --git a/cmd/statediff-rewriter/archive.go b/cmd/statediff-rewriter/archive.go new file mode 100644 index 00000000..986906a4 --- /dev/null +++ b/cmd/statediff-rewriter/archive.go @@ -0,0 +1,98 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + dbm "github.com/cosmos/cosmos-db" + "github.com/cosmos/iavl" + iavldb "github.com/cosmos/iavl/db" + "github.com/crypto-org-chain/cronos/cmd/cronosd/opendb" + + "cosmossdk.io/log" + "cosmossdk.io/store/metrics" + "cosmossdk.io/store/rootmulti" + storetypes "cosmossdk.io/store/types" + "cosmossdk.io/store/wrapper" +) + +type archiveReader struct { + home string + db dbm.DB + store *rootmulti.Store +} + +func openArchive(home string) (*archiveReader, error) { + if !rocksDBBuild { + return nil, fmt.Errorf("statediff-rewriter requires build tags rocksdb and grocksdb_clean_link") + } + databasePath := filepath.Join(home, "data", "application.db") + if _, err := requireReadOnlyFilesystem(databasePath, "archive database"); err != nil { + return nil, err + } + db, err := opendb.OpenReadOnlyDB(home, dbm.RocksDBBackend) + if err != nil { + return nil, err + } + return &archiveReader{home: home, db: db, store: rootmulti.NewStore(db, log.NewNopLogger(), metrics.NewNoOpMetrics())}, nil +} + +func (a *archiveReader) Close() error { return a.db.Close() } + +func (a *archiveReader) evmIAVLDB() iavldb.DB { + return wrapper.NewDBWrapper(dbm.NewPrefixDB(a.db, []byte(evmIAVLPrefix))) +} + +func (a *archiveReader) evmStateChangeSource(cacheSize int) stateChangeSource { + return iavl.NewImmutableTree(a.evmIAVLDB(), cacheSize, true, log.NewNopLogger()) +} + +func (a *archiveReader) evmLegacyLatestVersion() (int64, error) { + return legacyLatestRootVersion(a.evmIAVLDB()) +} + +func (a *archiveReader) commitInfo(version int64) (*storetypes.CommitInfo, error) { + info, err := a.store.GetCommitInfo(version) + if err != nil { + return nil, fmt.Errorf("read commit info %d: %w", version, err) + } + if info.Version != version { + return nil, fmt.Errorf("commit info %d has version %d", version, info.Version) + } + return info, nil +} + +func (a *archiveReader) identity() (archiveIdentity, error) { + databaseIdentity, err := readRocksDBIdentity(a.home) + if err != nil { + return archiveIdentity{}, err + } + latest := rootmulti.GetLatestVersion(a.db) + info, err := a.commitInfo(latest) + if err != nil { + return archiveIdentity{}, err + } + return archiveIdentity{ + Home: a.home, DatabaseIdentity: databaseIdentity, + LatestVersion: latest, FinalCommitHash: commonHash(info.Hash()), + }, nil +} + +func readRocksDBIdentity(home string) (string, error) { + path := filepath.Join(home, "data", "application.db", "IDENTITY") + body, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read RocksDB identity %s: %w", path, err) + } + fields := strings.Fields(string(body)) + if len(fields) != 1 { + return "", fmt.Errorf("RocksDB identity %s must contain exactly one value", path) + } + return fields[0], nil +} + +func commonHash(body []byte) string { + return fmt.Sprintf("0x%x", body) +} diff --git a/cmd/statediff-rewriter/archive_test.go b/cmd/statediff-rewriter/archive_test.go new file mode 100644 index 00000000..45c9a7ed --- /dev/null +++ b/cmd/statediff-rewriter/archive_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReadRocksDBIdentity(t *testing.T) { + home := t.TempDir() + dir := filepath.Join(home, "data", "application.db") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "IDENTITY"), []byte("db-123\n"), 0o600)) + + identity, err := readRocksDBIdentity(home) + require.NoError(t, err) + require.Equal(t, "db-123", identity) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "IDENTITY"), []byte("db-123\ndb-456\n"), 0o600)) + _, err = readRocksDBIdentity(home) + require.ErrorContains(t, err, "exactly one value") +} diff --git a/cmd/statediff-rewriter/artifact_safety.go b/cmd/statediff-rewriter/artifact_safety.go new file mode 100644 index 00000000..662d1a95 --- /dev/null +++ b/cmd/statediff-rewriter/artifact_safety.go @@ -0,0 +1,231 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "syscall" +) + +type filesystemStatus struct { + Device uint64 + ReadOnly bool +} + +var inspectArtifactFilesystem = statArtifactFilesystem + +func statArtifactFilesystem(path string) (filesystemStatus, error) { + var filesystem syscall.Statfs_t + if err := syscall.Statfs(path, &filesystem); err != nil { + return filesystemStatus{}, err + } + info, err := os.Stat(path) + if err != nil { + return filesystemStatus{}, err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return filesystemStatus{}, fmt.Errorf("stat %s returned an unsupported file identity", path) + } + return filesystemStatus{Device: uint64(stat.Dev), ReadOnly: uint64(filesystem.Flags)&1 != 0}, nil +} + +func requireReadOnlyFilesystem(path, label string) (filesystemStatus, error) { + status, err := inspectArtifactFilesystem(path) + if err != nil { + return filesystemStatus{}, fmt.Errorf("inspect %s filesystem: %w", label, err) + } + if !status.ReadOnly { + return filesystemStatus{}, fmt.Errorf("%s filesystem must be mounted read-only", label) + } + return status, nil +} + +func requireRegularFile(path, label string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s must be a regular non-symlink file: %s", label, path) + } + return nil +} + +func requireSealedPlanDirectory(path string) (string, error) { + dir := filepath.Clean(filepath.Dir(path)) + if filepath.Base(path) != "manifest.v1.json" || !filepath.IsLocal(filepath.Base(path)) { + return "", fmt.Errorf("sealed plan manifest must be named manifest.v1.json") + } + if filepath.Clean(path) != filepath.Join(dir, "manifest.v1.json") { + return "", fmt.Errorf("sealed plan manifest must be directly inside its plan directory") + } + if filepath.Ext(dir) != ".sealed" { + return "", fmt.Errorf("manifest is not inside a sealed plan directory") + } + info, err := os.Lstat(dir) + if err != nil { + return "", err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("sealed plan path must be a non-symlink directory: %s", dir) + } + if err := requireRegularFile(path, "sealed plan manifest"); err != nil { + return "", err + } + return dir, nil +} + +func requireDumpDirectory(path, suffix, label string) (string, error) { + dir, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + return "", err + } + if filepath.Ext(dir) != suffix { + return "", fmt.Errorf("%s path must end in %s", label, suffix) + } + info, err := os.Lstat(dir) + if err != nil { + return "", err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("%s path must be a non-symlink directory: %s", label, dir) + } + dir, err = filepath.EvalSymlinks(dir) + if err != nil { + return "", err + } + evmDir := filepath.Join(dir, "evm") + evmInfo, err := os.Lstat(evmDir) + if err != nil { + return "", err + } + if !evmInfo.IsDir() || evmInfo.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("%s evm path must be a non-symlink directory: %s", label, evmDir) + } + return dir, nil +} + +func requireSealedDumpDirectory(path string) (string, error) { + dir, err := requireDumpDirectory(path, ".sealed", "sealed dump") + if err != nil { + return "", err + } + if err := requireRegularFile(filepath.Join(dir, "dump-manifest.v1.json"), "sealed dump manifest"); err != nil { + return "", err + } + return dir, nil +} + +func requireDumpArtifact(dir, name string) (string, error) { + base := filepath.Base(name) + if base == "." || !filepath.IsLocal(base) || name != filepath.Join("evm", base) { + return "", fmt.Errorf("dump file path must be evm/: %q", name) + } + path := filepath.Join(dir, name) + if err := requireRegularFile(path, "dump file"); err != nil { + return "", err + } + return path, nil +} + +func requireReadOnlySealedDump(path string, manifest dumpManifest) (string, error) { + dir, err := requireSealedDumpDirectory(path) + if err != nil { + return "", err + } + root, err := requireReadOnlyFilesystem(dir, "sealed dump") + if err != nil { + return "", err + } + artifacts := []string{filepath.Join(dir, "evm"), filepath.Join(dir, "dump-manifest.v1.json")} + if manifest.SourceManifestSHA256 != "" { + source := filepath.Join(dir, dumpSourceFileName) + if err := requireRegularFile(source, "sealed dump source manifest"); err != nil { + return "", err + } + artifacts = append(artifacts, source) + } + for _, file := range manifest.Files { + artifact, err := requireDumpArtifact(dir, file.Path) + if err != nil { + return "", err + } + artifacts = append(artifacts, artifact) + } + for _, artifact := range artifacts { + status, err := requireReadOnlyFilesystem(artifact, "sealed dump artifact") + if err != nil { + return "", err + } + if status.Device != root.Device { + return "", fmt.Errorf("sealed dump artifact must be on the dump filesystem: %s", artifact) + } + } + return dir, nil +} + +func requireReadOnlyPlanManifest(dir string, root filesystemStatus) error { + path := filepath.Join(dir, "manifest.v1.json") + if err := requireRegularFile(path, "sealed plan manifest"); err != nil { + return err + } + status, err := requireReadOnlyFilesystem(path, "sealed plan manifest") + if err != nil { + return err + } + if status.Device != root.Device { + return fmt.Errorf("sealed plan manifest must be on the plan filesystem") + } + return nil +} + +func requireReadOnlyPlanArtifacts(dir string, manifest planManifest, root filesystemStatus) error { + if err := requireReadOnlyPlanManifest(dir, root); err != nil { + return err + } + names := []struct { + name string + label string + }{ + {manifest.HeightRootIndex, "height root index"}, + {manifest.RootIndex, "sorted root index"}, + } + for number, chunk := range manifest.Chunks { + names = append(names, + struct { + name string + label string + }{chunk.Pack, fmt.Sprintf("pack chunk %d", number)}, + struct { + name string + label string + }{chunk.Index, fmt.Sprintf("pack index %d", number)}, + ) + } + for _, artifact := range names { + path, err := requirePlanArtifact(dir, artifact.name, artifact.label) + if err != nil { + return err + } + status, err := requireReadOnlyFilesystem(path, artifact.label) + if err != nil { + return err + } + if status.Device != root.Device { + return fmt.Errorf("%s must be on the plan filesystem", artifact.label) + } + } + return nil +} + +func requirePlanArtifact(dir, name, label string) (string, error) { + if name == "" || name != filepath.Base(name) || name == "." || !filepath.IsLocal(name) { + return "", fmt.Errorf("%s path must be a basename inside the sealed plan: %q", label, name) + } + path := filepath.Join(dir, name) + if err := requireRegularFile(path, label); err != nil { + return "", err + } + return path, nil +} diff --git a/cmd/statediff-rewriter/artifact_safety_test.go b/cmd/statediff-rewriter/artifact_safety_test.go new file mode 100644 index 00000000..6be20241 --- /dev/null +++ b/cmd/statediff-rewriter/artifact_safety_test.go @@ -0,0 +1,321 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestStatArtifactFilesystemDetectsWritableTestVolume(t *testing.T) { + status, err := statArtifactFilesystem(t.TempDir()) + require.NoError(t, err) + require.False(t, status.ReadOnly) +} + +func TestLoadPlanManifestRejectsArtifactPathEscapeAndSymlinks(t *testing.T) { + t.Run("path escape", func(t *testing.T) { + manifestPath, manifest, _, _ := makeSealedPlanRecords(t, 1) + manifest.RootIndex = "../roots.sorted" + _, err := atomicJSON(manifestPath, manifest) + require.NoError(t, err) + _, _, err = loadPlanManifest(manifestPath) + require.ErrorContains(t, err, "path must be a basename") + }) + + t.Run("artifact symlink", func(t *testing.T) { + manifestPath, manifest, _, _ := makeSealedPlanRecords(t, 1) + rootPath := filepath.Join(filepath.Dir(manifestPath), manifest.RootIndex) + target := filepath.Join(t.TempDir(), "roots.sorted") + body, err := os.ReadFile(rootPath) + require.NoError(t, err) + require.NoError(t, os.WriteFile(target, body, 0o600)) + require.NoError(t, os.Remove(rootPath)) + require.NoError(t, os.Symlink(target, rootPath)) + _, _, err = loadPlanManifest(manifestPath) + require.ErrorContains(t, err, "regular non-symlink file") + }) + + t.Run("manifest symlink", func(t *testing.T) { + manifestPath, _, _, _ := makeSealedPlanRecords(t, 1) + target := filepath.Join(t.TempDir(), "manifest.v1.json") + body, err := os.ReadFile(manifestPath) + require.NoError(t, err) + require.NoError(t, os.WriteFile(target, body, 0o600)) + require.NoError(t, os.Remove(manifestPath)) + require.NoError(t, os.Symlink(target, manifestPath)) + _, _, err = loadPlanManifest(manifestPath) + require.ErrorContains(t, err, "regular non-symlink file") + }) +} + +func TestLoadPlanManifestProvesRootIndexesHaveSameMultiset(t *testing.T) { + manifestPath, manifest, _, _ := makeSealedPlanRecords(t, 2) + rootPath := filepath.Join(filepath.Dir(manifestPath), manifest.RootIndex) + writeRootIndex(t, rootPath, + rootRecord{Root: common.BytesToHash([]byte{10}), Height: 2}, + rootRecord{Root: common.BytesToHash([]byte{11}), Height: 3}, + ) + var err error + manifest.RootIndexSHA256, _, err = hashFile(rootPath) + require.NoError(t, err) + _, err = atomicJSON(manifestPath, manifest) + require.NoError(t, err) + _, _, err = loadPlanManifest(manifestPath) + require.ErrorContains(t, err, "sealed multiset") +} + +func TestLoadPlanManifestRejectsInvalidCountsAndChunkStructure(t *testing.T) { + tests := []struct { + name string + mutate func(*planManifest) + want string + }{ + { + name: "negative count", + mutate: func(manifest *planManifest) { + manifest.Changed, manifest.Unchanged, manifest.SkippedEqualRoot = 1, 1, -1 + }, + want: "block counts are inconsistent", + }, + { + name: "chunk number", + mutate: func(manifest *planManifest) { + manifest.Chunks[0].Number = 1 + }, + want: "pack chunk 0 metadata is invalid", + }, + { + name: "chunk record count", + mutate: func(manifest *planManifest) { + manifest.Chunks[0].Records++ + }, + want: "records exceed manifest changed count", + }, + { + name: "reused root artifact", + mutate: func(manifest *planManifest) { + manifest.RootIndex = manifest.HeightRootIndex + manifest.RootIndexSHA256 = manifest.HeightRootIndexSHA256 + }, + want: "manifest artifact", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + manifestPath, manifest, _, _ := makeSealedPlanRecords(t, 1) + test.mutate(&manifest) + _, err := atomicJSON(manifestPath, manifest) + require.NoError(t, err) + _, _, err = loadPlanManifest(manifestPath) + require.ErrorContains(t, err, test.want) + }) + } +} + +func TestSealedDumpRejectsEscapingAndSymlinkFiles(t *testing.T) { + t.Run("path escape", func(t *testing.T) { + _, manifest, _, _ := makeSealedPlanRecords(t, 1) + body, err := os.ReadFile(filepath.Join(manifest.DumpPath, "dump-manifest.v1.json")) + require.NoError(t, err) + var dump dumpManifest + require.NoError(t, decodeStrictJSON(body, &dump, "dump manifest")) + dump.Files[0].Path = "../outside.zz" + err = iterateSealedDump(manifest.DumpPath, dump, nil) + require.ErrorContains(t, err, "evm/") + }) + + t.Run("file symlink", func(t *testing.T) { + _, manifest, _, _ := makeSealedPlanRecords(t, 1) + body, err := os.ReadFile(filepath.Join(manifest.DumpPath, "dump-manifest.v1.json")) + require.NoError(t, err) + var dump dumpManifest + require.NoError(t, decodeStrictJSON(body, &dump, "dump manifest")) + path := filepath.Join(manifest.DumpPath, dump.Files[0].Path) + target := filepath.Join(t.TempDir(), "dump.zz") + fileBody, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(target, fileBody, 0o600)) + require.NoError(t, os.Remove(path)) + require.NoError(t, os.Symlink(target, path)) + err = iterateSealedDump(manifest.DumpPath, dump, nil) + require.ErrorContains(t, err, "regular non-symlink file") + }) +} + +func TestSealedArtifactSetsRejectRogueFiles(t *testing.T) { + t.Run("plan root", func(t *testing.T) { + manifestPath, _, _, _ := makeSealedPlanRecords(t, 1) + require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(manifestPath), "rogue.bin"), []byte("rogue"), 0o600)) + _, _, err := loadPlanManifest(manifestPath) + require.ErrorContains(t, err, "unexpected sealed plan artifact") + }) + + t.Run("invalid plan checkpoint", func(t *testing.T) { + manifestPath, _, _, _ := makeSealedPlanRecords(t, 1) + require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(manifestPath), "plan.checkpoint.json"), []byte("{}"), 0o600)) + _, _, err := loadPlanManifest(manifestPath) + require.ErrorContains(t, err, "plan checkpoint") + }) + + for _, location := range []string{"dump root", "dump evm"} { + t.Run(location, func(t *testing.T) { + _, plan, _, _ := makeSealedPlanRecords(t, 1) + body, err := os.ReadFile(filepath.Join(plan.DumpPath, "dump-manifest.v1.json")) + require.NoError(t, err) + var dump dumpManifest + require.NoError(t, decodeStrictJSON(body, &dump, "dump manifest")) + rogueDir := plan.DumpPath + if location == "dump evm" { + rogueDir = filepath.Join(rogueDir, "evm") + } + require.NoError(t, os.WriteFile(filepath.Join(rogueDir, "rogue.bin"), []byte("rogue"), 0o600)) + err = iterateSealedDump(plan.DumpPath, dump, nil) + require.ErrorContains(t, err, "artifact") + }) + } +} + +func TestWriteModesRequireReadOnlySealedPlan(t *testing.T) { + manifestPath, _, record, object := makeSealedPlan(t) + previous := inspectArtifactFilesystem + inspectArtifactFilesystem = func(string) (filesystemStatus, error) { + return filesystemStatus{Device: 1, ReadOnly: false}, nil + } + t.Cleanup(func() { inspectArtifactFilesystem = previous }) + store := &fakeObjectStore{objects: map[string]storedObject{record.Key: object}} + _, err := runWriteMode(context.Background(), manifestPath, filepath.Join(t.TempDir(), "rollback.json"), "", "rollback", store) + require.ErrorContains(t, err, "active sealed plan filesystem must be mounted read-only") + _, err = runVerify(context.Background(), manifestPath, filepath.Join(t.TempDir(), "verify"), store) + require.ErrorContains(t, err, "active sealed plan filesystem must be mounted read-only") + require.Zero(t, store.puts) +} + +func TestWriteModesRejectWritableOrForeignPlanArtifacts(t *testing.T) { + tests := []struct { + name string + status filesystemStatus + want string + }{ + {name: "writable child mount", status: filesystemStatus{Device: 1, ReadOnly: false}, want: "filesystem must be mounted read-only"}, + {name: "foreign child mount", status: filesystemStatus{Device: 2, ReadOnly: true}, want: "must be on the plan filesystem"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + manifestPath, manifest, record, object := makeSealedPlan(t) + previous := inspectArtifactFilesystem + inspectArtifactFilesystem = func(path string) (filesystemStatus, error) { + if filepath.Base(path) == manifest.Chunks[0].Pack { + return test.status, nil + } + return filesystemStatus{Device: 1, ReadOnly: true}, nil + } + t.Cleanup(func() { inspectArtifactFilesystem = previous }) + store := &fakeObjectStore{objects: map[string]storedObject{record.Key: object}} + _, err := runWriteMode( + context.Background(), manifestPath, filepath.Join(t.TempDir(), "rollback.json"), "", "rollback", store, + ) + require.ErrorContains(t, err, test.want) + require.Empty(t, store.gets) + require.Zero(t, store.puts) + }) + } +} + +func TestVerifyRequiresReadOnlySealedDumpBeforeS3(t *testing.T) { + manifestPath, manifest, record, object := makeSealedPlan(t) + previous := inspectArtifactFilesystem + inspectArtifactFilesystem = func(path string) (filesystemStatus, error) { + readOnly := path != manifest.DumpPath && !strings.HasPrefix(path, manifest.DumpPath+string(filepath.Separator)) + return filesystemStatus{Device: 1, ReadOnly: readOnly}, nil + } + t.Cleanup(func() { inspectArtifactFilesystem = previous }) + store := &fakeObjectStore{objects: map[string]storedObject{record.Key: object}} + _, err := runVerify(context.Background(), manifestPath, filepath.Join(t.TempDir(), "verify"), store) + require.ErrorContains(t, err, "sealed dump filesystem must be mounted read-only") + require.Empty(t, store.gets) + require.Zero(t, store.puts) +} + +func TestApplyRequiresIndependentReadOnlyBackupFilesystem(t *testing.T) { + manifestPath, _, record, object := makeSealedPlan(t) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + proofPath := writeBackupProof(t, manifestPath, manifestHash) + store := &fakeObjectStore{objects: map[string]storedObject{record.Key: object}} + + t.Run("same filesystem device", func(t *testing.T) { + previous := inspectArtifactFilesystem + inspectArtifactFilesystem = func(string) (filesystemStatus, error) { + return filesystemStatus{Device: 1, ReadOnly: true}, nil + } + defer func() { inspectArtifactFilesystem = previous }() + _, err := runWriteMode(context.Background(), manifestPath, filepath.Join(t.TempDir(), "apply.json"), proofPath, "apply", store) + require.ErrorContains(t, err, "same filesystem device") + }) + + t.Run("writable backup filesystem", func(t *testing.T) { + previous := inspectArtifactFilesystem + inspectArtifactFilesystem = func(path string) (filesystemStatus, error) { + if filepath.Base(path) == "backup.sealed" { + return filesystemStatus{Device: 2, ReadOnly: false}, nil + } + return filesystemStatus{Device: 1, ReadOnly: true}, nil + } + defer func() { inspectArtifactFilesystem = previous }() + _, err := runWriteMode(context.Background(), manifestPath, filepath.Join(t.TempDir(), "apply.json"), proofPath, "apply", store) + require.ErrorContains(t, err, "restored backup filesystem must be mounted read-only") + }) +} + +func TestBackupProofRequiresSchemaSnapshotAndIndependentRestore(t *testing.T) { + manifestPath, manifest, record, object := makeSealedPlan(t) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + backup := copyPlanDirectory(t, filepath.Dir(manifestPath)) + proofPath := filepath.Join(t.TempDir(), "backup.json") + proof := backupProof{ + Schema: backupProofSchema, ManifestSHA256: manifestHash, Kind: "ebs-snapshot-restore", + Location: backup, Status: "completed", + } + body, err := json.Marshal(proof) + require.NoError(t, err) + require.NoError(t, os.WriteFile(proofPath, body, 0o600)) + store := &fakeObjectStore{objects: map[string]storedObject{record.Key: object}} + _, err = runWriteMode(context.Background(), manifestPath, filepath.Join(t.TempDir(), "apply.json"), proofPath, "apply", store) + require.ErrorContains(t, err, "backup proof is incomplete") + + proof.Independent = true + proof.SnapshotID = manifest.SnapshotID + body, err = json.Marshal(proof) + require.NoError(t, err) + require.NoError(t, os.WriteFile(proofPath, body, 0o600)) + _, err = runWriteMode(context.Background(), manifestPath, filepath.Join(t.TempDir(), "apply-source-snapshot.json"), proofPath, "apply", store) + require.ErrorContains(t, err, "backup proof is incomplete") + + proof.SnapshotID = "snap-backup" + backupLink := filepath.Join(t.TempDir(), "backup-link.sealed") + require.NoError(t, os.Symlink(backup, backupLink)) + proof.Location = backupLink + body, err = json.Marshal(proof) + require.NoError(t, err) + require.NoError(t, os.WriteFile(proofPath, body, 0o600)) + _, err = runWriteMode(context.Background(), manifestPath, filepath.Join(t.TempDir(), "apply-link.json"), proofPath, "apply", store) + require.ErrorContains(t, err, "non-symlink directory") + + proof.Location = backup + body, err = json.Marshal(map[string]any{ + "schema": proof.Schema, "manifest_sha256": proof.ManifestSHA256, "kind": proof.Kind, + "snapshot_id": proof.SnapshotID, "location": proof.Location, "status": proof.Status, + "independent_restore": proof.Independent, "unknown": true, + }) + require.NoError(t, err) + require.NoError(t, os.WriteFile(proofPath, body, 0o600)) + _, err = runWriteMode(context.Background(), manifestPath, filepath.Join(t.TempDir(), "apply-unknown.json"), proofPath, "apply", store) + require.ErrorContains(t, err, "unknown field") +} diff --git a/cmd/statediff-rewriter/build_rocksdb.go b/cmd/statediff-rewriter/build_rocksdb.go new file mode 100644 index 00000000..9f52cb27 --- /dev/null +++ b/cmd/statediff-rewriter/build_rocksdb.go @@ -0,0 +1,5 @@ +//go:build rocksdb && grocksdb_clean_link + +package main + +const rocksDBBuild = true diff --git a/cmd/statediff-rewriter/build_stub.go b/cmd/statediff-rewriter/build_stub.go new file mode 100644 index 00000000..9187c384 --- /dev/null +++ b/cmd/statediff-rewriter/build_stub.go @@ -0,0 +1,5 @@ +//go:build !rocksdb || !grocksdb_clean_link + +package main + +const rocksDBBuild = false diff --git a/cmd/statediff-rewriter/checkpoint.go b/cmd/statediff-rewriter/checkpoint.go new file mode 100644 index 00000000..be54619d --- /dev/null +++ b/cmd/statediff-rewriter/checkpoint.go @@ -0,0 +1,330 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "os" + "path/filepath" +) + +const checkpointSchema = "statediff-rewriter-checkpoint/v1" + +type checkpoint struct { + Schema string `json:"schema"` + Checksum string `json:"checksum"` + RunID string `json:"run_id"` + ManifestHash string `json:"manifest_sha256"` + Mode string `json:"mode"` + Frontier uint64 `json:"frontier"` + Height uint64 `json:"height"` + Changed uint64 `json:"changed_frontier,omitempty"` + PUTAttempts uint64 `json:"put_attempts,omitempty"` + ConfirmedPUTs uint64 `json:"confirmed_puts,omitempty"` + UncertainPUTs uint64 `json:"uncertain_puts_verified,omitempty"` + AlreadyTarget uint64 `json:"already_target,omitempty"` + Conflicts uint64 `json:"conditional_conflicts,omitempty"` +} + +func checkpointChecksum(cp checkpoint) (string, error) { + cp.Checksum = "" + body, err := json.Marshal(cp) + if err != nil { + return "", err + } + return sha256Hex(body), nil +} + +func loadCheckpoint(path, runID, manifestHash, mode string) (checkpoint, error) { + body, found, err := readMutableStateFile(path, "checkpoint") + if !found && err == nil { + return checkpoint{Schema: checkpointSchema, RunID: runID, ManifestHash: manifestHash, Mode: mode}, nil + } + if err != nil { + return checkpoint{}, err + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + var cp checkpoint + if err := decoder.Decode(&cp); err != nil { + return checkpoint{}, fmt.Errorf("decode checkpoint: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return checkpoint{}, fmt.Errorf("checkpoint has trailing JSON") + } + if cp.Schema != checkpointSchema { + return checkpoint{}, fmt.Errorf("unsupported checkpoint schema %q", cp.Schema) + } + wantChecksum, err := checkpointChecksum(cp) + if err != nil { + return checkpoint{}, fmt.Errorf("hash checkpoint: %w", err) + } + if cp.Checksum == "" || cp.Checksum != wantChecksum { + return checkpoint{}, fmt.Errorf("checkpoint checksum mismatch") + } + if cp.RunID != runID || cp.ManifestHash != manifestHash || cp.Mode != mode { + return checkpoint{}, fmt.Errorf("checkpoint does not match run/manifest/mode") + } + if err := validateCheckpoint(cp); err != nil { + return checkpoint{}, err + } + return cp, nil +} + +func saveCheckpoint(path string, cp checkpoint) error { + if cp.Schema != "" && cp.Schema != checkpointSchema { + return fmt.Errorf("unsupported checkpoint schema %q", cp.Schema) + } + cp.Schema = checkpointSchema + if err := validateCheckpoint(cp); err != nil { + return err + } + if err := validateMutableStatePath(path, "checkpoint"); err != nil { + return err + } + checksum, err := checkpointChecksum(cp) + if err != nil { + return fmt.Errorf("hash checkpoint: %w", err) + } + cp.Checksum = checksum + _, err = atomicJSON(path, cp) + return err +} + +func validateCheckpoint(cp checkpoint) error { + if cp.Schema != checkpointSchema || cp.RunID == "" || cp.ManifestHash == "" || + (cp.Mode != applyMode && cp.Mode != rollbackMode && cp.Mode != verifyMode) { + return fmt.Errorf("checkpoint schema or identity is invalid") + } + if (cp.Frontier == 0) != (cp.Height == 0) || (cp.Frontier == 0 && cp.Changed != 0) { + return fmt.Errorf("checkpoint frontier and height are inconsistent") + } + if cp.Mode == verifyMode { + if cp.PUTAttempts != 0 || cp.ConfirmedPUTs != 0 || cp.UncertainPUTs != 0 || cp.AlreadyTarget != 0 || cp.Conflicts != 0 { + return fmt.Errorf("verify checkpoint contains write audit counters") + } + return nil + } + if cp.Changed != 0 || cp.ConfirmedPUTs > cp.PUTAttempts || + cp.UncertainPUTs > cp.PUTAttempts-cp.ConfirmedPUTs || + cp.Conflicts > cp.PUTAttempts || + cp.ConfirmedPUTs > cp.Frontier || cp.UncertainPUTs > cp.Frontier-cp.ConfirmedPUTs || + cp.AlreadyTarget != cp.Frontier-cp.ConfirmedPUTs-cp.UncertainPUTs { + return fmt.Errorf("checkpoint write audit counters are inconsistent") + } + return nil +} + +func loadPlanManifest(path string) (planManifest, string, error) { + return loadPlanManifestContext(context.Background(), path) +} + +func loadPlanManifestContext(ctx context.Context, path string) (planManifest, string, error) { + if ctx == nil { + return planManifest{}, "", fmt.Errorf("load plan manifest context is required") + } + dir, err := requireSealedPlanDirectory(path) + if err != nil { + return planManifest{}, "", err + } + body, err := readRegularFileNoFollow(path, "sealed plan manifest") + if err != nil { + return planManifest{}, "", err + } + var manifest planManifest + if err := decodeStrictJSON(body, &manifest, "plan manifest"); err != nil { + return planManifest{}, "", err + } + if (manifest.Schema != manifestSchema && manifest.Schema != pilotManifestSchema) || !manifest.Sealed { + return planManifest{}, "", fmt.Errorf("manifest is not sealed schema v1") + } + if manifest.RunID == "" || manifest.Bucket != defaultBucket || manifest.Prefix != defaultPrefix || manifest.Region != defaultRegion { + return planManifest{}, "", fmt.Errorf("manifest target is invalid") + } + if !validCreatedAt(manifest.CreatedAt) || manifest.SnapshotID == "" || + manifest.ArchiveIdentity.Home == "" || manifest.ArchiveIdentity.DatabaseIdentity == "" || + manifest.ArchiveIdentity.LatestVersion < 2 || manifest.ArchiveIdentity.FinalCommitHash == "" { + return planManifest{}, "", fmt.Errorf("manifest build or snapshot identity is incomplete") + } + if err := validateBuildIdentity( + manifest.CronosCommit, manifest.EthermintCommit, manifest.IAVLCommit, manifest.ImageDigest, manifest.BuildTags, + ); err != nil { + return planManifest{}, "", fmt.Errorf("manifest build identity: %w", err) + } + switch effectivePlanSourceMode(manifest) { + case planSourceDumpV1: + if !validSHA256Hex(manifest.DumpManifestHash) || !filepath.IsAbs(manifest.DumpPath) || + filepath.Ext(filepath.Clean(manifest.DumpPath)) != ".sealed" { + return planManifest{}, "", fmt.Errorf("manifest dump identity is incomplete") + } + case planSourceDirectV1: + if manifest.DumpPath != "" || manifest.DumpManifestHash != "" || !filepath.IsAbs(manifest.ArchiveIdentity.Home) { + return planManifest{}, "", fmt.Errorf("manifest direct IAVL identity is invalid") + } + default: + return planManifest{}, "", fmt.Errorf("unsupported plan source mode %q", manifest.SourceMode) + } + if manifest.FirstHeight < 2 || manifest.FinalHeight < manifest.FirstHeight || manifest.FinalHeight > manifest.ArchiveIdentity.LatestVersion { + return planManifest{}, "", fmt.Errorf("manifest height range is invalid") + } + if manifest.Schema == manifestSchema && (manifest.FirstHeight != 2 || manifest.FinalHeight != manifest.ArchiveIdentity.LatestVersion) { + return planManifest{}, "", fmt.Errorf("full manifest does not cover blocks 2 through archive latest") + } + expectedProcessed := manifest.FinalHeight - manifest.FirstHeight + 1 + if manifest.Processed != expectedProcessed || manifest.Processed < 0 || manifest.Changed < 0 || + manifest.Unchanged < 0 || manifest.SkippedEqualRoot < 0 || manifest.ChangedCanonical < 0 || + manifest.ChangedConflict < 0 || manifest.OldBytes < 0 || manifest.NewBytes < 0 || + manifest.Changed > manifest.Processed || manifest.Unchanged > manifest.Processed-manifest.Changed || + manifest.SkippedEqualRoot != manifest.Processed-manifest.Changed-manifest.Unchanged || + manifest.ChangedCanonical > manifest.Changed || manifest.ChangedConflict > manifest.Changed { + return planManifest{}, "", fmt.Errorf("manifest block counts are inconsistent") + } + if manifest.Processed > math.MaxInt64/rootRecordSize { + return planManifest{}, "", fmt.Errorf("manifest root index size overflows int64") + } + if manifest.HeightRootIndex == "" || manifest.HeightRootIndexSHA256 == "" || manifest.RootIndex == "" || + manifest.RootIndexSHA256 == "" || manifest.RootMultisetSHA256 == "" { + return planManifest{}, "", fmt.Errorf("manifest has incomplete root indexes") + } + if !validSHA256Hex(manifest.HeightRootIndexSHA256) || !validSHA256Hex(manifest.RootIndexSHA256) || + !validSHA256Hex(manifest.RootMultisetSHA256) { + return planManifest{}, "", fmt.Errorf("manifest root index hashes are malformed") + } + artifacts := make(map[string]string, 2+len(manifest.Chunks)*2) + addArtifact := func(name, label string) error { + if previous, found := artifacts[name]; found { + return fmt.Errorf("manifest artifact %q is reused by %s and %s", name, previous, label) + } + artifacts[name] = label + return nil + } + if err := addArtifact(manifest.HeightRootIndex, "height root index"); err != nil { + return planManifest{}, "", err + } + if err := addArtifact(manifest.RootIndex, "sorted root index"); err != nil { + return planManifest{}, "", err + } + heightRootPath, err := requirePlanArtifact(dir, manifest.HeightRootIndex, "height root index") + if err != nil { + return planManifest{}, "", err + } + heightRootHash, heightRootSize, err := hashFileContext(ctx, heightRootPath) + if err != nil { + return planManifest{}, "", err + } + if heightRootHash != manifest.HeightRootIndexSHA256 || heightRootSize != manifest.Processed*rootRecordSize { + return planManifest{}, "", fmt.Errorf("height root index changed or has the wrong size") + } + rootPath, err := requirePlanArtifact(dir, manifest.RootIndex, "sorted root index") + if err != nil { + return planManifest{}, "", err + } + rootHash, rootSize, err := hashFileContext(ctx, rootPath) + if err != nil { + return planManifest{}, "", err + } + if rootHash != manifest.RootIndexSHA256 || rootSize != manifest.Processed*rootRecordSize { + return planManifest{}, "", fmt.Errorf("root index changed or has the wrong size") + } + heightMultiset, heightCount, err := rootMultisetSHA256Context(ctx, heightRootPath) + if err != nil { + return planManifest{}, "", err + } + sortedMultiset, sortedCount, err := rootMultisetSHA256Context(ctx, rootPath) + if err != nil { + return planManifest{}, "", err + } + if heightCount != manifest.Processed || sortedCount != manifest.Processed || + heightMultiset != manifest.RootMultisetSHA256 || sortedMultiset != manifest.RootMultisetSHA256 { + return planManifest{}, "", fmt.Errorf("root indexes do not contain the sealed multiset") + } + var chunkRecords int64 + for number, chunk := range manifest.Chunks { + if err := ctx.Err(); err != nil { + return planManifest{}, "", err + } + if chunk.Number != number || chunk.Pack != fmt.Sprintf("chunk-%06d.pack", number) || + chunk.Index != fmt.Sprintf("chunk-%06d.idx", number) || chunk.Records < 1 || chunk.PackSize < 1 || + !validSHA256Hex(chunk.PackSHA256) || !validSHA256Hex(chunk.IndexSHA256) { + return planManifest{}, "", fmt.Errorf("pack chunk %d metadata is invalid", number) + } + if chunkRecords > manifest.Changed-chunk.Records { + return planManifest{}, "", fmt.Errorf("pack chunk records exceed manifest changed count") + } + chunkRecords += chunk.Records + if err := addArtifact(chunk.Pack, fmt.Sprintf("pack chunk %d", number)); err != nil { + return planManifest{}, "", err + } + if err := addArtifact(chunk.Index, fmt.Sprintf("pack index %d", number)); err != nil { + return planManifest{}, "", err + } + packPath, err := requirePlanArtifact(dir, chunk.Pack, "pack chunk") + if err != nil { + return planManifest{}, "", err + } + packHash, packSize, err := hashFileContext(ctx, packPath) + if err != nil { + return planManifest{}, "", err + } + if packHash != chunk.PackSHA256 || packSize != chunk.PackSize { + return planManifest{}, "", fmt.Errorf("pack chunk changed: %s", chunk.Pack) + } + indexPath, err := requirePlanArtifact(dir, chunk.Index, "pack index") + if err != nil { + return planManifest{}, "", err + } + indexHash, _, err := hashFileContext(ctx, indexPath) + if err != nil { + return planManifest{}, "", err + } + if indexHash != chunk.IndexSHA256 { + return planManifest{}, "", fmt.Errorf("pack index changed: %s", chunk.Index) + } + } + if chunkRecords != manifest.Changed || (manifest.Changed == 0) != (len(manifest.Chunks) == 0) { + return planManifest{}, "", fmt.Errorf("pack chunk records differ from manifest changed count") + } + artifacts["manifest.v1.json"] = "plan manifest" + entries, err := os.ReadDir(dir) + if err != nil { + return planManifest{}, "", err + } + for _, entry := range entries { + if entry.Name() == "plan.checkpoint.json" { + checkpoint, found, err := loadPlanCheckpoint(filepath.Join(dir, entry.Name()), manifest) + if err != nil { + return planManifest{}, "", err + } + if !found || checkpoint.Frontier != uint64(manifest.FinalHeight) || + checkpoint.Manifest.RunID != manifest.RunID || checkpoint.Manifest.CreatedAt != manifest.CreatedAt || + !samePlanProgress(checkpoint.Manifest, manifest) { + return planManifest{}, "", fmt.Errorf("sealed plan checkpoint does not describe the final manifest") + } + continue + } + if _, ok := artifacts[entry.Name()]; !ok { + return planManifest{}, "", fmt.Errorf("unexpected sealed plan artifact: %s", entry.Name()) + } + } + return manifest, sha256Hex(body), nil +} + +func samePlanProgress(left, right planManifest) bool { + return left.Processed == right.Processed && left.Changed == right.Changed && + left.ChangedCanonical == right.ChangedCanonical && left.ChangedConflict == right.ChangedConflict && + left.Unchanged == right.Unchanged && left.SkippedEqualRoot == right.SkippedEqualRoot && + left.SlotsAdded == right.SlotsAdded && left.SlotsRemoved == right.SlotsRemoved && + left.SlotsChanged == right.SlotsChanged && left.OldBytes == right.OldBytes && left.NewBytes == right.NewBytes +} + +func requireFullPlan(manifest planManifest, operation string) error { + if manifest.Schema != manifestSchema { + return fmt.Errorf("pilot plans support plan only; %s is disabled", operation) + } + return nil +} diff --git a/cmd/statediff-rewriter/direct.go b/cmd/statediff-rewriter/direct.go new file mode 100644 index 00000000..7fa759b5 --- /dev/null +++ b/cmd/statediff-rewriter/direct.go @@ -0,0 +1,272 @@ +package main + +import ( + "context" + "fmt" + + "github.com/cosmos/iavl" + "github.com/evmos/ethermint/debank/statediff" + dtypes "github.com/evmos/ethermint/debank/types" + "golang.org/x/sync/errgroup" +) + +const ( + defaultDirectIAVLConcurrency = 8 + maximumDirectIAVLConcurrency = 64 + directIAVLShardSize = int64(64) + directResultBaseBytes = int64(1 << 20) + directResultItemBytes = int64(128) +) + +type directStorageChange struct { + height int64 + canonical []dtypes.AccountStorageDiff +} + +type directShardTask struct { + ordinal uint64 + first int64 + last int64 +} + +type directShardResult struct { + directShardTask + changes []directStorageChange +} + +func iterateArchiveDirectStorageDiffsContext( + ctx context.Context, + archive *archiveReader, + cacheSize, concurrency int, + first, last int64, + callback func(int64, []dtypes.AccountStorageDiff) error, +) error { + if archive == nil { + return fmt.Errorf("direct archive is required") + } + legacyLatest, err := archive.evmLegacyLatestVersion() + if err != nil { + return fmt.Errorf("resolve latest legacy EVM IAVL version: %w", err) + } + return iterateParallelDirectStorageDiffsContext( + ctx, + func() stateChangeSource { return archive.evmStateChangeSource(cacheSize) }, + legacyLatest, concurrency, first, last, callback, + ) +} + +func iterateParallelDirectStorageDiffsContext( + ctx context.Context, + newSource func() stateChangeSource, + legacyLatest int64, + concurrency int, + first, last int64, + callback func(int64, []dtypes.AccountStorageDiff) error, +) error { + if ctx == nil || newSource == nil || callback == nil { + return fmt.Errorf("parallel direct traversal context, source factory, and callback are required") + } + if concurrency < 1 || concurrency > maximumDirectIAVLConcurrency { + return fmt.Errorf("iavl-concurrency must be between 1 and %d", maximumDirectIAVLConcurrency) + } + if legacyLatest < 0 { + return fmt.Errorf("invalid latest legacy version %d", legacyLatest) + } + if first < 2 || last < first { + return fmt.Errorf("invalid parallel direct traversal range %d-%d", first, last) + } + + pipelineCtx, cancel := context.WithCancel(ctx) + defer cancel() + group, groupCtx := errgroup.WithContext(pipelineCtx) + jobs := make(chan directShardTask, concurrency) + results := make(chan directShardResult, concurrency) + tokens := make(chan struct{}, concurrency) + + group.Go(func() error { + defer close(jobs) + ordinal := uint64(1) + for shardFirst := first; ; ordinal++ { + shardLast := shardFirst + directIAVLShardSize - 1 + if shardLast < shardFirst || shardLast > last { + shardLast = last + } + if legacyLatest >= shardFirst && legacyLatest < shardLast { + shardLast = legacyLatest + } + task := directShardTask{ordinal: ordinal, first: shardFirst, last: shardLast} + select { + case tokens <- struct{}{}: + case <-groupCtx.Done(): + return groupCtx.Err() + } + select { + case jobs <- task: + case <-groupCtx.Done(): + <-tokens + return groupCtx.Err() + } + if shardLast == last { + return nil + } + shardFirst = shardLast + 1 + } + }) + + for range concurrency { + group.Go(func() error { + source := newSource() + if source == nil { + return fmt.Errorf("direct state change source factory returned nil") + } + for task := range jobs { + retainedBytes := int64(0) + result := directShardResult{ + directShardTask: task, + changes: make([]directStorageChange, 0, task.last-task.first+1), + } + err := iterateDirectStateChangesContext(groupCtx, source, task.first, task.last, func(height int64, changeSet *iavl.ChangeSet) error { + canonical, err := statediff.CanonicalStorageDiff(changeSet) + if err != nil { + return fmt.Errorf("height %d canonical storage: %w", height, err) + } + retainedBytes, err = addDirectShardRetainedBytes(retainedBytes, canonical) + if err != nil { + return fmt.Errorf("height %d canonical storage: %w", height, err) + } + result.changes = append(result.changes, directStorageChange{height: height, canonical: canonical}) + return nil + }) + if err != nil { + return fmt.Errorf("direct shard %d versions %d-%d: %w", task.ordinal, task.first, task.last, err) + } + select { + case results <- result: + case <-groupCtx.Done(): + return groupCtx.Err() + } + } + return nil + }) + } + + groupDone := make(chan error, 1) + go func() { + groupDone <- group.Wait() + close(results) + }() + + expectedOrdinal := uint64(1) + expectedHeight := first + pending := make(map[uint64]directShardResult, concurrency) + var callbackErr error + for result := range results { + if callbackErr != nil { + continue + } + if result.ordinal < expectedOrdinal { + callbackErr = fmt.Errorf("direct shard result %d precedes expected %d", result.ordinal, expectedOrdinal) + cancel() + continue + } + if _, found := pending[result.ordinal]; found { + callbackErr = fmt.Errorf("duplicate direct shard result %d", result.ordinal) + cancel() + continue + } + pending[result.ordinal] = result + for { + next, found := pending[expectedOrdinal] + if !found { + break + } + if next.first != expectedHeight || int64(len(next.changes)) != next.last-next.first+1 { + callbackErr = fmt.Errorf("direct shard %d does not continuously cover height %d", next.ordinal, expectedHeight) + cancel() + break + } + for _, change := range next.changes { + if change.height != expectedHeight { + callbackErr = fmt.Errorf("direct shard %d emitted height %d, want %d", next.ordinal, change.height, expectedHeight) + cancel() + break + } + if err := callback(change.height, change.canonical); err != nil { + callbackErr = err + cancel() + break + } + expectedHeight++ + } + if callbackErr != nil { + break + } + delete(pending, expectedOrdinal) + <-tokens + expectedOrdinal++ + } + } + groupErr := <-groupDone + if callbackErr != nil { + return callbackErr + } + if groupErr != nil { + return groupErr + } + if expectedHeight != last+1 || len(pending) != 0 || len(tokens) != 0 { + return fmt.Errorf("parallel direct traversal ended at height %d, want %d", expectedHeight-1, last) + } + return nil +} + +func addDirectShardRetainedBytes(current int64, storage []dtypes.AccountStorageDiff) (int64, error) { + if current < 0 || current > maxObjectSize { + return 0, fmt.Errorf("invalid retained canonical shard bytes %d", current) + } + estimated, err := estimatedCanonicalStorageBytes(storage) + if err != nil { + return 0, err + } + // The 1 MiB base belongs to the outer object task. A buffered shard retains + // only this height entry and the canonical account/slot values. + retained := directResultItemBytes + if estimated != 0 { + retained += estimated - directResultBaseBytes + } + if retained > maxObjectSize-current { + return 0, fmt.Errorf("retained canonical shard exceeds %d bytes", maxObjectSize) + } + return current + retained, nil +} + +func estimatedCanonicalStorageBytes(storage []dtypes.AccountStorageDiff) (int64, error) { + if len(storage) == 0 { + return 0, nil + } + remaining := (maxObjectSize - directResultBaseBytes) / directResultItemBytes + items := int64(0) + for _, account := range storage { + if remaining < 1 { + return 0, fmt.Errorf("retained canonical storage exceeds %d bytes", maxObjectSize) + } + remaining-- + items++ + if int64(len(account.Values)) > remaining { + return 0, fmt.Errorf("retained canonical storage exceeds %d bytes", maxObjectSize) + } + remaining -= int64(len(account.Values)) + items += int64(len(account.Values)) + } + return directResultBaseBytes + items*directResultItemBytes, nil +} + +func canonicalObjectOperationBytes(storage []dtypes.AccountStorageDiff, equalRoot bool) (int64, error) { + if equalRoot { + return 1 << 20, nil + } + retained, err := estimatedCanonicalStorageBytes(storage) + if err != nil { + return 0, err + } + return maxObjectOperationBytes + retained, nil +} diff --git a/cmd/statediff-rewriter/direct_parallel_test.go b/cmd/statediff-rewriter/direct_parallel_test.go new file mode 100644 index 00000000..e924bb65 --- /dev/null +++ b/cmd/statediff-rewriter/direct_parallel_test.go @@ -0,0 +1,305 @@ +package main + +import ( + "bytes" + "context" + "errors" + "sort" + "sync" + "testing" + "time" + + "github.com/cosmos/iavl" + dtypes "github.com/evmos/ethermint/debank/types" + "github.com/stretchr/testify/require" +) + +type parallelDirectFake struct { + mu sync.Mutex + + ranges [][2]int64 + completed [][2]int64 + active int + maxActive int + workerError error + errorFirst int64 + + holdFirst int64 + release chan struct{} + releaseOnce sync.Once + + barrierTarget int + barrier chan struct{} + barrierOnce sync.Once +} + +type parallelDirectFakeSource struct { + fake *parallelDirectFake +} + +func (source *parallelDirectFakeSource) VersionHash(version int64) ([]byte, error) { + return bytes.Repeat([]byte{byte(version)}, legacyHashLength), nil +} + +func (source *parallelDirectFakeSource) TraverseStateChanges( + first, last int64, + callback func(int64, *iavl.ChangeSet) error, +) error { + fake := source.fake + fake.mu.Lock() + fake.ranges = append(fake.ranges, [2]int64{first, last}) + fake.active++ + if fake.active > fake.maxActive { + fake.maxActive = fake.active + } + if fake.barrierTarget > 0 && fake.active == fake.barrierTarget { + fake.barrierOnce.Do(func() { close(fake.barrier) }) + } + fake.mu.Unlock() + defer func() { + fake.mu.Lock() + fake.active-- + fake.mu.Unlock() + }() + + if fake.barrierTarget > 0 { + select { + case <-fake.barrier: + case <-time.After(2 * time.Second): + return errors.New("parallel direct fake barrier timed out") + } + } + if first == fake.errorFirst && fake.workerError != nil { + return fake.workerError + } + if first == fake.holdFirst { + select { + case <-fake.release: + case <-time.After(2 * time.Second): + return errors.New("parallel direct fake release timed out") + } + } + for version := first; version <= last; version++ { + if err := callback(version, &iavl.ChangeSet{}); err != nil { + return err + } + } + fake.mu.Lock() + fake.completed = append(fake.completed, [2]int64{first, last}) + fake.mu.Unlock() + if first != fake.holdFirst && fake.release != nil { + fake.releaseOnce.Do(func() { close(fake.release) }) + } + return nil +} + +func (fake *parallelDirectFake) source() stateChangeSource { + return ¶llelDirectFakeSource{fake: fake} +} + +func (fake *parallelDirectFake) snapshot() (ranges, completed [][2]int64, maxActive int) { + fake.mu.Lock() + defer fake.mu.Unlock() + return append([][2]int64(nil), fake.ranges...), append([][2]int64(nil), fake.completed...), fake.maxActive +} + +func TestIterateParallelDirectStorageDiffsContextOrdersShardsAndSplitsLegacyBoundary(t *testing.T) { + const ( + first = int64(2) + legacyLatest = int64(100) + last = int64(228) + concurrency = 3 + ) + fake := ¶llelDirectFake{ + holdFirst: first, + release: make(chan struct{}), + } + var heights []int64 + + err := iterateParallelDirectStorageDiffsContext( + context.Background(), fake.source, legacyLatest, concurrency, first, last, + func(height int64, storage []dtypes.AccountStorageDiff) error { + heights = append(heights, height) + require.Empty(t, storage) + return nil + }, + ) + require.NoError(t, err) + require.Len(t, heights, int(last-first+1)) + for index, height := range heights { + require.Equal(t, first+int64(index), height) + } + + ranges, completed, maxActive := fake.snapshot() + sort.Slice(ranges, func(i, j int) bool { return ranges[i][0] < ranges[j][0] }) + require.Equal(t, [][2]int64{ + {2, 65}, + {66, legacyLatest}, + {legacyLatest + 1, 164}, + {165, last}, + }, ranges) + require.NotEmpty(t, completed) + require.NotEqual(t, first, completed[0][0], "the held first shard must complete out of order") + require.LessOrEqual(t, maxActive, concurrency) +} + +func TestIterateParallelDirectStorageDiffsContextBoundsConcurrency(t *testing.T) { + const concurrency = 3 + fake := ¶llelDirectFake{ + barrierTarget: concurrency, + barrier: make(chan struct{}), + } + + err := iterateParallelDirectStorageDiffsContext( + context.Background(), fake.source, 0, concurrency, 2, 2+directIAVLShardSize*4-1, + func(int64, []dtypes.AccountStorageDiff) error { return nil }, + ) + require.NoError(t, err) + _, _, maxActive := fake.snapshot() + require.Equal(t, concurrency, maxActive) +} + +func TestIterateParallelDirectStorageDiffsContextRejectsNilFactoryResult(t *testing.T) { + err := iterateParallelDirectStorageDiffsContext( + context.Background(), func() stateChangeSource { return nil }, 0, 1, 2, 2, + func(int64, []dtypes.AccountStorageDiff) error { return nil }, + ) + require.ErrorContains(t, err, "direct state change source factory returned nil") +} + +func TestIterateParallelDirectStorageDiffsContextPropagatesWorkerError(t *testing.T) { + wantErr := errors.New("worker failed") + fake := ¶llelDirectFake{workerError: wantErr, errorFirst: 2} + + err := iterateParallelDirectStorageDiffsContext( + context.Background(), fake.source, 0, 2, 2, 129, + func(int64, []dtypes.AccountStorageDiff) error { return nil }, + ) + require.ErrorIs(t, err, wantErr) + require.ErrorContains(t, err, "direct shard 1 versions 2-65") +} + +func TestIterateParallelDirectStorageDiffsContextPropagatesCallbackError(t *testing.T) { + wantErr := errors.New("callback failed") + fake := ¶llelDirectFake{} + + err := iterateParallelDirectStorageDiffsContext( + context.Background(), fake.source, 0, 2, 2, 129, + func(height int64, _ []dtypes.AccountStorageDiff) error { + if height == 7 { + return wantErr + } + return nil + }, + ) + require.ErrorIs(t, err, wantErr) +} + +func TestIterateParallelDirectStorageDiffsContextHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + fake := ¶llelDirectFake{} + + err := iterateParallelDirectStorageDiffsContext( + ctx, fake.source, 0, 2, 2, 129, + func(int64, []dtypes.AccountStorageDiff) error { return nil }, + ) + require.ErrorIs(t, err, context.Canceled) +} + +type postCheckMismatchSource struct { + traversed bool +} + +func (source *postCheckMismatchSource) VersionHash(version int64) ([]byte, error) { + value := byte(version) + if source.traversed { + value++ + } + return bytes.Repeat([]byte{value}, legacyHashLength), nil +} + +func (source *postCheckMismatchSource) TraverseStateChanges( + first, last int64, + callback func(int64, *iavl.ChangeSet) error, +) error { + for version := first; version <= last; version++ { + if err := callback(version, &iavl.ChangeSet{}); err != nil { + return err + } + } + source.traversed = true + return nil +} + +func TestIterateParallelDirectStorageDiffsContextPublishesOnlyPostCheckedShards(t *testing.T) { + callbacks := 0 + err := iterateParallelDirectStorageDiffsContext( + context.Background(), + func() stateChangeSource { return &postCheckMismatchSource{} }, + 0, 1, 2, 2+directIAVLShardSize-1, + func(int64, []dtypes.AccountStorageDiff) error { + callbacks++ + return nil + }, + ) + require.ErrorContains(t, err, "root changed during traversal") + require.Zero(t, callbacks) +} + +func TestIterateParallelDirectStorageDiffsContextRejectsInvalidLegacyBoundary(t *testing.T) { + fake := ¶llelDirectFake{} + err := iterateParallelDirectStorageDiffsContext( + context.Background(), fake.source, -1, 1, 2, 2, + func(int64, []dtypes.AccountStorageDiff) error { return nil }, + ) + require.ErrorContains(t, err, "invalid latest legacy version -1") +} + +func TestEstimatedCanonicalStorageBytesBoundsRetainedMemory(t *testing.T) { + got, err := estimatedCanonicalStorageBytes(nil) + require.NoError(t, err) + require.Zero(t, got) + + one := []dtypes.AccountStorageDiff{{Values: []dtypes.IndexValuePair{{}}}} + got, err = estimatedCanonicalStorageBytes(one) + require.NoError(t, err) + require.Equal(t, directResultBaseBytes+2*directResultItemBytes, got) + + remaining := (maxObjectSize - directResultBaseBytes) / directResultItemBytes + values := make([]dtypes.IndexValuePair, int(remaining)) + atLimit := []dtypes.AccountStorageDiff{{Values: values[:remaining-1]}} + got, err = estimatedCanonicalStorageBytes(atLimit) + require.NoError(t, err) + require.Equal(t, directResultBaseBytes+remaining*directResultItemBytes, got) + + tooLarge := []dtypes.AccountStorageDiff{{Values: values}} + _, err = estimatedCanonicalStorageBytes(tooLarge) + require.ErrorContains(t, err, "retained canonical storage exceeds") + + weight, err := canonicalObjectOperationBytes(one, false) + require.NoError(t, err) + require.Equal(t, maxObjectOperationBytes+directResultBaseBytes+2*directResultItemBytes, weight) + weight, err = canonicalObjectOperationBytes(nil, true) + require.NoError(t, err) + require.Equal(t, int64(1<<20), weight) +} + +func TestAddDirectShardRetainedBytesEnforcesCumulativeLimit(t *testing.T) { + one := []dtypes.AccountStorageDiff{{Values: []dtypes.IndexValuePair{{}}}} + retained, err := addDirectShardRetainedBytes(0, nil) + require.NoError(t, err) + require.Equal(t, directResultItemBytes, retained) + + retained, err = addDirectShardRetainedBytes(retained, one) + require.NoError(t, err) + require.Equal(t, 4*directResultItemBytes, retained) + + oneBytes := int64(3) * directResultItemBytes + retained, err = addDirectShardRetainedBytes(maxObjectSize-oneBytes, one) + require.NoError(t, err) + require.Equal(t, maxObjectSize, retained) + + _, err = addDirectShardRetainedBytes(maxObjectSize-oneBytes+1, one) + require.ErrorContains(t, err, "retained canonical shard exceeds") +} diff --git a/cmd/statediff-rewriter/direct_plan_test.go b/cmd/statediff-rewriter/direct_plan_test.go new file mode 100644 index 00000000..a6ce844a --- /dev/null +++ b/cmd/statediff-rewriter/direct_plan_test.go @@ -0,0 +1,249 @@ +package main + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/cosmos/iavl" + "github.com/stretchr/testify/require" +) + +type directPlanEmission struct { + version int64 + changeSet *iavl.ChangeSet +} + +type directPlanFakeSource struct { + emissions []directPlanEmission + versionHashErrs map[int64]error + versionHashCalls []int64 + traversalCalls [][2]int64 +} + +func (source *directPlanFakeSource) VersionHash(version int64) ([]byte, error) { + source.versionHashCalls = append(source.versionHashCalls, version) + if err := source.versionHashErrs[version]; err != nil { + return nil, err + } + return bytes.Repeat([]byte{byte(version)}, legacyHashLength), nil +} + +func (source *directPlanFakeSource) TraverseStateChanges( + first, last int64, + callback func(int64, *iavl.ChangeSet) error, +) error { + source.traversalCalls = append(source.traversalCalls, [2]int64{first, last}) + for _, emission := range source.emissions { + if err := callback(emission.version, emission.changeSet); err != nil { + return err + } + } + return nil +} + +func TestIterateDirectStateChangesContext(t *testing.T) { + changes := []*iavl.ChangeSet{{}, {}, {}} + source := &directPlanFakeSource{emissions: []directPlanEmission{ + {version: 2, changeSet: changes[0]}, + {version: 3, changeSet: changes[1]}, + {version: 4, changeSet: changes[2]}, + }} + var versions []int64 + var received []*iavl.ChangeSet + + err := iterateDirectStateChangesContext( + context.Background(), source, 2, 4, + func(version int64, changeSet *iavl.ChangeSet) error { + versions = append(versions, version) + received = append(received, changeSet) + return nil + }, + ) + require.NoError(t, err) + require.Equal(t, []int64{2, 3, 4}, versions) + require.Equal(t, changes, received) + require.Equal(t, [][2]int64{{2, 4}}, source.traversalCalls) + require.Equal(t, []int64{1, 2, 4, 1, 2, 4}, source.versionHashCalls) +} + +func TestIterateDirectStateChangesContextRejectsInvalidEmissions(t *testing.T) { + changeSet := &iavl.ChangeSet{} + testCases := []struct { + name string + emissions []directPlanEmission + wantError string + }{ + { + name: "missing final version", + emissions: []directPlanEmission{ + {version: 2, changeSet: changeSet}, + {version: 3, changeSet: changeSet}, + }, + wantError: "direct traversal ended at version 3, want 4", + }, + { + name: "duplicate version", + emissions: []directPlanEmission{ + {version: 2, changeSet: changeSet}, + {version: 2, changeSet: changeSet}, + {version: 3, changeSet: changeSet}, + {version: 4, changeSet: changeSet}, + }, + wantError: "direct traversal emitted version 2, want 3", + }, + { + name: "out of order version", + emissions: []directPlanEmission{ + {version: 2, changeSet: changeSet}, + {version: 4, changeSet: changeSet}, + {version: 3, changeSet: changeSet}, + }, + wantError: "direct traversal emitted version 4, want 3", + }, + { + name: "nil changeset", + emissions: []directPlanEmission{ + {version: 2}, + }, + wantError: "direct traversal emitted nil changeset at version 2", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + source := &directPlanFakeSource{emissions: testCase.emissions} + err := iterateDirectStateChangesContext( + context.Background(), source, 2, 4, + func(int64, *iavl.ChangeSet) error { return nil }, + ) + require.ErrorContains(t, err, testCase.wantError) + }) + } +} + +func TestIterateDirectStateChangesContextRejectsMissingPredecessor(t *testing.T) { + wantErr := errors.New("predecessor was pruned") + source := &directPlanFakeSource{versionHashErrs: map[int64]error{1: wantErr}} + + err := iterateDirectStateChangesContext( + context.Background(), source, 2, 2, + func(int64, *iavl.ChangeSet) error { return nil }, + ) + require.ErrorIs(t, err, wantErr) + require.ErrorContains(t, err, "resolve IAVL version 1 root before traversal") + require.Empty(t, source.traversalCalls) +} + +func TestIterateDirectStateChangesContextHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + source := &directPlanFakeSource{emissions: []directPlanEmission{ + {version: 2, changeSet: &iavl.ChangeSet{}}, + }} + callbackCalls := 0 + + err := iterateDirectStateChangesContext(ctx, source, 2, 2, func(int64, *iavl.ChangeSet) error { + callbackCalls++ + return nil + }) + require.ErrorIs(t, err, context.Canceled) + require.Zero(t, callbackCalls) +} + +func TestEffectivePlanSourceModeAndIdentity(t *testing.T) { + legacyDump := directPlanIdentityFixture() + legacyDump.SourceMode = "" + explicitDump := legacyDump + explicitDump.SourceMode = planSourceDumpV1 + require.Equal(t, planSourceDumpV1, effectivePlanSourceMode(legacyDump)) + require.Equal(t, planSourceDumpV1, effectivePlanSourceMode(explicitDump)) + require.True(t, samePlanIdentity(legacyDump, explicitDump)) + + direct := legacyDump + direct.SourceMode = planSourceDirectV1 + direct.DumpPath = "" + direct.DumpManifestHash = "" + require.Equal(t, planSourceDirectV1, effectivePlanSourceMode(direct)) + require.True(t, samePlanIdentity(direct, direct)) + require.False(t, samePlanIdentity(legacyDump, direct)) + + otherDirectArchive := direct + otherDirectArchive.ArchiveIdentity.DatabaseIdentity = "other-db" + require.False(t, samePlanIdentity(direct, otherDirectArchive)) + + otherDump := explicitDump + otherDump.DumpManifestHash = "other-dump-hash" + require.False(t, samePlanIdentity(explicitDump, otherDump)) +} + +func TestLoadPlanManifestAcceptsDirectSource(t *testing.T) { + manifestPath, manifest, _, _ := makeSealedPlanRecords(t, 1) + manifest.SourceMode = planSourceDirectV1 + manifest.DumpPath = "" + manifest.DumpManifestHash = "" + _, err := atomicJSON(manifestPath, manifest) + require.NoError(t, err) + + loaded, _, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + require.Equal(t, planSourceDirectV1, effectivePlanSourceMode(loaded)) + require.Empty(t, loaded.DumpPath) + require.Empty(t, loaded.DumpManifestHash) +} + +func TestLoadPlanManifestRejectsDumpIdentityForDirectSource(t *testing.T) { + testCases := []struct { + name string + keepPath bool + keepHash bool + }{ + {name: "dump path", keepPath: true}, + {name: "dump manifest hash", keepHash: true}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + manifestPath, manifest, _, _ := makeSealedPlanRecords(t, 1) + manifest.SourceMode = planSourceDirectV1 + if !testCase.keepPath { + manifest.DumpPath = "" + } + if !testCase.keepHash { + manifest.DumpManifestHash = "" + } + _, err := atomicJSON(manifestPath, manifest) + require.NoError(t, err) + + _, _, err = loadPlanManifest(manifestPath) + require.ErrorContains(t, err, "manifest direct IAVL identity is invalid") + }) + } +} + +func directPlanIdentityFixture() planManifest { + return planManifest{ + Schema: manifestSchema, + Sealed: false, + Bucket: defaultBucket, + Prefix: defaultPrefix, + Region: defaultRegion, + FirstHeight: 2, + FinalHeight: 10, + CronosCommit: "cronos", + EthermintCommit: "ethermint", + IAVLCommit: "iavl", + SnapshotID: "snapshot", + ImageDigest: "image", + BuildTags: "rocksdb,objstore", + DumpPath: "/dump.sealed", + DumpManifestHash: "dump-hash", + ArchiveIdentity: archiveIdentity{ + Home: "/archive", + DatabaseIdentity: "db", + LatestVersion: 10, + FinalCommitHash: "commit", + }, + } +} diff --git a/cmd/statediff-rewriter/dump.go b/cmd/statediff-rewriter/dump.go new file mode 100644 index 00000000..eb8f998b --- /dev/null +++ b/cmd/statediff-rewriter/dump.go @@ -0,0 +1,701 @@ +package main + +import ( + "bufio" + "bytes" + "compress/zlib" + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "syscall" + "time" + + "github.com/cosmos/iavl" + "github.com/evmos/ethermint/debank/statediff" +) + +const ( + dumpManifestSchema = "cronos-changeset-dump/v1" + pilotDumpManifestSchema = "cronos-changeset-pilot-dump/v1" +) + +const maxChangeSetPayload = uint64(256 << 20) + +type countingWriter struct{ n int64 } + +type contextReader struct { + ctx context.Context + reader *bufio.Reader +} + +type dumpContext struct { + Schema string `json:"dump_schema"` + FirstVersion int64 `json:"first_version"` + LastVersion int64 `json:"last_version"` + SnapshotID string `json:"snapshot_id"` + ArchiveIdentity archiveIdentity `json:"archive_identity"` + CronosCommit string `json:"cronos_commit"` + EthermintCommit string `json:"ethermint_commit"` + IAVLCommit string `json:"iavl_commit"` + ImageDigest string `json:"image_digest"` + BuildTags string `json:"build_tags"` +} + +func (w *countingWriter) Write(body []byte) (int, error) { + w.n += int64(len(body)) + return len(body), nil +} + +func (reader contextReader) Read(body []byte) (int, error) { + if err := reader.ctx.Err(); err != nil { + return 0, err + } + return reader.reader.Read(body) +} + +func (reader contextReader) ReadByte() (byte, error) { + if err := reader.ctx.Err(); err != nil { + return 0, err + } + return reader.reader.ReadByte() +} + +func scanZlibChangeSets(path string, fn func(int64, *iavl.ChangeSet) error) (dumpFileManifest, error) { + return scanZlibChangeSetsContext(context.Background(), path, fn) +} + +func scanZlibChangeSetsContext(ctx context.Context, path string, fn func(int64, *iavl.ChangeSet) error) (dumpFileManifest, error) { + if ctx == nil { + return dumpFileManifest{}, fmt.Errorf("scan changesets context is required") + } + file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0) + if err != nil { + return dumpFileManifest{}, err + } + defer file.Close() + stat, err := file.Stat() + if err != nil { + return dumpFileManifest{}, err + } + if !stat.Mode().IsRegular() { + return dumpFileManifest{}, fmt.Errorf("changeset dump must be a regular non-symlink file: %s", path) + } + + hasher := sha256.New() + counter := &countingWriter{} + compressed := bufio.NewReader(io.TeeReader(file, io.MultiWriter(hasher, counter))) + zreader, err := zlib.NewReader(contextReader{ctx: ctx, reader: compressed}) + if err != nil { + return dumpFileManifest{}, fmt.Errorf("open zlib %s: %w", path, err) + } + reader := bufio.NewReader(zreader) + + var first, last, records int64 + for { + if err := ctx.Err(); err != nil { + _ = zreader.Close() + return dumpFileManifest{}, err + } + _, peekErr := reader.Peek(1) + if errors.Is(peekErr, io.EOF) { + break + } + if peekErr != nil { + _ = zreader.Close() + return dumpFileManifest{}, fmt.Errorf("read zlib %s: %w", path, peekErr) + } + version, changeSet, readErr := readChangeSet(reader) + if readErr != nil { + _ = zreader.Close() + return dumpFileManifest{}, fmt.Errorf("read changeset %s record %d: %w", path, records+1, readErr) + } + if records == 0 { + first = version + } else if version != last+1 { + _ = zreader.Close() + return dumpFileManifest{}, fmt.Errorf("non-contiguous changeset in %s: got %d after %d", path, version, last) + } + last = version + records++ + if fn != nil { + if err := fn(version, changeSet); err != nil { + _ = zreader.Close() + return dumpFileManifest{}, err + } + } + } + if err := zreader.Close(); err != nil { + return dumpFileManifest{}, fmt.Errorf("close zlib %s: %w", path, err) + } + if _, err := compressed.Peek(1); !errors.Is(err, io.EOF) { + if err == nil { + return dumpFileManifest{}, fmt.Errorf("zlib file %s has trailing data", path) + } + return dumpFileManifest{}, fmt.Errorf("read zlib trailer %s: %w", path, err) + } + if counter.n != stat.Size() { + return dumpFileManifest{}, fmt.Errorf("zlib file %s was not fully consumed: %d of %d bytes", path, counter.n, stat.Size()) + } + if records == 0 { + return dumpFileManifest{}, fmt.Errorf("empty changeset file %s", path) + } + return dumpFileManifest{ + Path: filepath.Base(path), SHA256: hex.EncodeToString(hasher.Sum(nil)), Size: stat.Size(), + FirstVersion: first, LastVersion: last, Records: records, + }, nil +} + +func readChangeSet(reader *bufio.Reader) (int64, *iavl.ChangeSet, error) { + var header [16]byte + if _, err := io.ReadFull(reader, header[:]); err != nil { + return 0, nil, err + } + version := int64(binary.LittleEndian.Uint64(header[:8])) + size := binary.LittleEndian.Uint64(header[8:]) + if size > maxChangeSetPayload { + return 0, nil, fmt.Errorf("changeset payload %d exceeds limit %d", size, maxChangeSetPayload) + } + payload := make([]byte, size) + if _, err := io.ReadFull(reader, payload); err != nil { + return 0, nil, err + } + payloadReader := bytes.NewReader(payload) + changeSet := &iavl.ChangeSet{} + for payloadReader.Len() > 0 { + deletion, err := payloadReader.ReadByte() + if err != nil { + return 0, nil, err + } + if deletion > 1 { + return 0, nil, fmt.Errorf("invalid deletion marker %d", deletion) + } + keyLength, err := binary.ReadUvarint(payloadReader) + if err != nil { + return 0, nil, err + } + if keyLength > uint64(payloadReader.Len()) { + return 0, nil, io.ErrUnexpectedEOF + } + pair := &iavl.KVPair{Delete: deletion == 1, Key: make([]byte, keyLength)} + if _, err := io.ReadFull(payloadReader, pair.Key); err != nil { + return 0, nil, err + } + if !pair.Delete { + valueLength, err := binary.ReadUvarint(payloadReader) + if err != nil { + return 0, nil, err + } + if valueLength > uint64(payloadReader.Len()) { + return 0, nil, io.ErrUnexpectedEOF + } + pair.Value = make([]byte, valueLength) + if _, err := io.ReadFull(payloadReader, pair.Value); err != nil { + return 0, nil, err + } + } + changeSet.Pairs = append(changeSet.Pairs, pair) + } + return version, changeSet, nil +} + +func writeChangeSet(writer io.Writer, version int64, changeSet *iavl.ChangeSet) error { + var payloadSize uint64 + for _, pair := range changeSet.Pairs { + if pair == nil { + return fmt.Errorf("nil changeset pair") + } + pairSize := uint64(1 + uvarintLength(uint64(len(pair.Key))) + len(pair.Key)) + if !pair.Delete { + pairSize += uint64(uvarintLength(uint64(len(pair.Value))) + len(pair.Value)) + } + if pairSize > maxChangeSetPayload-payloadSize { + return fmt.Errorf("changeset payload exceeds limit %d", maxChangeSetPayload) + } + payloadSize += pairSize + } + + var header [16]byte + binary.LittleEndian.PutUint64(header[:8], uint64(version)) + binary.LittleEndian.PutUint64(header[8:], payloadSize) + if err := writeBytes(writer, header[:]); err != nil { + return err + } + var scratch [binary.MaxVarintLen64]byte + for _, pair := range changeSet.Pairs { + deletion := byte(0) + if pair.Delete { + deletion = 1 + } + if err := writeBytes(writer, []byte{deletion}); err != nil { + return err + } + n := binary.PutUvarint(scratch[:], uint64(len(pair.Key))) + if err := writeBytes(writer, scratch[:n]); err != nil { + return err + } + if err := writeBytes(writer, pair.Key); err != nil { + return err + } + if pair.Delete { + continue + } + n = binary.PutUvarint(scratch[:], uint64(len(pair.Value))) + if err := writeBytes(writer, scratch[:n]); err != nil { + return err + } + if err := writeBytes(writer, pair.Value); err != nil { + return err + } + } + return nil +} + +func uvarintLength(value uint64) int { + var scratch [binary.MaxVarintLen64]byte + return binary.PutUvarint(scratch[:], value) +} + +func writeBytes(writer io.Writer, body []byte) error { + for len(body) > 0 { + written, err := writer.Write(body) + if err != nil { + return err + } + if written == 0 { + return io.ErrShortWrite + } + body = body[written:] + } + return nil +} + +func sealDump(stagingPath string, contexts ...dumpContext) (string, dumpManifest, string, error) { + return sealDumpContext(context.Background(), stagingPath, contexts...) +} + +func sealDumpContext(ctx context.Context, stagingPath string, contexts ...dumpContext) (string, dumpManifest, string, error) { + if ctx == nil { + return "", dumpManifest{}, "", fmt.Errorf("seal dump context is required") + } + var err error + stagingPath, err = requireDumpDirectory(stagingPath, ".staging", "dump staging") + if err != nil { + return "", dumpManifest{}, "", err + } + files, err := filepath.Glob(filepath.Join(stagingPath, "evm", "*.zz")) + if err != nil { + return "", dumpManifest{}, "", err + } + if len(files) == 0 { + return "", dumpManifest{}, "", fmt.Errorf("no evm/*.zz files in %s", stagingPath) + } + + manifest := dumpManifest{Schema: dumpManifestSchema, CreatedAt: time.Now().UTC().Format(time.RFC3339Nano)} + if len(contexts) > 1 { + return "", dumpManifest{}, "", fmt.Errorf("multiple dump contexts") + } + if len(contexts) == 1 { + context := contexts[0] + sourcePath := filepath.Join(stagingPath, dumpSourceFileName) + if err := requireRegularFile(sourcePath, "dump source manifest"); err != nil { + return "", dumpManifest{}, "", err + } + source, sourceHash, found, err := loadDumpSource(sourcePath) + if err != nil { + return "", dumpManifest{}, "", err + } + if !found { + return "", dumpManifest{}, "", fmt.Errorf("execution dump staging has no source manifest") + } + if source.Context != context { + return "", dumpManifest{}, "", fmt.Errorf("dump source identity differs from plan") + } + manifest.Schema = context.Schema + manifest.SnapshotID, manifest.ArchiveIdentity = context.SnapshotID, context.ArchiveIdentity + manifest.CronosCommit, manifest.EthermintCommit = context.CronosCommit, context.EthermintCommit + manifest.IAVLCommit = context.IAVLCommit + manifest.ImageDigest, manifest.BuildTags = context.ImageDigest, context.BuildTags + manifest.SourceManifestSHA256 = sourceHash + } + for _, path := range files { + if err := requireRegularFile(path, "dump staging file"); err != nil { + return "", dumpManifest{}, "", err + } + entry, err := scanZlibChangeSetsContext(ctx, path, func(version int64, changeSet *iavl.ChangeSet) error { + if version != 1 { + return nil + } + storage, err := statediff.CanonicalStorageDiff(changeSet) + if err != nil { + return fmt.Errorf("validate genesis changeset: %w", err) + } + if len(storage) != 0 { + return fmt.Errorf("genesis changeset contains EVM storage") + } + return nil + }) + if err != nil { + return "", dumpManifest{}, "", err + } + entry.Path = filepath.Join("evm", entry.Path) + manifest.Files = append(manifest.Files, entry) + } + sort.Slice(manifest.Files, func(i, j int) bool { + return manifest.Files[i].FirstVersion < manifest.Files[j].FirstVersion + }) + manifest.FirstVersion = manifest.Files[0].FirstVersion + manifest.LastVersion = manifest.Files[len(manifest.Files)-1].LastVersion + expectedFirst, expectedLast := int64(1), manifest.LastVersion + if len(contexts) == 1 { + expectedFirst, expectedLast = contexts[0].FirstVersion, contexts[0].LastVersion + } + if manifest.FirstVersion != expectedFirst { + return "", dumpManifest{}, "", fmt.Errorf("dump starts at version %d, want %d", manifest.FirstVersion, expectedFirst) + } + if manifest.LastVersion != expectedLast { + return "", dumpManifest{}, "", fmt.Errorf("dump ends at version %d, want %d", manifest.LastVersion, expectedLast) + } + for i, file := range manifest.Files { + manifest.Records += file.Records + if i > 0 && file.FirstVersion != manifest.Files[i-1].LastVersion+1 { + return "", dumpManifest{}, "", fmt.Errorf("dump gap or overlap between %s and %s", manifest.Files[i-1].Path, file.Path) + } + } + expectedRecords := manifest.LastVersion - manifest.FirstVersion + 1 + if manifest.Records != expectedRecords { + return "", dumpManifest{}, "", fmt.Errorf("dump has %d records for versions %d through %d", manifest.Records, manifest.FirstVersion, manifest.LastVersion) + } + if len(contexts) == 1 { + if err := validateDumpContext(manifest, contexts[0]); err != nil { + return "", dumpManifest{}, "", err + } + } + if err := ctx.Err(); err != nil { + return "", dumpManifest{}, "", err + } + + body, err := atomicJSON(filepath.Join(stagingPath, "dump-manifest.v1.json"), manifest) + if err != nil { + return "", dumpManifest{}, "", err + } + if err := validateDumpArtifactSet(stagingPath, manifest); err != nil { + return "", dumpManifest{}, "", err + } + sealedPath := strings.TrimSuffix(filepath.Clean(stagingPath), ".staging") + ".sealed" + if _, err := os.Lstat(sealedPath); err == nil { + return "", dumpManifest{}, "", fmt.Errorf("sealed dump already exists: %s", sealedPath) + } else if !errors.Is(err, os.ErrNotExist) { + return "", dumpManifest{}, "", err + } + if err := syncDir(stagingPath); err != nil { + return "", dumpManifest{}, "", err + } + if err := ctx.Err(); err != nil { + return "", dumpManifest{}, "", err + } + if err := renameNoReplace(stagingPath, sealedPath); err != nil { + return "", dumpManifest{}, "", err + } + if err := syncDir(filepath.Dir(sealedPath)); err != nil { + return "", dumpManifest{}, "", err + } + return sealedPath, manifest, sha256Hex(body), nil +} + +func prepareDump(path string, expected dumpContext) (string, dumpManifest, string, error) { + return prepareDumpContext(context.Background(), path, expected) +} + +func prepareDumpContext(ctx context.Context, path string, expected dumpContext) (string, dumpManifest, string, error) { + if ctx == nil { + return "", dumpManifest{}, "", fmt.Errorf("prepare dump context is required") + } + cleanPath := filepath.Clean(path) + if strings.HasSuffix(cleanPath, ".staging") { + sealedPath := strings.TrimSuffix(cleanPath, ".staging") + ".sealed" + _, stagingErr := os.Lstat(cleanPath) + _, sealedErr := os.Lstat(sealedPath) + if stagingErr != nil && !errors.Is(stagingErr, os.ErrNotExist) { + return "", dumpManifest{}, "", fmt.Errorf("stat staging dump %s: %w", cleanPath, stagingErr) + } + if sealedErr != nil && !errors.Is(sealedErr, os.ErrNotExist) { + return "", dumpManifest{}, "", fmt.Errorf("stat sealed dump %s: %w", sealedPath, sealedErr) + } + switch { + case stagingErr == nil && sealedErr == nil: + return "", dumpManifest{}, "", fmt.Errorf("both staging and sealed dumps exist: %s and %s", cleanPath, sealedPath) + case stagingErr == nil: + return sealDumpContext(ctx, cleanPath, expected) + case sealedErr == nil: + cleanPath = sealedPath + default: + return "", dumpManifest{}, "", fmt.Errorf("dump does not exist: %s or %s", cleanPath, sealedPath) + } + } + if !strings.HasSuffix(cleanPath, ".sealed") { + return "", dumpManifest{}, "", fmt.Errorf("dump path must end in .staging or .sealed") + } + cleanPath, err := requireSealedDumpDirectory(cleanPath) + if err != nil { + return "", dumpManifest{}, "", err + } + body, err := readRegularFileNoFollow(filepath.Join(cleanPath, "dump-manifest.v1.json"), "sealed dump manifest") + if err != nil { + return "", dumpManifest{}, "", err + } + var manifest dumpManifest + if err := decodeStrictJSON(body, &manifest, "dump manifest"); err != nil { + return "", dumpManifest{}, "", err + } + if err := validateDumpContext(manifest, expected); err != nil { + return "", dumpManifest{}, "", err + } + if err := validateDumpArtifactSet(cleanPath, manifest); err != nil { + return "", dumpManifest{}, "", err + } + if err := iterateSealedDumpContext(ctx, cleanPath, manifest, func(int64, *iavl.ChangeSet) error { return nil }); err != nil { + return "", dumpManifest{}, "", err + } + return cleanPath, manifest, sha256Hex(body), nil +} + +func iterateSealedDump(path string, manifest dumpManifest, fn func(int64, *iavl.ChangeSet) error) error { + return iterateSealedDumpContext(context.Background(), path, manifest, fn) +} + +func iterateSealedDumpContext(ctx context.Context, path string, manifest dumpManifest, fn func(int64, *iavl.ChangeSet) error) error { + if ctx == nil { + return fmt.Errorf("iterate sealed dump context is required") + } + path, err := requireSealedDumpDirectory(path) + if err != nil { + return err + } + if err := validateDumpArtifactSet(path, manifest); err != nil { + return err + } + if manifest.SourceManifestSHA256 != "" { + sourcePath := filepath.Join(path, dumpSourceFileName) + if err := requireRegularFile(sourcePath, "sealed dump source manifest"); err != nil { + return err + } + _, sourceHash, found, err := loadDumpSource(sourcePath) + if err != nil { + return err + } + if !found || sourceHash != manifest.SourceManifestSHA256 { + return fmt.Errorf("sealed dump source manifest changed") + } + } + expected := manifest.FirstVersion + var records int64 + for _, file := range manifest.Files { + filePath, err := requireDumpArtifact(path, file.Path) + if err != nil { + return err + } + entry, err := scanZlibChangeSetsContext(ctx, filePath, func(version int64, changeSet *iavl.ChangeSet) error { + if version != expected { + return fmt.Errorf("dump version %d, want %d", version, expected) + } + expected++ + return fn(version, changeSet) + }) + if err != nil { + return err + } + if entry.SHA256 != file.SHA256 || entry.Size != file.Size || entry.FirstVersion != file.FirstVersion || entry.LastVersion != file.LastVersion || entry.Records != file.Records { + return fmt.Errorf("sealed dump file changed: %s", file.Path) + } + records += entry.Records + } + if expected != manifest.LastVersion+1 { + return fmt.Errorf("dump ended at %d, want %d", expected-1, manifest.LastVersion) + } + expectedRecords := manifest.LastVersion - manifest.FirstVersion + 1 + if records != manifest.Records || records != expectedRecords { + return fmt.Errorf("dump has %d records, manifest has %d for versions %d through %d", records, manifest.Records, manifest.FirstVersion, manifest.LastVersion) + } + return nil +} + +func validateSealedDumpArtifactsContext(ctx context.Context, path string, manifest dumpManifest) error { + if ctx == nil { + return fmt.Errorf("validate sealed dump context is required") + } + path, err := requireSealedDumpDirectory(path) + if err != nil { + return err + } + if manifest.FirstVersion < 1 || manifest.LastVersion < manifest.FirstVersion || + manifest.Records != manifest.LastVersion-manifest.FirstVersion+1 { + return fmt.Errorf("sealed dump range or record count is invalid") + } + if err := validateDumpArtifactSet(path, manifest); err != nil { + return err + } + if manifest.SourceManifestSHA256 != "" { + sourcePath := filepath.Join(path, dumpSourceFileName) + if err := requireRegularFile(sourcePath, "sealed dump source manifest"); err != nil { + return err + } + _, sourceHash, found, err := loadDumpSource(sourcePath) + if err != nil { + return err + } + if !found || sourceHash != manifest.SourceManifestSHA256 { + return fmt.Errorf("sealed dump source manifest changed") + } + } + seen := make(map[string]struct{}, len(manifest.Files)) + expected := manifest.FirstVersion + var records int64 + for _, file := range manifest.Files { + if err := ctx.Err(); err != nil { + return err + } + if _, found := seen[file.Path]; found { + return fmt.Errorf("duplicate sealed dump file %q", file.Path) + } + seen[file.Path] = struct{}{} + filePath, err := requireDumpArtifact(path, file.Path) + if err != nil { + return err + } + if file.FirstVersion != expected || file.LastVersion < file.FirstVersion || + file.Records != file.LastVersion-file.FirstVersion+1 || file.Size < 1 || !validSHA256Hex(file.SHA256) { + return fmt.Errorf("sealed dump file metadata is invalid: %s", file.Path) + } + hash, size, err := hashFileContext(ctx, filePath) + if err != nil { + return err + } + if hash != file.SHA256 || size != file.Size { + return fmt.Errorf("sealed dump file changed: %s", file.Path) + } + if file.Records > manifest.Records-records { + return fmt.Errorf("sealed dump file records exceed manifest: %s", file.Path) + } + expected = file.LastVersion + 1 + records += file.Records + } + if len(manifest.Files) == 0 || expected != manifest.LastVersion+1 || records != manifest.Records || + records != manifest.LastVersion-manifest.FirstVersion+1 { + return fmt.Errorf("sealed dump file coverage differs from manifest") + } + return nil +} + +func validateDumpArtifactSet(path string, manifest dumpManifest) error { + rootEntries, err := os.ReadDir(path) + if err != nil { + return err + } + allowedRoot := map[string]struct{}{"evm": {}, "dump-manifest.v1.json": {}} + if manifest.SourceManifestSHA256 != "" { + allowedRoot[dumpSourceFileName] = struct{}{} + } + for _, entry := range rootEntries { + if _, ok := allowedRoot[entry.Name()]; !ok { + return fmt.Errorf("unexpected dump artifact: %s", entry.Name()) + } + } + wantFiles := make(map[string]struct{}, len(manifest.Files)) + for _, file := range manifest.Files { + if _, err := requireDumpArtifact(path, file.Path); err != nil { + return err + } + if _, found := wantFiles[file.Path]; found { + return fmt.Errorf("duplicate dump file %q", file.Path) + } + wantFiles[file.Path] = struct{}{} + } + evmEntries, err := os.ReadDir(filepath.Join(path, "evm")) + if err != nil { + return err + } + if len(evmEntries) != len(wantFiles) { + return fmt.Errorf("dump EVM artifact set differs from manifest") + } + for _, entry := range evmEntries { + name := filepath.Join("evm", entry.Name()) + if _, ok := wantFiles[name]; !ok { + return fmt.Errorf("unexpected dump EVM artifact: %s", entry.Name()) + } + if err := requireRegularFile(filepath.Join(path, name), "dump EVM artifact"); err != nil { + return err + } + } + return nil +} + +func validateSealedDumpManifest(path string, expected dumpManifest, expectedHash string) error { + body, err := readRegularFileNoFollow(filepath.Join(path, "dump-manifest.v1.json"), "sealed dump manifest") + if err != nil { + return err + } + if sha256Hex(body) != expectedHash { + return fmt.Errorf("sealed dump manifest changed") + } + var current dumpManifest + if err := decodeStrictJSON(body, ¤t, "dump manifest"); err != nil { + return err + } + if !reflect.DeepEqual(current, expected) { + return fmt.Errorf("sealed dump manifest identity changed") + } + return nil +} + +func validateDumpContext(manifest dumpManifest, context dumpContext) error { + if err := validateDumpSourceContext(context); err != nil { + return err + } + if !validCreatedAt(manifest.CreatedAt) { + return fmt.Errorf("dump manifest creation time is missing or malformed") + } + checks := []struct { + name string + equal bool + got any + want any + }{ + {"schema", manifest.Schema == context.Schema, manifest.Schema, context.Schema}, + {"first version", manifest.FirstVersion == context.FirstVersion, manifest.FirstVersion, context.FirstVersion}, + {"last version", manifest.LastVersion == context.LastVersion, manifest.LastVersion, context.LastVersion}, + {"snapshot ID", manifest.SnapshotID == context.SnapshotID, manifest.SnapshotID, context.SnapshotID}, + {"archive identity", manifest.ArchiveIdentity == context.ArchiveIdentity, manifest.ArchiveIdentity, context.ArchiveIdentity}, + {"Cronos commit", manifest.CronosCommit == context.CronosCommit, manifest.CronosCommit, context.CronosCommit}, + {"Ethermint commit", manifest.EthermintCommit == context.EthermintCommit, manifest.EthermintCommit, context.EthermintCommit}, + {"IAVL commit", manifest.IAVLCommit == context.IAVLCommit, manifest.IAVLCommit, context.IAVLCommit}, + {"image digest", manifest.ImageDigest == context.ImageDigest, manifest.ImageDigest, context.ImageDigest}, + {"build tags", manifest.BuildTags == context.BuildTags, manifest.BuildTags, context.BuildTags}, + } + for _, check := range checks { + if !check.equal { + return fmt.Errorf("sealed dump identity does not match this run: %s is %v, want %v", check.name, check.got, check.want) + } + } + if manifest.SnapshotID == "" || manifest.ImageDigest == "" { + return fmt.Errorf("snapshot ID and image digest are required to seal an execution dump") + } + if manifest.SourceManifestSHA256 == "" { + return fmt.Errorf("execution dump has no source manifest hash") + } + if manifest.Schema == dumpManifestSchema && manifest.ArchiveIdentity.LatestVersion != manifest.LastVersion { + return fmt.Errorf("dump final version %d differs from archive %d", manifest.LastVersion, manifest.ArchiveIdentity.LatestVersion) + } + if manifest.LastVersion > manifest.ArchiveIdentity.LatestVersion { + return fmt.Errorf("dump final version %d exceeds archive %d", manifest.LastVersion, manifest.ArchiveIdentity.LatestVersion) + } + return nil +} diff --git a/cmd/statediff-rewriter/dump_source.go b/cmd/statediff-rewriter/dump_source.go new file mode 100644 index 00000000..f1c9ee3c --- /dev/null +++ b/cmd/statediff-rewriter/dump_source.go @@ -0,0 +1,137 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +const ( + dumpSourceSchema = "cronos-changeset-dump-source/v1" + dumpSourceFileName = "dump-source.v1.json" +) + +type dumpSourceManifest struct { + Schema string `json:"schema"` + Checksum string `json:"checksum"` + CreatedAt string `json:"created_at"` + Context dumpContext `json:"context"` +} + +func dumpSourceChecksum(source dumpSourceManifest) (string, error) { + source.Checksum = "" + body, err := json.Marshal(source) + if err != nil { + return "", err + } + return sha256Hex(body), nil +} + +func validateDumpSourceContext(context dumpContext) error { + if context.Schema != dumpManifestSchema && context.Schema != pilotDumpManifestSchema { + return fmt.Errorf("unsupported dump source schema %q", context.Schema) + } + if context.FirstVersion < 1 || context.LastVersion < context.FirstVersion { + return fmt.Errorf("invalid dump source range %d-%d", context.FirstVersion, context.LastVersion) + } + if context.SnapshotID == "" || context.ArchiveIdentity.Home == "" || + context.ArchiveIdentity.DatabaseIdentity == "" || + context.ArchiveIdentity.LatestVersion < context.LastVersion || context.ArchiveIdentity.FinalCommitHash == "" { + return fmt.Errorf("dump source identity is incomplete") + } + if err := validateBuildIdentity( + context.CronosCommit, context.EthermintCommit, context.IAVLCommit, context.ImageDigest, context.BuildTags, + ); err != nil { + return err + } + if context.Schema == dumpManifestSchema && + (context.FirstVersion != 1 || context.LastVersion != context.ArchiveIdentity.LatestVersion) { + return fmt.Errorf("full dump source does not cover 1 through archive latest") + } + return nil +} + +func ensureDumpSource(output string, expected dumpContext) (string, error) { + if err := validateDumpSourceContext(expected); err != nil { + return "", err + } + path := filepath.Join(output, dumpSourceFileName) + source, hash, found, err := loadDumpSource(path) + if err != nil { + return "", err + } + if found { + if source.Context != expected { + return "", fmt.Errorf("dump source identity differs from this extraction") + } + return hash, nil + } + entries, err := os.ReadDir(output) + if err != nil { + return "", err + } + for _, entry := range entries { + if entry.Name() != "evm" { + return "", fmt.Errorf("dump staging has %s but no source manifest", entry.Name()) + } + evmEntries, err := os.ReadDir(filepath.Join(output, "evm")) + if err != nil { + return "", err + } + if len(evmEntries) != 0 { + return "", fmt.Errorf("dump staging contains EVM output but no source manifest") + } + } + source = dumpSourceManifest{ + Schema: dumpSourceSchema, CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), Context: expected, + } + checksum, err := dumpSourceChecksum(source) + if err != nil { + return "", err + } + source.Checksum = checksum + body, err := atomicJSONNoReplace(path, source) + if err != nil { + return "", err + } + return sha256Hex(body), nil +} + +func loadDumpSource(path string) (dumpSourceManifest, string, bool, error) { + body, err := readRegularFileNoFollow(path, "dump source manifest") + if errors.Is(err, os.ErrNotExist) { + return dumpSourceManifest{}, "", false, nil + } + if err != nil { + return dumpSourceManifest{}, "", false, err + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + var source dumpSourceManifest + if err := decoder.Decode(&source); err != nil { + return dumpSourceManifest{}, "", false, fmt.Errorf("decode dump source: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return dumpSourceManifest{}, "", false, fmt.Errorf("dump source has trailing JSON") + } + if source.Schema != dumpSourceSchema || !validCreatedAt(source.CreatedAt) { + return dumpSourceManifest{}, "", false, fmt.Errorf("unsupported or incomplete dump source manifest") + } + checksum, err := dumpSourceChecksum(source) + if err != nil { + return dumpSourceManifest{}, "", false, err + } + if source.Checksum == "" || source.Checksum != checksum { + return dumpSourceManifest{}, "", false, fmt.Errorf("dump source checksum mismatch") + } + if err := validateDumpSourceContext(source.Context); err != nil { + return dumpSourceManifest{}, "", false, err + } + return source, sha256Hex(body), true, nil +} diff --git a/cmd/statediff-rewriter/dump_source_test.go b/cmd/statediff-rewriter/dump_source_test.go new file mode 100644 index 00000000..d57d773b --- /dev/null +++ b/cmd/statediff-rewriter/dump_source_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func validTestDumpContext() dumpContext { + return dumpContext{ + Schema: pilotDumpManifestSchema, FirstVersion: 100, LastVersion: 109, + SnapshotID: "snap-test", + ArchiveIdentity: archiveIdentity{ + Home: "/archive", DatabaseIdentity: "db-test", LatestVersion: 200, FinalCommitHash: "0x200", + }, + CronosCommit: testCronosCommit, EthermintCommit: testEthermintCommit, IAVLCommit: testIAVLCommit, + ImageDigest: testImageDigest, BuildTags: testBuildTags, + } +} + +func TestEnsureDumpSourcePinsIdentityBeforeOutput(t *testing.T) { + staging := filepath.Join(t.TempDir(), "dump.staging") + require.NoError(t, os.MkdirAll(filepath.Join(staging, "evm"), 0o755)) + context := validTestDumpContext() + hash, err := ensureDumpSource(staging, context) + require.NoError(t, err) + require.Len(t, hash, 64) + + reusedHash, err := ensureDumpSource(staging, context) + require.NoError(t, err) + require.Equal(t, hash, reusedHash) + + context.SnapshotID = "snap-other" + _, err = ensureDumpSource(staging, context) + require.ErrorContains(t, err, "identity differs") +} + +func TestEnsureDumpSourceRefusesToRelabelExistingOutput(t *testing.T) { + staging := filepath.Join(t.TempDir(), "dump.staging") + writeDumpFile(t, staging, "block-100.zz", []byte("existing")) + _, err := ensureDumpSource(staging, validTestDumpContext()) + require.ErrorContains(t, err, "contains EVM output but no source manifest") +} + +func TestLoadDumpSourceRejectsMutationUnknownAndTrailingJSON(t *testing.T) { + staging := filepath.Join(t.TempDir(), "dump.staging") + require.NoError(t, os.MkdirAll(filepath.Join(staging, "evm"), 0o755)) + _, err := ensureDumpSource(staging, validTestDumpContext()) + require.NoError(t, err) + path := filepath.Join(staging, dumpSourceFileName) + body, err := os.ReadFile(path) + require.NoError(t, err) + + mutated := append([]byte(nil), body...) + mutated[len(mutated)-3] ^= 1 + require.NoError(t, os.WriteFile(path, mutated, 0o600)) + _, _, _, err = loadDumpSource(path) + require.Error(t, err) + + writeTestDumpSource(t, staging, validTestDumpContext()) + body, err = os.ReadFile(path) + require.NoError(t, err) + unknown := append(append([]byte(nil), body[:len(body)-2]...), []byte(",\n \"unknown\": true\n}\n")...) + require.NoError(t, os.WriteFile(path, unknown, 0o600)) + _, _, _, err = loadDumpSource(path) + require.ErrorContains(t, err, "unknown field") + + writeTestDumpSource(t, staging, validTestDumpContext()) + body, err = os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(body, []byte("{}")...), 0o600)) + _, _, _, err = loadDumpSource(path) + require.ErrorContains(t, err, "trailing JSON") + + writeTestDumpSource(t, staging, validTestDumpContext()) + source, _, found, err := loadDumpSource(path) + require.NoError(t, err) + require.True(t, found) + source.CreatedAt = "not-a-time" + source.Checksum, err = dumpSourceChecksum(source) + require.NoError(t, err) + _, err = atomicJSON(path, source) + require.NoError(t, err) + _, _, _, err = loadDumpSource(path) + require.ErrorContains(t, err, "incomplete dump source manifest") +} diff --git a/cmd/statediff-rewriter/dump_test.go b/cmd/statediff-rewriter/dump_test.go new file mode 100644 index 00000000..5faa0b80 --- /dev/null +++ b/cmd/statediff-rewriter/dump_test.go @@ -0,0 +1,291 @@ +package main + +import ( + "bytes" + "compress/zlib" + "encoding/binary" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/cosmos/iavl" + "github.com/stretchr/testify/require" +) + +func encodeChangeSet(t *testing.T, version int64, pairs ...*iavl.KVPair) []byte { + t.Helper() + var payload bytes.Buffer + for _, pair := range pairs { + if pair.Delete { + payload.WriteByte(1) + } else { + payload.WriteByte(0) + } + var scratch [10]byte + n := binary.PutUvarint(scratch[:], uint64(len(pair.Key))) + payload.Write(scratch[:n]) + payload.Write(pair.Key) + if !pair.Delete { + n = binary.PutUvarint(scratch[:], uint64(len(pair.Value))) + payload.Write(scratch[:n]) + payload.Write(pair.Value) + } + } + var result bytes.Buffer + var header [16]byte + binary.LittleEndian.PutUint64(header[:8], uint64(version)) + binary.LittleEndian.PutUint64(header[8:], uint64(payload.Len())) + result.Write(header[:]) + result.Write(payload.Bytes()) + return result.Bytes() +} + +func zlibBody(t *testing.T, body []byte) []byte { + t.Helper() + var compressed bytes.Buffer + writer := zlib.NewWriter(&compressed) + _, err := writer.Write(body) + require.NoError(t, err) + require.NoError(t, writer.Close()) + return compressed.Bytes() +} + +func writeDumpFile(t *testing.T, staging, name string, body []byte) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(staging, "evm"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(staging, "evm", name), body, 0o600)) +} + +func writeTestDumpSource(t *testing.T, staging string, context dumpContext) { + t.Helper() + source := dumpSourceManifest{Schema: dumpSourceSchema, CreatedAt: "2026-07-22T00:00:00Z", Context: context} + checksum, err := dumpSourceChecksum(source) + require.NoError(t, err) + source.Checksum = checksum + _, err = atomicJSON(filepath.Join(staging, dumpSourceFileName), source) + require.NoError(t, err) +} + +func TestSealDumpSortsByFirstVersion(t *testing.T) { + staging := filepath.Join(t.TempDir(), "dump.staging") + var versions2To9 []byte + for version := int64(2); version <= 9; version++ { + versions2To9 = append(versions2To9, encodeChangeSet(t, version)...) + } + writeDumpFile(t, staging, "block-10.zz", zlibBody(t, encodeChangeSet(t, 10))) + writeDumpFile(t, staging, "block-2.zz", zlibBody(t, versions2To9)) + writeDumpFile(t, staging, "block-1.zz", zlibBody(t, encodeChangeSet(t, 1))) + + sealed, manifest, hash, err := sealDump(staging) + require.NoError(t, err) + require.Equal(t, int64(1), manifest.FirstVersion) + require.Equal(t, int64(10), manifest.LastVersion) + require.Equal(t, "evm/block-1.zz", manifest.Files[0].Path) + require.Equal(t, "evm/block-2.zz", manifest.Files[1].Path) + require.Equal(t, "evm/block-10.zz", manifest.Files[2].Path) + require.NotEmpty(t, hash) + require.DirExists(t, sealed) + require.NoDirExists(t, staging) +} + +func TestScanZlibChangeSetsRejectsTruncationAndCorruption(t *testing.T) { + for length := 1; length <= 15; length++ { + t.Run("partial-header-"+strconv.Itoa(length), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "partial.zz") + require.NoError(t, os.WriteFile(path, zlibBody(t, make([]byte, length)), 0o600)) + _, err := scanZlibChangeSets(path, nil) + require.Error(t, err) + }) + } + + t.Run("short payload", func(t *testing.T) { + var header [16]byte + binary.LittleEndian.PutUint64(header[:8], 1) + binary.LittleEndian.PutUint64(header[8:], 10) + path := filepath.Join(t.TempDir(), "short.zz") + require.NoError(t, os.WriteFile(path, zlibBody(t, append(header[:], 1)), 0o600)) + _, err := scanZlibChangeSets(path, nil) + require.Error(t, err) + }) + + t.Run("bad checksum", func(t *testing.T) { + body := zlibBody(t, encodeChangeSet(t, 1)) + body[len(body)-1] ^= 0xff + path := filepath.Join(t.TempDir(), "checksum.zz") + require.NoError(t, os.WriteFile(path, body, 0o600)) + _, err := scanZlibChangeSets(path, nil) + require.Error(t, err) + }) + + t.Run("missing trailer", func(t *testing.T) { + body := zlibBody(t, encodeChangeSet(t, 1)) + path := filepath.Join(t.TempDir(), "truncated.zz") + require.NoError(t, os.WriteFile(path, body[:len(body)-2], 0o600)) + _, err := scanZlibChangeSets(path, nil) + require.Error(t, err) + }) + + t.Run("trailing data", func(t *testing.T) { + body := append(zlibBody(t, encodeChangeSet(t, 1)), []byte("garbage")...) + path := filepath.Join(t.TempDir(), "trailing.zz") + require.NoError(t, os.WriteFile(path, body, 0o600)) + _, err := scanZlibChangeSets(path, nil) + require.ErrorContains(t, err, "trailing data") + }) + + t.Run("large trailing data", func(t *testing.T) { + body := append(zlibBody(t, encodeChangeSet(t, 1)), bytes.Repeat([]byte{0x5a}, 2<<20)...) + path := filepath.Join(t.TempDir(), "large-trailing.zz") + require.NoError(t, os.WriteFile(path, body, 0o600)) + _, err := scanZlibChangeSets(path, nil) + require.ErrorContains(t, err, "trailing data") + }) + + t.Run("concatenated stream", func(t *testing.T) { + body := append(zlibBody(t, encodeChangeSet(t, 1)), zlibBody(t, encodeChangeSet(t, 2))...) + path := filepath.Join(t.TempDir(), "concatenated.zz") + require.NoError(t, os.WriteFile(path, body, 0o600)) + _, err := scanZlibChangeSets(path, nil) + require.ErrorContains(t, err, "trailing data") + }) +} + +func TestSealDumpRejectsGenesisStorage(t *testing.T) { + staging := filepath.Join(t.TempDir(), "dump.staging") + key := make([]byte, 53) + key[0] = 0x02 + writeDumpFile(t, staging, "block-1.zz", zlibBody(t, encodeChangeSet(t, 1, &iavl.KVPair{Key: key, Value: []byte{1}}))) + _, _, _, err := sealDump(staging) + require.ErrorContains(t, err, "genesis changeset contains EVM storage") +} + +func TestSealDumpAcceptsExactPilotRange(t *testing.T) { + staging := filepath.Join(t.TempDir(), "dump.staging") + var body []byte + for version := int64(100); version <= 109; version++ { + body = append(body, encodeChangeSet(t, version)...) + } + writeDumpFile(t, staging, "block-100.zz", zlibBody(t, body)) + context := dumpContext{ + Schema: pilotDumpManifestSchema, FirstVersion: 100, LastVersion: 109, + SnapshotID: "snap-test", ArchiveIdentity: archiveIdentity{ + Home: "/archive", DatabaseIdentity: "db-test", LatestVersion: 200, FinalCommitHash: "0x200", + }, + CronosCommit: testCronosCommit, EthermintCommit: testEthermintCommit, IAVLCommit: testIAVLCommit, + ImageDigest: testImageDigest, BuildTags: testBuildTags, + } + writeTestDumpSource(t, staging, context) + + sealed, manifest, _, err := sealDump(staging, context) + require.NoError(t, err) + require.Equal(t, pilotDumpManifestSchema, manifest.Schema) + require.Equal(t, int64(100), manifest.FirstVersion) + require.Equal(t, int64(109), manifest.LastVersion) + require.Equal(t, int64(10), manifest.Records) + + var versions []int64 + require.NoError(t, iterateSealedDump(sealed, manifest, func(version int64, _ *iavl.ChangeSet) error { + versions = append(versions, version) + return nil + })) + require.Equal(t, []int64{100, 101, 102, 103, 104, 105, 106, 107, 108, 109}, versions) + + context.SnapshotID = "snap-other" + _, _, _, err = prepareDump(sealed, context) + require.ErrorContains(t, err, "identity does not match this run: snapshot ID") +} + +func TestPrepareDumpResumesSealedSibling(t *testing.T) { + staging := filepath.Join(t.TempDir(), "dump.staging") + writeDumpFile(t, staging, "block-100.zz", zlibBody(t, encodeChangeSet(t, 100))) + context := dumpContext{ + Schema: pilotDumpManifestSchema, FirstVersion: 100, LastVersion: 100, + SnapshotID: "snap-test", ArchiveIdentity: archiveIdentity{ + Home: "/archive", DatabaseIdentity: "db-test", LatestVersion: 200, FinalCommitHash: "0x200", + }, + CronosCommit: testCronosCommit, EthermintCommit: testEthermintCommit, IAVLCommit: testIAVLCommit, + ImageDigest: testImageDigest, BuildTags: testBuildTags, + } + writeTestDumpSource(t, staging, context) + + sealed, wantManifest, wantHash, err := sealDump(staging, context) + require.NoError(t, err) + gotPath, gotManifest, gotHash, err := prepareDump(staging, context) + require.NoError(t, err) + require.Equal(t, sealed, gotPath) + require.Equal(t, wantManifest, gotManifest) + require.Equal(t, wantHash, gotHash) +} + +func TestPrepareDumpRejectsAmbiguousOrInvalidSealedSibling(t *testing.T) { + newSealedDump := func(t *testing.T) (string, string, dumpContext) { + t.Helper() + staging := filepath.Join(t.TempDir(), "dump.staging") + writeDumpFile(t, staging, "block-100.zz", zlibBody(t, encodeChangeSet(t, 100))) + context := dumpContext{ + Schema: pilotDumpManifestSchema, FirstVersion: 100, LastVersion: 100, + SnapshotID: "snap-test", ArchiveIdentity: archiveIdentity{ + Home: "/archive", DatabaseIdentity: "db-test", LatestVersion: 200, FinalCommitHash: "0x200", + }, + CronosCommit: testCronosCommit, EthermintCommit: testEthermintCommit, IAVLCommit: testIAVLCommit, + ImageDigest: testImageDigest, BuildTags: testBuildTags, + } + writeTestDumpSource(t, staging, context) + sealed, _, _, err := sealDump(staging, context) + require.NoError(t, err) + return staging, sealed, context + } + + t.Run("both staging and sealed exist", func(t *testing.T) { + staging, sealed, context := newSealedDump(t) + require.NoError(t, os.MkdirAll(staging, 0o755)) + + _, _, _, err := prepareDump(staging, context) + require.ErrorContains(t, err, "both staging and sealed dumps exist") + require.DirExists(t, staging) + require.DirExists(t, sealed) + }) + + t.Run("sealed sibling is corrupt", func(t *testing.T) { + staging, sealed, context := newSealedDump(t) + file := filepath.Join(sealed, "evm", "block-100.zz") + require.NoError(t, os.WriteFile(file, []byte("not zlib"), 0o600)) + + _, _, _, err := prepareDump(staging, context) + require.Error(t, err) + }) +} + +func TestSealDumpRejectsPilotRangeMismatch(t *testing.T) { + for _, testCase := range []struct { + name string + first int64 + last int64 + }{ + {name: "starts-before", first: 99, last: 109}, + {name: "starts-after", first: 101, last: 109}, + {name: "ends-before", first: 100, last: 108}, + {name: "ends-after", first: 100, last: 110}, + } { + t.Run(testCase.name, func(t *testing.T) { + staging := filepath.Join(t.TempDir(), "dump.staging") + var body []byte + for version := testCase.first; version <= testCase.last; version++ { + body = append(body, encodeChangeSet(t, version)...) + } + writeDumpFile(t, staging, "block.zz", zlibBody(t, body)) + context := dumpContext{ + Schema: pilotDumpManifestSchema, FirstVersion: 100, LastVersion: 109, + SnapshotID: "snap-test", ArchiveIdentity: archiveIdentity{ + Home: "/archive", DatabaseIdentity: "db-test", LatestVersion: 200, FinalCommitHash: "0x200", + }, + CronosCommit: testCronosCommit, EthermintCommit: testEthermintCommit, IAVLCommit: testIAVLCommit, + ImageDigest: testImageDigest, BuildTags: testBuildTags, + } + writeTestDumpSource(t, staging, context) + _, _, _, err := sealDump(staging, context) + require.Error(t, err) + }) + } +} diff --git a/cmd/statediff-rewriter/external_sort.go b/cmd/statediff-rewriter/external_sort.go new file mode 100644 index 00000000..297a6bf5 --- /dev/null +++ b/cmd/statediff-rewriter/external_sort.go @@ -0,0 +1,604 @@ +package main + +import ( + "bufio" + "container/heap" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "math" + "os" + "path/filepath" + "sort" + "syscall" + + "github.com/golang/snappy" +) + +const ( + externalSortBufferSize = 64 << 10 + externalRecordMemoryBytes = int64(32) + externalRecordLengthBytes = 4 + externalSortDirectoryMatch = ".statediff-sort-*" +) + +// externalSorter bounds its in-memory run by maxChunkBytes. The accounting +// includes each copied record and a fixed allowance for its slice and framing. +// Runs are written synchronously, so at most one run is being written at once. +type externalSorter struct { + ctx context.Context + less func([]byte, []byte) bool + maxChunkBytes int64 + maxChunks int + minFree uint64 + allocationUnit uint64 + workDir string + chunkBytes map[string]uint64 + ensureSpace func(string, uint64) error + records [][]byte + bufferedBytes int64 + chunks []string + nextChunk uint64 + finalized bool + closed bool + failed error + merge *externalMergeReader +} + +// newExternalSorter creates a sorter whose merge phase reads at most maxChunks +// source runs at once. Merge memory is bounded by one record and one fixed-size +// read buffer per source run. +func newExternalSorter( + dir string, + maxChunkBytes int64, + maxChunks int, + less func([]byte, []byte) bool, +) (*externalSorter, error) { + return newExternalSorterWithContext(context.Background(), dir, maxChunkBytes, maxChunks, 0, less) +} + +func newExternalSorterWithContext( + ctx context.Context, + dir string, + maxChunkBytes int64, + maxChunks int, + minFree uint64, + less func([]byte, []byte) bool, +) (*externalSorter, error) { + if ctx == nil { + return nil, fmt.Errorf("external sorter context is required") + } + if dir == "" { + return nil, fmt.Errorf("external sorter directory is required") + } + if maxChunkBytes < externalRecordMemoryBytes { + return nil, fmt.Errorf("external sorter max chunk bytes must be at least %d", externalRecordMemoryBytes) + } + if maxChunks < 2 { + return nil, fmt.Errorf("external sorter max chunks must be at least 2") + } + if less == nil { + return nil, fmt.Errorf("external sorter comparison function is required") + } + workDir, err := os.MkdirTemp(dir, externalSortDirectoryMatch) + if err != nil { + return nil, fmt.Errorf("create external sorter directory: %w", err) + } + allocationUnit := uint64(1) + if minFree != 0 { + var filesystem syscall.Statfs_t + if err := syscall.Statfs(workDir, &filesystem); err != nil { + _ = os.RemoveAll(workDir) + return nil, fmt.Errorf("inspect external sorter filesystem: %w", err) + } + if filesystem.Bsize <= 0 { + _ = os.RemoveAll(workDir) + return nil, fmt.Errorf("external sorter filesystem has invalid allocation unit %d", filesystem.Bsize) + } + allocationUnit = uint64(filesystem.Bsize) + } + return &externalSorter{ + ctx: ctx, less: less, maxChunkBytes: maxChunkBytes, maxChunks: maxChunks, minFree: minFree, + allocationUnit: allocationUnit, workDir: workDir, + chunkBytes: make(map[string]uint64), ensureSpace: ensureFreeSpace, + }, nil +} + +// Feed copies record. If adding it would exceed maxChunkBytes, Feed sorts and +// writes the current run before returning. +func (s *externalSorter) Feed(record []byte) error { + if s == nil { + return fmt.Errorf("external sorter is nil") + } + if s.closed { + return fmt.Errorf("external sorter is closed") + } + if s.failed != nil { + return s.failed + } + if err := s.ctx.Err(); err != nil { + return s.fail(err) + } + if s.finalized { + return fmt.Errorf("external sorter is finalized") + } + if uint64(len(record)) > math.MaxUint32 { + return s.fail(fmt.Errorf("external sorter record length %d exceeds uint32", len(record))) + } + recordBytes := int64(len(record)) + externalRecordMemoryBytes + if recordBytes > s.maxChunkBytes { + return s.fail(fmt.Errorf("external sorter record requires %d bytes, exceeding chunk limit %d", recordBytes, s.maxChunkBytes)) + } + if len(s.records) > 0 && s.bufferedBytes+recordBytes > s.maxChunkBytes { + if err := s.flushChunk(); err != nil { + return s.fail(err) + } + } + copyOfRecord := append([]byte(nil), record...) + s.records = append(s.records, copyOfRecord) + s.bufferedBytes += recordBytes + return nil +} + +// Finalize writes the final run and returns a k-way merge reader. The reader +// owns the temporary runs after Finalize succeeds. +func (s *externalSorter) Finalize() (*externalMergeReader, error) { + if s == nil { + return nil, fmt.Errorf("external sorter is nil") + } + if s.closed { + return nil, fmt.Errorf("external sorter is closed") + } + if s.failed != nil { + return nil, s.failed + } + if s.finalized { + return nil, fmt.Errorf("external sorter is already finalized") + } + if err := s.flushChunk(); err != nil { + return nil, s.fail(err) + } + if err := s.mergePasses(); err != nil { + return nil, s.fail(err) + } + merge, err := newExternalMergeReader(s) + if err != nil { + return nil, s.fail(err) + } + s.finalized = true + s.merge = merge + return merge, nil +} + +// Close releases open readers and removes all temporary runs. It is idempotent. +func (s *externalSorter) Close() error { + if s == nil || s.closed { + return nil + } + if s.merge != nil { + return s.merge.Close() + } + return s.cleanup() +} + +func (s *externalSorter) fail(err error) error { + if s.failed == nil { + s.failed = err + } + return s.failed +} + +func (s *externalSorter) flushChunk() error { + if len(s.records) == 0 { + return nil + } + if err := s.ctx.Err(); err != nil { + return err + } + sort.Slice(s.records, func(i, j int) bool { + return s.less(s.records[i], s.records[j]) + }) + if err := s.ctx.Err(); err != nil { + return err + } + var logicalBytes uint64 + for _, record := range s.records { + recordBytes := uint64(externalRecordLengthBytes + len(record)) + if logicalBytes > math.MaxUint64-recordBytes { + return fmt.Errorf("external sort chunk size overflows uint64") + } + logicalBytes += recordBytes + } + if err := s.ensureOutputSpace(logicalBytes); err != nil { + return err + } + path := s.nextChunkPath() + if err := writeExternalChunk(s.ctx, path, s.records); err != nil { + return fmt.Errorf("write external sort chunk %d: %w", len(s.chunks), err) + } + s.chunks = append(s.chunks, path) + s.chunkBytes[path] = logicalBytes + s.records = nil + s.bufferedBytes = 0 + return nil +} + +func (s *externalSorter) ensureOutputSpace(logicalBytes uint64) error { + if s.minFree == 0 { + return nil + } + blocks := logicalBytes / (64 << 10) + if logicalBytes%(64<<10) != 0 { + blocks++ + } + if blocks > (math.MaxUint64-10)/8 || logicalBytes > math.MaxUint64-blocks*8-10 { + return fmt.Errorf("external sort output size overflows uint64") + } + encodedUpperBound := logicalBytes + blocks*8 + 10 + allocationBlocks := encodedUpperBound / s.allocationUnit + if encodedUpperBound%s.allocationUnit != 0 { + allocationBlocks++ + } + maxBlocks := math.MaxUint64 / s.allocationUnit + if maxBlocks < 2 || allocationBlocks > maxBlocks-2 { + return fmt.Errorf("external sort allocated output size overflows uint64") + } + allocatedUpperBound := (allocationBlocks + 2) * s.allocationUnit + if s.minFree > math.MaxUint64-allocatedUpperBound { + return fmt.Errorf("external sort free-space requirement overflows uint64") + } + return s.ensureSpace(s.workDir, s.minFree+allocatedUpperBound) +} + +func (s *externalSorter) nextChunkPath() string { + path := filepath.Join(s.workDir, fmt.Sprintf("chunk-%012d.snappy", s.nextChunk)) + s.nextChunk++ + return path +} + +// mergePasses reduces an arbitrary number of initial runs to maxChunks runs. +// It merges the smallest number of runs needed at each step and chooses the +// smallest runs first. Inputs are removed only after their replacement is +// durable, bounding peak disk use to current runs plus that selected group. +func (s *externalSorter) mergePasses() error { + for len(s.chunks) > s.maxChunks { + if err := s.ctx.Err(); err != nil { + return err + } + sort.Slice(s.chunks, func(i, j int) bool { + left, right := s.chunkBytes[s.chunks[i]], s.chunkBytes[s.chunks[j]] + if left != right { + return left < right + } + return s.chunks[i] < s.chunks[j] + }) + mergeCount := len(s.chunks) - s.maxChunks + 1 + if mergeCount > s.maxChunks { + mergeCount = s.maxChunks + } + inputs := append([]string(nil), s.chunks[:mergeCount]...) + var logicalBytes uint64 + for _, input := range inputs { + inputBytes := s.chunkBytes[input] + if logicalBytes > math.MaxUint64-inputBytes { + return fmt.Errorf("merged external sort output size overflows uint64") + } + logicalBytes += inputBytes + } + if err := s.ensureOutputSpace(logicalBytes); err != nil { + return err + } + output := s.nextChunkPath() + if err := writeMergedExternalChunk(s.ctx, output, inputs, s.maxChunkBytes, s.less); err != nil { + return fmt.Errorf("merge %d external sort chunks: %w", len(inputs), err) + } + for _, path := range inputs { + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove merged external sort chunk %q: %w", path, err) + } + delete(s.chunkBytes, path) + } + s.chunkBytes[output] = logicalBytes + s.chunks = append(append([]string(nil), s.chunks[mergeCount:]...), output) + } + return nil +} + +func (s *externalSorter) cleanup() error { + if s.closed { + return nil + } + s.closed = true + s.records = nil + s.chunks = nil + s.chunkBytes = nil + if err := os.RemoveAll(s.workDir); err != nil { + return fmt.Errorf("remove external sorter directory: %w", err) + } + return nil +} + +func writeExternalChunk(ctx context.Context, path string, records [][]byte) error { + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + ok := false + defer func() { + if !ok { + _ = os.Remove(path) + } + }() + buffered := bufio.NewWriterSize(file, externalSortBufferSize) + compressed := snappy.NewBufferedWriter(buffered) + var writeErr error + for _, record := range records { + if writeErr = ctx.Err(); writeErr != nil { + break + } + if writeErr = writeExternalRecord(compressed, record); writeErr != nil { + break + } + } + err = errors.Join(writeErr, compressed.Close(), buffered.Flush(), file.Sync(), file.Close()) + if err != nil { + return err + } + ok = true + return nil +} + +func writeMergedExternalChunk( + ctx context.Context, + path string, + inputs []string, + maxChunkBytes int64, + less func([]byte, []byte) bool, +) error { + reader, err := newExternalMergeReaderForChunks(ctx, inputs, maxChunkBytes, less) + if err != nil { + return err + } + temporary := path + ".partial" + file, err := os.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + _ = reader.Close() + return err + } + ok := false + defer func() { + if !ok { + _ = os.Remove(temporary) + _ = os.Remove(path) + } + }() + buffered := bufio.NewWriterSize(file, externalSortBufferSize) + compressed := snappy.NewBufferedWriter(buffered) + var writeErr error + for { + if writeErr = ctx.Err(); writeErr != nil { + break + } + record, readErr := reader.Next() + if errors.Is(readErr, io.EOF) { + break + } + if readErr != nil { + writeErr = readErr + break + } + if writeErr = writeExternalRecord(compressed, record); writeErr != nil { + break + } + } + err = errors.Join(writeErr, compressed.Close(), buffered.Flush(), file.Sync(), file.Close(), reader.Close()) + if err != nil { + return err + } + if err := os.Rename(temporary, path); err != nil { + return err + } + if err := syncDir(filepath.Dir(path)); err != nil { + return err + } + ok = true + return nil +} + +func writeExternalRecord(writer io.Writer, record []byte) error { + var header [externalRecordLengthBytes]byte + binary.BigEndian.PutUint32(header[:], uint32(len(record))) + if err := writeBytes(writer, header[:]); err != nil { + return fmt.Errorf("write record length: %w", err) + } + if err := writeBytes(writer, record); err != nil { + return fmt.Errorf("write record body: %w", err) + } + return nil +} + +func readExternalRecord(reader io.Reader, maxChunkBytes int64) ([]byte, error) { + var header [externalRecordLengthBytes]byte + read, err := io.ReadFull(reader, header[:]) + if err != nil { + if read == 0 && errors.Is(err, io.EOF) { + return nil, io.EOF + } + return nil, fmt.Errorf("read record length: %w", err) + } + length := int64(binary.BigEndian.Uint32(header[:])) + if length+externalRecordMemoryBytes > maxChunkBytes { + return nil, fmt.Errorf("record length %d exceeds external sort chunk limit %d", length, maxChunkBytes) + } + record := make([]byte, int(length)) + if _, err := io.ReadFull(reader, record); err != nil { + return nil, fmt.Errorf("read record body of length %d: %w", length, err) + } + return record, nil +} + +type externalChunkReader struct { + file *os.File + reader *snappy.Reader +} + +type externalMergeItem struct { + record []byte + chunk int +} + +type externalMergeHeap struct { + items []externalMergeItem + less func([]byte, []byte) bool +} + +func (h externalMergeHeap) Len() int { return len(h.items) } +func (h externalMergeHeap) Swap(i, j int) { h.items[i], h.items[j] = h.items[j], h.items[i] } +func (h externalMergeHeap) Less(i, j int) bool { + left, right := h.items[i], h.items[j] + if h.less(left.record, right.record) { + return true + } + if h.less(right.record, left.record) { + return false + } + return left.chunk < right.chunk +} + +func (h *externalMergeHeap) Push(value any) { + h.items = append(h.items, value.(externalMergeItem)) +} + +func (h *externalMergeHeap) Pop() any { + old := h.items + last := len(old) - 1 + item := old[last] + h.items = old[:last] + return item +} + +type externalMergeReader struct { + ctx context.Context + owner *externalSorter + maxChunkBytes int64 + readers []externalChunkReader + heap externalMergeHeap + closed bool + failed error +} + +func newExternalMergeReader(owner *externalSorter) (*externalMergeReader, error) { + merge, err := newExternalMergeReaderForChunks(owner.ctx, owner.chunks, owner.maxChunkBytes, owner.less) + if err != nil { + return nil, err + } + merge.owner = owner + return merge, nil +} + +func newExternalMergeReaderForChunks( + ctx context.Context, + chunks []string, + maxChunkBytes int64, + less func([]byte, []byte) bool, +) (*externalMergeReader, error) { + if ctx == nil { + return nil, fmt.Errorf("external merge reader context is required") + } + merge := &externalMergeReader{ + ctx: ctx, + maxChunkBytes: maxChunkBytes, + readers: make([]externalChunkReader, len(chunks)), + heap: externalMergeHeap{less: less}, + } + for index, path := range chunks { + if err := ctx.Err(); err != nil { + _ = merge.closeFiles() + return nil, err + } + file, err := os.Open(path) + if err != nil { + _ = merge.closeFiles() + return nil, fmt.Errorf("open external sort chunk %d: %w", index, err) + } + merge.readers[index] = externalChunkReader{ + file: file, reader: snappy.NewReader(bufio.NewReaderSize(file, externalSortBufferSize)), + } + record, err := readExternalRecord(merge.readers[index].reader, maxChunkBytes) + if err != nil { + _ = merge.closeFiles() + if errors.Is(err, io.EOF) { + return nil, fmt.Errorf("external sort chunk %d is empty", index) + } + return nil, fmt.Errorf("read external sort chunk %d: %w", index, err) + } + merge.heap.items = append(merge.heap.items, externalMergeItem{record: record, chunk: index}) + } + heap.Init(&merge.heap) + return merge, nil +} + +// Next returns the next record or io.EOF after all records have been read. +func (r *externalMergeReader) Next() ([]byte, error) { + if r == nil { + return nil, fmt.Errorf("external merge reader is nil") + } + if r.closed { + return nil, fmt.Errorf("external merge reader is closed") + } + if r.failed != nil { + return nil, r.failed + } + if err := r.ctx.Err(); err != nil { + r.failed = err + return nil, err + } + if r.heap.Len() == 0 { + return nil, io.EOF + } + item := heap.Pop(&r.heap).(externalMergeItem) + next, err := readExternalRecord(r.readers[item.chunk].reader, r.maxChunkBytes) + switch { + case err == nil: + if r.heap.less(next, item.record) { + r.failed = fmt.Errorf("external sort chunk %d is not sorted", item.chunk) + return nil, r.failed + } + heap.Push(&r.heap, externalMergeItem{record: next, chunk: item.chunk}) + case errors.Is(err, io.EOF): + // This run is exhausted. + default: + r.failed = fmt.Errorf("read external sort chunk %d: %w", item.chunk, err) + return nil, r.failed + } + return item.record, nil +} + +// Close releases all chunk files and removes the sorter's temporary directory. +func (r *externalMergeReader) Close() error { + if r == nil || r.closed { + return nil + } + r.closed = true + err := r.closeFiles() + owner := r.owner + r.owner = nil + if owner != nil { + owner.merge = nil + err = errors.Join(err, owner.cleanup()) + } + return err +} + +func (r *externalMergeReader) closeFiles() error { + var errs []error + for index := range r.readers { + if r.readers[index].file != nil { + errs = append(errs, r.readers[index].file.Close()) + r.readers[index].file = nil + } + } + return errors.Join(errs...) +} diff --git a/cmd/statediff-rewriter/external_sort_test.go b/cmd/statediff-rewriter/external_sort_test.go new file mode 100644 index 00000000..e2f7ead1 --- /dev/null +++ b/cmd/statediff-rewriter/external_sort_test.go @@ -0,0 +1,341 @@ +package main + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func byteLess(left, right []byte) bool { + return bytes.Compare(left, right) < 0 +} + +func collectExternalRecords(t *testing.T, reader *externalMergeReader) [][]byte { + t.Helper() + var records [][]byte + for { + record, err := reader.Next() + if errors.Is(err, io.EOF) { + return records + } + require.NoError(t, err) + records = append(records, record) + } +} + +func TestExternalSorterMultipleChunks(t *testing.T) { + sorter, err := newExternalSorter(t.TempDir(), 70, 10, byteLess) + require.NoError(t, err) + defer sorter.Close() + + input := [][]byte{[]byte("g"), []byte("a"), []byte("d"), []byte("b"), []byte("f"), []byte("c"), []byte("e")} + for _, record := range input { + require.NoError(t, sorter.Feed(record)) + } + require.GreaterOrEqual(t, len(sorter.chunks), 3) + + reader, err := sorter.Finalize() + require.NoError(t, err) + require.Equal(t, [][]byte{ + []byte("a"), []byte("b"), []byte("c"), []byte("d"), []byte("e"), []byte("f"), []byte("g"), + }, collectExternalRecords(t, reader)) + require.NoError(t, reader.Close()) +} + +func TestExternalSorterUsesInjectedComparison(t *testing.T) { + comparisons := 0 + descending := func(left, right []byte) bool { + comparisons++ + return bytes.Compare(left, right) > 0 + } + sorter, err := newExternalSorter(t.TempDir(), 68, 10, descending) + require.NoError(t, err) + defer sorter.Close() + for _, record := range [][]byte{[]byte("a"), []byte("c"), []byte("b"), []byte("b")} { + require.NoError(t, sorter.Feed(record)) + } + reader, err := sorter.Finalize() + require.NoError(t, err) + require.Equal(t, [][]byte{[]byte("c"), []byte("b"), []byte("b"), []byte("a")}, collectExternalRecords(t, reader)) + require.Positive(t, comparisons) + require.NoError(t, reader.Close()) +} + +func TestExternalSorterCopiesFedRecords(t *testing.T) { + sorter, err := newExternalSorter(t.TempDir(), 64, 2, byteLess) + require.NoError(t, err) + defer sorter.Close() + record := []byte("a") + require.NoError(t, sorter.Feed(record)) + record[0] = 'z' + reader, err := sorter.Finalize() + require.NoError(t, err) + require.Equal(t, [][]byte{[]byte("a")}, collectExternalRecords(t, reader)) + require.NoError(t, reader.Close()) +} + +func TestExternalSorterReservesMinFreeForEveryOutput(t *testing.T) { + sorter, err := newExternalSorterWithContext( + context.Background(), t.TempDir(), externalRecordMemoryBytes+1, 2, 100, byteLess, + ) + require.NoError(t, err) + defer sorter.Close() + sorter.allocationUnit = 4096 + var requirements []uint64 + sorter.ensureSpace = func(path string, minimum uint64) error { + require.Equal(t, sorter.workDir, path) + requirements = append(requirements, minimum) + return nil + } + require.NoError(t, sorter.Feed([]byte("b"))) + require.NoError(t, sorter.Feed([]byte("a"))) + reader, err := sorter.Finalize() + require.NoError(t, err) + require.Equal(t, []uint64{12_388, 12_388}, requirements) + require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, collectExternalRecords(t, reader)) + require.NoError(t, reader.Close()) +} + +func TestExternalSorterFreeSpaceFailureIsSticky(t *testing.T) { + sorter, err := newExternalSorterWithContext( + context.Background(), t.TempDir(), externalRecordMemoryBytes+1, 2, 100, byteLess, + ) + require.NoError(t, err) + defer sorter.Close() + want := errors.New("insufficient external sort space") + sorter.ensureSpace = func(string, uint64) error { return want } + require.NoError(t, sorter.Feed([]byte("a"))) + require.ErrorIs(t, sorter.Feed([]byte("b")), want) + _, err = sorter.Finalize() + require.ErrorIs(t, err, want) + require.Empty(t, sorter.chunks) +} + +func TestExternalSorterHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + sorter, err := newExternalSorterWithContext(ctx, t.TempDir(), 64, 2, 0, byteLess) + require.NoError(t, err) + defer sorter.Close() + require.NoError(t, sorter.Feed([]byte("a"))) + cancel() + require.ErrorIs(t, sorter.Feed([]byte("b")), context.Canceled) + _, err = sorter.Finalize() + require.ErrorIs(t, err, context.Canceled) +} + +func TestExternalSorterMultiPassFanInTwo(t *testing.T) { + sorter, err := newExternalSorter(t.TempDir(), externalRecordMemoryBytes+1, 2, byteLess) + require.NoError(t, err) + defer sorter.Close() + for _, record := range [][]byte{ + []byte("i"), []byte("h"), []byte("g"), []byte("f"), []byte("e"), + []byte("d"), []byte("c"), []byte("b"), []byte("a"), + } { + require.NoError(t, sorter.Feed(record)) + } + require.Len(t, sorter.chunks, 8) + initialChunks := append([]string(nil), sorter.chunks...) + + reader, err := sorter.Finalize() + require.NoError(t, err) + require.LessOrEqual(t, len(sorter.chunks), 2) + for _, path := range initialChunks { + _, statErr := os.Stat(path) + require.ErrorIs(t, statErr, os.ErrNotExist) + } + require.Equal(t, [][]byte{ + []byte("a"), []byte("b"), []byte("c"), []byte("d"), []byte("e"), + []byte("f"), []byte("g"), []byte("h"), []byte("i"), + }, collectExternalRecords(t, reader)) + require.NoError(t, reader.Close()) +} + +func TestExternalSorterFailedMergeKeepsUnreplacedInputsAndCleansPartialOutput(t *testing.T) { + sorter, err := newExternalSorter(t.TempDir(), externalRecordMemoryBytes+1, 2, byteLess) + require.NoError(t, err) + workDir := sorter.workDir + defer sorter.Close() + for _, record := range [][]byte{[]byte("e"), []byte("d"), []byte("c"), []byte("b"), []byte("a")} { + require.NoError(t, sorter.Feed(record)) + } + require.Len(t, sorter.chunks, 4) + firstGroup := append([]string(nil), sorter.chunks[:2]...) + failedInput := sorter.chunks[2] + require.NoError(t, os.Truncate(sorter.chunks[2], 0)) + + _, err = sorter.Finalize() + require.Error(t, err) + require.NotErrorIs(t, err, io.EOF) + entries, readErr := os.ReadDir(workDir) + require.NoError(t, readErr) + require.NotEmpty(t, entries) + for _, entry := range entries { + require.False(t, entry.IsDir()) + require.NotContains(t, entry.Name(), ".partial") + } + for _, path := range firstGroup { + _, statErr := os.Stat(path) + require.ErrorIs(t, statErr, os.ErrNotExist) + } + require.FileExists(t, failedInput) + + require.NoError(t, sorter.Close()) + _, err = os.Stat(workDir) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestExternalSorterRejectsOversizedRecord(t *testing.T) { + sorter, err := newExternalSorter(t.TempDir(), externalRecordMemoryBytes+3, 2, byteLess) + require.NoError(t, err) + workDir := sorter.workDir + err = sorter.Feed([]byte("four")) + require.ErrorContains(t, err, "exceeding chunk limit") + require.EqualError(t, sorter.Feed([]byte("a")), err.Error()) + _, finalizeErr := sorter.Finalize() + require.EqualError(t, finalizeErr, err.Error()) + require.NoError(t, sorter.Close()) + _, statErr := os.Stat(workDir) + require.ErrorIs(t, statErr, os.ErrNotExist) +} + +func TestNewExternalSorterRejectsInvalidOptions(t *testing.T) { + dir := t.TempDir() + tests := []struct { + name string + dir string + maxChunkBytes int64 + maxChunks int + less func([]byte, []byte) bool + }{ + {name: "empty directory", maxChunkBytes: 64, maxChunks: 1, less: byteLess}, + {name: "small chunk", dir: dir, maxChunkBytes: externalRecordMemoryBytes - 1, maxChunks: 1, less: byteLess}, + {name: "zero chunks", dir: dir, maxChunkBytes: 64, less: byteLess}, + {name: "single chunk", dir: dir, maxChunkBytes: 64, maxChunks: 1, less: byteLess}, + {name: "nil comparison", dir: dir, maxChunkBytes: 64, maxChunks: 1}, + {name: "missing parent", dir: filepath.Join(dir, "missing"), maxChunkBytes: 64, maxChunks: 1, less: byteLess}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + sorter, err := newExternalSorter(test.dir, test.maxChunkBytes, test.maxChunks, test.less) + require.Error(t, err) + require.Nil(t, sorter) + }) + } +} + +func TestExternalSorterCloseCleansTemporaryDirectory(t *testing.T) { + t.Run("before finalize", func(t *testing.T) { + sorter, err := newExternalSorter(t.TempDir(), 64, 2, byteLess) + require.NoError(t, err) + workDir := sorter.workDir + require.NoError(t, sorter.Feed([]byte("record"))) + require.NoError(t, sorter.Close()) + require.NoError(t, sorter.Close()) + _, err = os.Stat(workDir) + require.ErrorIs(t, err, os.ErrNotExist) + }) + + t.Run("merge reader owns cleanup", func(t *testing.T) { + sorter, err := newExternalSorter(t.TempDir(), 64, 2, byteLess) + require.NoError(t, err) + workDir := sorter.workDir + require.NoError(t, sorter.Feed([]byte("record"))) + reader, err := sorter.Finalize() + require.NoError(t, err) + require.NoError(t, reader.Close()) + require.NoError(t, reader.Close()) + require.NoError(t, sorter.Close()) + _, err = os.Stat(workDir) + require.ErrorIs(t, err, os.ErrNotExist) + }) +} + +func TestExternalSorterDetectsDamagedChunk(t *testing.T) { + tests := []struct { + name string + damage func(*testing.T, string) + }{ + { + name: "corrupt", + damage: func(t *testing.T, path string) { + t.Helper() + body, err := os.ReadFile(path) + require.NoError(t, err) + require.Greater(t, len(body), 1) + body[len(body)-1] ^= 0xff + require.NoError(t, os.WriteFile(path, body, 0o600)) + }, + }, + { + name: "truncated", + damage: func(t *testing.T, path string) { + t.Helper() + info, err := os.Stat(path) + require.NoError(t, err) + require.NoError(t, os.Truncate(path, info.Size()-1)) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + sorter, err := newExternalSorter(t.TempDir(), externalRecordMemoryBytes+1, 4, byteLess) + require.NoError(t, err) + defer sorter.Close() + require.NoError(t, sorter.Feed([]byte("b"))) + require.NoError(t, sorter.Feed([]byte("a"))) + require.Len(t, sorter.chunks, 1) + test.damage(t, sorter.chunks[0]) + + reader, finalizeErr := sorter.Finalize() + if finalizeErr == nil { + defer reader.Close() + for { + _, finalizeErr = reader.Next() + if finalizeErr != nil { + break + } + } + } + require.Error(t, finalizeErr) + require.NotErrorIs(t, finalizeErr, io.EOF) + }) + } +} + +func TestReadExternalRecordRejectsShortOrInvalidRecords(t *testing.T) { + t.Run("short header", func(t *testing.T) { + _, err := readExternalRecord(bytes.NewReader([]byte{0, 0, 0}), 64) + require.ErrorIs(t, err, io.ErrUnexpectedEOF) + }) + + t.Run("short body", func(t *testing.T) { + body := make([]byte, externalRecordLengthBytes+2) + binary.BigEndian.PutUint32(body, 3) + _, err := readExternalRecord(bytes.NewReader(body), 64) + require.ErrorIs(t, err, io.ErrUnexpectedEOF) + }) + + t.Run("declared length exceeds limit", func(t *testing.T) { + body := make([]byte, externalRecordLengthBytes) + binary.BigEndian.PutUint32(body, 33) + _, err := readExternalRecord(bytes.NewReader(body), 64) + require.ErrorContains(t, err, "exceeds external sort chunk limit") + }) +} + +func TestExternalSorterEmpty(t *testing.T) { + sorter, err := newExternalSorter(t.TempDir(), 64, 2, byteLess) + require.NoError(t, err) + reader, err := sorter.Finalize() + require.NoError(t, err) + _, err = reader.Next() + require.ErrorIs(t, err, io.EOF) + require.NoError(t, reader.Close()) +} diff --git a/cmd/statediff-rewriter/extract.go b/cmd/statediff-rewriter/extract.go new file mode 100644 index 00000000..4dfa39bb --- /dev/null +++ b/cmd/statediff-rewriter/extract.go @@ -0,0 +1,770 @@ +package main + +import ( + "bytes" + "compress/zlib" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "time" + + dbm "github.com/cosmos/cosmos-db" + "github.com/cosmos/iavl" + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + + "cosmossdk.io/log" + "cosmossdk.io/store/rootmulti" + "cosmossdk.io/store/wrapper" + + "github.com/cosmos/cosmos-sdk/version" +) + +const ( + defaultDumpConcurrency = 8 + defaultDumpChunkSize = int64(1_000_000) + defaultDumpCacheSize = 10_000 + evmIAVLPrefix = "s/k:evm/" +) + +type dumpOptions struct { + Home string + Output string + FirstVersion int64 + LastVersion int64 + Concurrency int + ChunkSize int64 + CacheSize int + ZlibLevel int + MinFree uint64 + LegacyTempDir string + LegacySortChunkSize int64 + LegacyMaxSortChunks int + LegacyReferenceSamples int + SnapshotID string + ImageDigest string + StopAfterLegacy bool +} + +type dumpReport struct { + Phase string `json:"phase"` + Output string `json:"output"` + FirstVersion int64 `json:"first_version"` + LastVersion int64 `json:"last_version"` + CompletedLastVersion int64 `json:"completed_last_version"` + TargetRecords int64 `json:"target_records"` + Files int `json:"files"` + GeneratedFiles int64 `json:"generated_files"` + ReusedFiles int64 `json:"reused_files"` + Records int64 `json:"records"` + GeneratedRecords int64 `json:"generated_records"` + ReusedRecords int64 `json:"reused_records"` + Concurrency int `json:"concurrency"` + CacheSize int `json:"cache_size_per_worker"` + ZlibLevel int `json:"zlib_level"` + DurationSeconds float64 `json:"duration_seconds"` + HandledBlocksPerSecond float64 `json:"handled_blocks_per_second"` + GeneratedBlocksPerSecond float64 `json:"generated_blocks_per_second"` + LegacyLatestVersion int64 `json:"legacy_latest_version"` + LegacyScan *legacyScanReport `json:"legacy_scan,omitempty"` + ModernRecords int64 `json:"modern_records"` + LegacyReferenceSamples int `json:"legacy_reference_samples"` +} + +type versionRange struct { + First int64 + Last int64 +} + +type stateChangeTraverser interface { + TraverseStateChanges(int64, int64, func(int64, *iavl.ChangeSet) error) error +} + +type stateChangeSource interface { + stateChangeTraverser + VersionHash(int64) ([]byte, error) +} + +func newDumpCommand() *cobra.Command { + options := dumpOptions{ + Concurrency: defaultConcurrency(), + ChunkSize: defaultDumpChunkSize, + CacheSize: defaultDumpCacheSize, + ZlibLevel: zlib.BestSpeed, + LegacySortChunkSize: defaultLegacySortChunkBytes, + LegacyMaxSortChunks: defaultLegacySortMaxChunks, + LegacyReferenceSamples: 256, + } + command := &cobra.Command{ + Use: "dump", + Short: "Extract an explicit EVM IAVL version range without scanning the full node prefix", + RunE: func(command *cobra.Command, _ []string) error { + report, err := runDump(command.Context(), options) + if err != nil { + return err + } + return printJSON(report) + }, + } + command.Flags().StringVar(&options.Home, "home", "", "stopped Cronos node home with a read-only RocksDB application.db") + command.Flags().StringVar(&options.Output, "output", "", "changeset dump directory ending in .staging") + command.Flags().Int64Var(&options.FirstVersion, "first-version", 0, "first IAVL version to extract, inclusive") + command.Flags().Int64Var(&options.LastVersion, "last-version", 0, "last IAVL version to extract, inclusive") + command.Flags().IntVar(&options.Concurrency, "concurrency", options.Concurrency, "number of IAVL traversal workers") + command.Flags().Int64Var(&options.ChunkSize, "chunk-size", options.ChunkSize, "maximum versions per output file") + command.Flags().IntVar(&options.CacheSize, "iavl-cache-size", options.CacheSize, "IAVL node-cache entries per worker") + command.Flags().IntVar(&options.ZlibLevel, "zlib-level", options.ZlibLevel, "zlib compression level (0-9)") + command.Flags().Uint64Var(&options.MinFree, "min-free-bytes", 0, "stop before filesystem free space falls below this value") + command.Flags().StringVar(&options.LegacyTempDir, "legacy-temp-dir", "", "temporary directory for bounded legacy external sorts; defaults to the dump parent") + command.Flags().Int64Var(&options.LegacySortChunkSize, "legacy-sort-chunk-bytes", options.LegacySortChunkSize, "maximum in-memory bytes per legacy external-sort run") + command.Flags().IntVar(&options.LegacyMaxSortChunks, "legacy-max-sort-chunks", options.LegacyMaxSortChunks, "maximum input files opened by each legacy external-sort merge") + command.Flags().StringVar(&options.SnapshotID, "snapshot-id", "", "snapshot or immutable clone identity used to create this archive") + command.Flags().StringVar(&options.ImageDigest, "image-digest", "", "immutable rewriter image digest") + command.Flags().IntVar(&options.LegacyReferenceSamples, "legacy-reference-samples", options.LegacyReferenceSamples, "evenly spaced legacy versions compared with reference traversal") + command.Flags().BoolVar(&options.StopAfterLegacy, "stop-after-legacy", false, "prepare the legacy prefix of a full dump and stop before modern traversal") + _ = command.MarkFlagRequired("home") + _ = command.MarkFlagRequired("output") + _ = command.MarkFlagRequired("first-version") + _ = command.MarkFlagRequired("last-version") + _ = command.MarkFlagRequired("min-free-bytes") + _ = command.MarkFlagRequired("snapshot-id") + _ = command.MarkFlagRequired("image-digest") + return command +} + +func runDump(ctx context.Context, options dumpOptions) (dumpReport, error) { + started := time.Now() + if options.FirstVersion < 1 || options.LastVersion < options.FirstVersion { + return dumpReport{}, fmt.Errorf("invalid version range %d-%d", options.FirstVersion, options.LastVersion) + } + if options.Concurrency < 1 { + return dumpReport{}, fmt.Errorf("concurrency must be positive") + } + if options.ChunkSize < 1 { + return dumpReport{}, fmt.Errorf("chunk-size must be positive") + } + if options.LegacyReferenceSamples < 0 { + return dumpReport{}, fmt.Errorf("legacy-reference-samples must not be negative") + } + if options.CacheSize < 0 { + return dumpReport{}, fmt.Errorf("iavl-cache-size must not be negative") + } + if options.ZlibLevel < zlib.NoCompression || options.ZlibLevel > zlib.BestCompression { + return dumpReport{}, fmt.Errorf("zlib-level must be between %d and %d", zlib.NoCompression, zlib.BestCompression) + } + if options.SnapshotID == "" || options.ImageDigest == "" { + return dumpReport{}, fmt.Errorf("snapshot-id and image-digest are required") + } + cronosCommit, ethermintCommit, iavlCommit := buildCommits() + if err := requireRuntimeBuild(cronosCommit, ethermintCommit, iavlCommit, options.ImageDigest, version.BuildTags); err != nil { + return dumpReport{}, err + } + output, err := resolveDumpOutput(options.Output) + if err != nil { + return dumpReport{}, err + } + locks, err := acquireStagingLocks(output) + if err != nil { + return dumpReport{}, err + } + defer func() { _ = releaseStagingLocks(locks) }() + if _, err := resolveDumpOutput(output); err != nil { + return dumpReport{}, err + } + archive, err := openArchive(options.Home) + if err != nil { + return dumpReport{}, err + } + defer archive.Close() + latest := rootmulti.GetLatestVersion(archive.db) + if options.LastVersion > latest { + return dumpReport{}, fmt.Errorf("last-version %d exceeds archive latest version %d", options.LastVersion, latest) + } + identity, err := archive.identity() + if err != nil { + return dumpReport{}, err + } + if identity.LatestVersion != latest { + return dumpReport{}, fmt.Errorf("archive latest version changed while reading identity: %d to %d", latest, identity.LatestVersion) + } + dumpSchema := pilotDumpManifestSchema + if options.FirstVersion == 1 && options.LastVersion == latest { + dumpSchema = dumpManifestSchema + } + prefix := []byte(evmIAVLPrefix) + evmDB := wrapper.NewDBWrapper(dbm.NewPrefixDB(archive.db, prefix)) + legacyLatest, err := legacyLatestRootVersion(evmDB) + if err != nil { + return dumpReport{}, err + } + legacyRanges, modernRanges, ranges, err := splitDumpRangesAtLegacy( + options.FirstVersion, options.LastVersion, legacyLatest, options.Concurrency, options.ChunkSize, + ) + if err != nil { + return dumpReport{}, err + } + if err := validateLegacyPreparation(options, latest, legacyRanges, modernRanges); err != nil { + return dumpReport{}, err + } + if len(legacyRanges) != 0 && options.LegacyReferenceSamples < 1 { + return dumpReport{}, fmt.Errorf("legacy-reference-samples must be positive when extracting legacy versions") + } + evmDir, err := ensureDumpOutput(output) + if err != nil { + return dumpReport{}, err + } + if err := ensureFreeSpace(output, options.MinFree); err != nil { + return dumpReport{}, err + } + space, err := newFreeSpaceReserver(output, options.MinFree) + if err != nil { + return dumpReport{}, err + } + sourceContext := dumpContext{ + Schema: dumpSchema, FirstVersion: options.FirstVersion, LastVersion: options.LastVersion, + SnapshotID: options.SnapshotID, ArchiveIdentity: identity, + CronosCommit: cronosCommit, EthermintCommit: ethermintCommit, IAVLCommit: iavlCommit, + ImageDigest: options.ImageDigest, BuildTags: version.BuildTags, + } + sourceHash, err := ensureDumpSource(output, sourceContext) + if err != nil { + return dumpReport{}, err + } + if err := validateDumpOutput(evmDir, ranges); err != nil { + return dumpReport{}, err + } + + var generatedFiles, reusedFiles, generatedRecords, reusedRecords atomic.Int64 + var legacyReport *legacyScanReport + legacyReferenceSamples := 0 + if len(legacyRanges) != 0 { + tempDir := options.LegacyTempDir + if tempDir == "" { + tempDir = filepath.Dir(output) + } + result, err := writeLegacyDumpRanges( + ctx, + iavlLegacyRecordSource{db: evmDB}, + legacyScanOptions{ + TempDir: tempDir, FirstVersion: legacyRanges[0].First, + LastVersion: legacyRanges[len(legacyRanges)-1].Last, + SortChunkSize: options.LegacySortChunkSize, MaxSortChunks: options.LegacyMaxSortChunks, + MinFree: options.MinFree, + }, + evmDir, legacyRanges, options.ZlibLevel, options.MinFree, + ) + if err != nil { + return dumpReport{}, fmt.Errorf("extract legacy versions: %w", err) + } + if result.Scanned { + legacyReport = &result.Scan + } + generatedFiles.Add(result.GeneratedFiles) + reusedFiles.Add(result.ReusedFiles) + generatedRecords.Add(result.GeneratedRecords) + reusedRecords.Add(result.ReusedRecords) + if options.LegacyReferenceSamples > 0 { + reference := iavl.NewImmutableTree(evmDB, options.CacheSize, true, log.NewNopLogger()) + legacyReferenceSamples, err = verifyLegacyDumpSamples( + ctx, evmDir, legacyRanges, reference, options.LegacyReferenceSamples, + ) + if err != nil { + return dumpReport{}, err + } + } + } + if options.StopAfterLegacy { + if err := validateDumpOutputComplete(evmDir, legacyRanges); err != nil { + return dumpReport{}, err + } + if err := validateDumpSourceUnchanged(archive, identity, output, sourceContext, sourceHash); err != nil { + return dumpReport{}, err + } + duration := time.Since(started) + records := countVersionRanges(legacyRanges) + return dumpReport{ + Phase: "legacy-prepared", Output: output, FirstVersion: options.FirstVersion, LastVersion: options.LastVersion, + CompletedLastVersion: legacyRanges[len(legacyRanges)-1].Last, + TargetRecords: options.LastVersion - options.FirstVersion + 1, + Files: len(legacyRanges), GeneratedFiles: generatedFiles.Load(), ReusedFiles: reusedFiles.Load(), Records: records, + GeneratedRecords: generatedRecords.Load(), ReusedRecords: reusedRecords.Load(), Concurrency: 1, + CacheSize: options.CacheSize, ZlibLevel: options.ZlibLevel, DurationSeconds: duration.Seconds(), + HandledBlocksPerSecond: float64(records) / duration.Seconds(), + GeneratedBlocksPerSecond: float64(generatedRecords.Load()) / duration.Seconds(), + LegacyLatestVersion: legacyLatest, LegacyScan: legacyReport, + ModernRecords: countVersionRanges(modernRanges), LegacyReferenceSamples: legacyReferenceSamples, + }, nil + } + + workerCount := options.Concurrency + if workerCount > len(modernRanges) { + workerCount = len(modernRanges) + } + if workerCount > 0 { + jobs := make(chan versionRange) + group, groupCtx := errgroup.WithContext(ctx) + for range workerCount { + group.Go(func() error { + tree := iavl.NewImmutableTree( + wrapper.NewDBWrapper(dbm.NewPrefixDB(archive.db, prefix)), + options.CacheSize, + true, + log.NewNopLogger(), + ) + for blockRange := range jobs { + if err := groupCtx.Err(); err != nil { + return err + } + path := dumpRangePath(evmDir, blockRange) + if _, err := os.Lstat(path); err == nil { + if err := validateReusableDumpRange(groupCtx, path, blockRange); err != nil { + return err + } + reusedFiles.Add(1) + reusedRecords.Add(blockRange.Last - blockRange.First + 1) + continue + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + if err := ensureFreeSpace(output, options.MinFree); err != nil { + return err + } + if err := writeDumpRange(groupCtx, path, tree, blockRange, options.ZlibLevel, space); err != nil { + return fmt.Errorf("extract versions %d-%d: %w", blockRange.First, blockRange.Last, err) + } + generatedFiles.Add(1) + generatedRecords.Add(blockRange.Last - blockRange.First + 1) + } + return nil + }) + } + group.Go(func() error { + defer close(jobs) + for _, blockRange := range modernRanges { + select { + case jobs <- blockRange: + case <-groupCtx.Done(): + return groupCtx.Err() + } + } + return nil + }) + if err := group.Wait(); err != nil { + return dumpReport{}, err + } + } + if err := validateDumpOutputComplete(evmDir, ranges); err != nil { + return dumpReport{}, err + } + if err := validateDumpSourceUnchanged(archive, identity, output, sourceContext, sourceHash); err != nil { + return dumpReport{}, err + } + + duration := time.Since(started) + records := options.LastVersion - options.FirstVersion + 1 + return dumpReport{ + Phase: "complete", Output: output, FirstVersion: options.FirstVersion, LastVersion: options.LastVersion, + CompletedLastVersion: options.LastVersion, TargetRecords: records, + Files: len(ranges), GeneratedFiles: generatedFiles.Load(), ReusedFiles: reusedFiles.Load(), Records: records, + GeneratedRecords: generatedRecords.Load(), ReusedRecords: reusedRecords.Load(), + Concurrency: workerCount, CacheSize: options.CacheSize, ZlibLevel: options.ZlibLevel, + DurationSeconds: duration.Seconds(), HandledBlocksPerSecond: float64(records) / duration.Seconds(), + GeneratedBlocksPerSecond: float64(generatedRecords.Load()) / duration.Seconds(), + LegacyLatestVersion: legacyLatest, LegacyScan: legacyReport, + ModernRecords: countVersionRanges(modernRanges), LegacyReferenceSamples: legacyReferenceSamples, + }, nil +} + +func validateLegacyPreparation( + options dumpOptions, + latest int64, + legacyRanges, modernRanges []versionRange, +) error { + if !options.StopAfterLegacy { + return nil + } + if options.FirstVersion != 1 || options.LastVersion != latest { + return fmt.Errorf( + "stop-after-legacy requires the full archive range 1-%d, got %d-%d", + latest, options.FirstVersion, options.LastVersion, + ) + } + if len(legacyRanges) == 0 { + return fmt.Errorf("stop-after-legacy requires legacy versions") + } + if len(modernRanges) == 0 { + return fmt.Errorf("stop-after-legacy requires a modern suffix") + } + return nil +} + +func validateDumpSourceUnchanged( + archive *archiveReader, + expectedIdentity archiveIdentity, + output string, + expectedContext dumpContext, + expectedHash string, +) error { + identity, err := archive.identity() + if err != nil { + return err + } + if identity != expectedIdentity { + return fmt.Errorf("archive identity changed during extraction: got %+v, want %+v", identity, expectedIdentity) + } + source, sourceHash, found, err := loadDumpSource(filepath.Join(output, dumpSourceFileName)) + if err != nil { + return err + } + if !found || source.Context != expectedContext || sourceHash != expectedHash { + return fmt.Errorf("dump source identity changed during extraction") + } + return nil +} + +func resolveDumpOutput(path string) (string, error) { + path = filepath.Clean(path) + if !strings.HasSuffix(path, ".staging") { + return "", fmt.Errorf("output must end in .staging") + } + absolute, err := filepath.Abs(path) + if err != nil { + return "", err + } + parent, err := filepath.EvalSymlinks(filepath.Dir(absolute)) + if err != nil { + return "", fmt.Errorf("resolve dump output parent: %w", err) + } + output := filepath.Join(parent, filepath.Base(absolute)) + if info, err := os.Lstat(output); err == nil { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("dump staging must be a non-symlink directory: %s", output) + } + evmDir := filepath.Join(output, "evm") + if evmInfo, evmErr := os.Lstat(evmDir); evmErr == nil { + if !evmInfo.IsDir() || evmInfo.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("dump EVM output must be a non-symlink directory: %s", evmDir) + } + } else if !errors.Is(evmErr, os.ErrNotExist) { + return "", evmErr + } + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + return output, nil +} + +func ensureDumpOutput(output string) (string, error) { + if info, err := os.Lstat(output); errors.Is(err, os.ErrNotExist) { + if err := os.Mkdir(output, 0o755); err != nil { + return "", err + } + } else if err != nil { + return "", err + } else if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("dump staging must be a non-symlink directory: %s", output) + } + evmDir := filepath.Join(output, "evm") + if info, err := os.Lstat(evmDir); errors.Is(err, os.ErrNotExist) { + if err := os.Mkdir(evmDir, 0o755); err != nil { + return "", err + } + } else if err != nil { + return "", err + } else if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("dump EVM output must be a non-symlink directory: %s", evmDir) + } + return evmDir, nil +} + +func splitDumpRangesAtLegacy( + first, last, legacyLatest int64, + concurrency int, + maxChunkSize int64, +) (legacy, modern, all []versionRange, returnErr error) { + if first < 1 || last < first { + return nil, nil, nil, fmt.Errorf("invalid version range %d-%d", first, last) + } + if legacyLatest < 0 { + return nil, nil, nil, fmt.Errorf("invalid latest legacy version %d", legacyLatest) + } + if first <= legacyLatest { + legacyEnd := last + if legacyEnd > legacyLatest { + legacyEnd = legacyLatest + } + var err error + legacy, err = splitDumpRanges(first, legacyEnd, concurrency, maxChunkSize) + if err != nil { + return nil, nil, nil, err + } + } + modernFirst := first + if modernFirst <= legacyLatest { + modernFirst = legacyLatest + 1 + } + if modernFirst <= last { + var err error + modern, err = splitDumpRanges(modernFirst, last, concurrency, maxChunkSize) + if err != nil { + return nil, nil, nil, err + } + } + all = append(append(make([]versionRange, 0, len(legacy)+len(modern)), legacy...), modern...) + return legacy, modern, all, nil +} + +func countVersionRanges(ranges []versionRange) int64 { + var count int64 + for _, blockRange := range ranges { + count += blockRange.Last - blockRange.First + 1 + } + return count +} + +func splitDumpRanges(first, last int64, concurrency int, maxChunkSize int64) ([]versionRange, error) { + if first < 1 || last < first { + return nil, fmt.Errorf("invalid version range %d-%d", first, last) + } + if concurrency < 1 { + return nil, fmt.Errorf("concurrency must be positive") + } + if maxChunkSize < 1 { + return nil, fmt.Errorf("chunk-size must be positive") + } + total := last - first + 1 + chunkSize := (total-1)/int64(concurrency) + 1 + if chunkSize > maxChunkSize { + chunkSize = maxChunkSize + } + ranges := make([]versionRange, 0, (total-1)/chunkSize+1) + for begin := first; begin <= last; begin += chunkSize { + end := begin + chunkSize - 1 + if end > last { + end = last + } + ranges = append(ranges, versionRange{First: begin, Last: end}) + } + return ranges, nil +} + +func dumpRangePath(evmDir string, blockRange versionRange) string { + return filepath.Join(evmDir, fmt.Sprintf("block-%d.zz", blockRange.First)) +} + +func validateDumpOutput(evmDir string, ranges []versionRange) error { + allowed := make(map[string]struct{}, len(ranges)*2) + for _, blockRange := range ranges { + name := filepath.Base(dumpRangePath(evmDir, blockRange)) + allowed[name] = struct{}{} + allowed[name+".partial"] = struct{}{} + } + entries, err := os.ReadDir(evmDir) + if err != nil { + return err + } + for _, entry := range entries { + if _, ok := allowed[entry.Name()]; !ok { + return fmt.Errorf("unexpected file in dump output: %s", entry.Name()) + } + path := filepath.Join(evmDir, entry.Name()) + if err := requireRegularFile(path, "dump output entry"); err != nil { + return err + } + } + return nil +} + +func validateDumpOutputComplete(evmDir string, ranges []versionRange) error { + for _, blockRange := range ranges { + path := dumpRangePath(evmDir, blockRange) + if err := requireRegularFile(path, "dump output"); err != nil { + return err + } + if _, err := os.Lstat(path + ".partial"); err == nil { + return fmt.Errorf("partial dump remains after extraction: %s", path+".partial") + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + } + return nil +} + +func validateDumpRangeFile(path string, blockRange versionRange) error { + return validateDumpRangeFileContext(context.Background(), path, blockRange) +} + +func validateDumpRangeFileContext(ctx context.Context, path string, blockRange versionRange) error { + manifest, err := scanZlibChangeSetsContext(ctx, path, nil) + if err != nil { + return fmt.Errorf("validate existing dump %s: %w", path, err) + } + wantRecords := blockRange.Last - blockRange.First + 1 + if manifest.FirstVersion != blockRange.First || manifest.LastVersion != blockRange.Last || manifest.Records != wantRecords { + return fmt.Errorf( + "dump %s contains versions %d-%d (%d records), want %d-%d (%d records)", + path, manifest.FirstVersion, manifest.LastVersion, manifest.Records, + blockRange.First, blockRange.Last, wantRecords, + ) + } + return nil +} + +func validateReusableDumpRange(ctx context.Context, path string, blockRange versionRange) error { + if err := requireRegularFile(path, "existing dump output"); err != nil { + return err + } + if err := validateDumpRangeFileContext(ctx, path, blockRange); err != nil { + return err + } + partial := path + ".partial" + if err := requireRegularFile(partial, "stale partial dump"); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + if err := os.Remove(partial); err != nil { + return err + } + return syncDir(filepath.Dir(path)) +} + +func writeDumpRange( + ctx context.Context, + path string, + tree stateChangeSource, + blockRange versionRange, + zlibLevel int, + reservations ...*freeSpaceReserver, +) (returnErr error) { + partial := path + ".partial" + file, err := openLockedPartial(partial) + if err != nil { + return err + } + fileOpen := true + defer func() { + if fileOpen { + if err := file.Close(); returnErr == nil { + returnErr = err + } + } + }() + var output io.Writer = file + if len(reservations) > 1 { + return fmt.Errorf("write dump range has multiple free-space reservers") + } + if len(reservations) == 1 { + if reservations[0] == nil { + return fmt.Errorf("write dump range has a nil free-space reserver") + } + output = reservedWriter{writer: file, reserver: reservations[0]} + } + writer, err := zlib.NewWriterLevel(output, zlibLevel) + if err != nil { + return err + } + writerOpen := true + defer func() { + if writerOpen { + if err := writer.Close(); returnErr == nil { + returnErr = err + } + } + }() + + expected := blockRange.First + err = traverseVerifiedStateChanges(tree, blockRange.First, blockRange.Last, func(version int64, changeSet *iavl.ChangeSet) error { + if err := ctx.Err(); err != nil { + return err + } + if version != expected { + return fmt.Errorf("traversal returned version %d, want %d", version, expected) + } + if err := writeChangeSet(writer, version, changeSet); err != nil { + return err + } + expected++ + return nil + }) + if err != nil { + return err + } + if expected != blockRange.Last+1 { + return fmt.Errorf("traversal ended at version %d, want %d", expected-1, blockRange.Last) + } + if err := writer.Close(); err != nil { + return err + } + writerOpen = false + if err := file.Sync(); err != nil { + return err + } + if err := commitFileNoReplace(partial, path); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + fileOpen = false + return nil +} + +func traverseVerifiedStateChanges( + source stateChangeSource, + first, last int64, + callback func(int64, *iavl.ChangeSet) error, +) error { + if source == nil || callback == nil { + return fmt.Errorf("state change source and callback are required") + } + if first < 1 || last < first { + return fmt.Errorf("invalid verified traversal range %d-%d", first, last) + } + versions := make([]int64, 0, 3) + if first > 1 { + versions = append(versions, first-1) + } + versions = append(versions, first) + if last != first { + versions = append(versions, last) + } + hashes := make(map[int64][]byte, len(versions)) + for _, version := range versions { + hash, err := source.VersionHash(version) + if err != nil { + return fmt.Errorf("resolve IAVL version %d root before traversal: %w", version, err) + } + if len(hash) != legacyHashLength { + return fmt.Errorf("IAVL version %d root hash has length %d", version, len(hash)) + } + hashes[version] = hash + } + if err := source.TraverseStateChanges(first, last, callback); err != nil { + return err + } + for _, version := range versions { + hash, err := source.VersionHash(version) + if err != nil { + return fmt.Errorf("resolve IAVL version %d root after traversal: %w", version, err) + } + if !bytes.Equal(hash, hashes[version]) { + return fmt.Errorf("IAVL version %d root changed during traversal", version) + } + } + return nil +} + +func defaultConcurrency() int { + if runtime.NumCPU() < defaultDumpConcurrency { + return runtime.NumCPU() + } + return defaultDumpConcurrency +} diff --git a/cmd/statediff-rewriter/extract_test.go b/cmd/statediff-rewriter/extract_test.go new file mode 100644 index 00000000..296dbc00 --- /dev/null +++ b/cmd/statediff-rewriter/extract_test.go @@ -0,0 +1,420 @@ +package main + +import ( + "bytes" + "compress/zlib" + "context" + "errors" + "io" + "os" + "path/filepath" + "testing" + + "github.com/cosmos/iavl" + iavldb "github.com/cosmos/iavl/db" + "github.com/stretchr/testify/require" + + "cosmossdk.io/log" +) + +type fakeTraverser struct { + versions []int64 + changes map[int64]*iavl.ChangeSet + err error + start int64 + end int64 + hashResults map[int64][][]byte + hashCalls map[int64]int +} + +type failingLegacySource struct{ calls int } + +func (source *failingLegacySource) InspectRoots(func(iavl.LegacyRootRecord) error) error { + source.calls++ + return errors.New("legacy source must not be scanned") +} + +func (source *failingLegacySource) InspectNodes(func(iavl.LegacyNodeRecord) error) error { + source.calls++ + return errors.New("legacy source must not be scanned") +} + +func (source *failingLegacySource) InspectOrphans(func(iavl.LegacyOrphanRecord) error) error { + source.calls++ + return errors.New("legacy source must not be scanned") +} + +func (f *fakeTraverser) TraverseStateChanges(start, end int64, callback func(int64, *iavl.ChangeSet) error) error { + f.start, f.end = start, end + for _, version := range f.versions { + changeSet := f.changes[version] + if changeSet == nil { + changeSet = &iavl.ChangeSet{} + } + if err := callback(version, changeSet); err != nil { + return err + } + } + return f.err +} + +func (f *fakeTraverser) VersionHash(version int64) ([]byte, error) { + if f.hashCalls == nil { + f.hashCalls = make(map[int64]int) + } + call := f.hashCalls[version] + f.hashCalls[version]++ + if results := f.hashResults[version]; call < len(results) { + if results[call] == nil { + return nil, iavl.ErrVersionDoesNotExist + } + return append([]byte(nil), results[call]...), nil + } + return bytes.Repeat([]byte{byte(version)}, legacyHashLength), nil +} + +func TestSplitDumpRanges(t *testing.T) { + ranges, err := splitDumpRanges(100, 199, 8, 1_000_000) + require.NoError(t, err) + require.Equal(t, []versionRange{ + {First: 100, Last: 112}, + {First: 113, Last: 125}, + {First: 126, Last: 138}, + {First: 139, Last: 151}, + {First: 152, Last: 164}, + {First: 165, Last: 177}, + {First: 178, Last: 190}, + {First: 191, Last: 199}, + }, ranges) + + ranges, err = splitDumpRanges(1, 80_000_000, 8, 1_000_000) + require.NoError(t, err) + require.Len(t, ranges, 80) + require.Equal(t, versionRange{First: 1, Last: 1_000_000}, ranges[0]) + require.Equal(t, versionRange{First: 79_000_001, Last: 80_000_000}, ranges[79]) + + for _, testCase := range []struct { + first int64 + last int64 + concurrency int + chunkSize int64 + }{ + {first: 0, last: 1, concurrency: 1, chunkSize: 1}, + {first: 2, last: 1, concurrency: 1, chunkSize: 1}, + {first: 1, last: 1, concurrency: 0, chunkSize: 1}, + {first: 1, last: 1, concurrency: 1, chunkSize: 0}, + } { + _, err := splitDumpRanges(testCase.first, testCase.last, testCase.concurrency, testCase.chunkSize) + require.Error(t, err) + } +} + +func TestSplitDumpRangesAtLegacyBoundary(t *testing.T) { + legacy, modern, all, err := splitDumpRangesAtLegacy(1, 10, 4, 2, 3) + require.NoError(t, err) + require.Equal(t, []versionRange{{First: 1, Last: 2}, {First: 3, Last: 4}}, legacy) + require.Equal(t, []versionRange{{First: 5, Last: 7}, {First: 8, Last: 10}}, modern) + require.Equal(t, append(append([]versionRange{}, legacy...), modern...), all) + require.Equal(t, int64(6), countVersionRanges(modern)) + + legacy, modern, all, err = splitDumpRangesAtLegacy(6, 8, 4, 2, 3) + require.NoError(t, err) + require.Empty(t, legacy) + require.Equal(t, []versionRange{{First: 6, Last: 7}, {First: 8, Last: 8}}, modern) + require.Equal(t, modern, all) + + legacy, modern, all, err = splitDumpRangesAtLegacy(2, 4, 10, 2, 3) + require.NoError(t, err) + require.Equal(t, []versionRange{{First: 2, Last: 3}, {First: 4, Last: 4}}, legacy) + require.Empty(t, modern) + require.Equal(t, legacy, all) +} + +func TestValidateLegacyPreparation(t *testing.T) { + legacy := []versionRange{{First: 1, Last: 4}} + modern := []versionRange{{First: 5, Last: 10}} + require.NoError(t, validateLegacyPreparation( + dumpOptions{FirstVersion: 1, LastVersion: 10, StopAfterLegacy: true}, 10, legacy, modern, + )) + + for _, testCase := range []struct { + name string + options dumpOptions + latest int64 + legacy []versionRange + modern []versionRange + message string + }{ + { + name: "partial range", options: dumpOptions{FirstVersion: 2, LastVersion: 10, StopAfterLegacy: true}, + latest: 10, legacy: legacy, modern: modern, message: "full archive range", + }, + { + name: "not latest", options: dumpOptions{FirstVersion: 1, LastVersion: 9, StopAfterLegacy: true}, + latest: 10, legacy: legacy, modern: modern, message: "full archive range", + }, + { + name: "no legacy", options: dumpOptions{FirstVersion: 1, LastVersion: 10, StopAfterLegacy: true}, + latest: 10, modern: modern, message: "requires legacy versions", + }, + { + name: "no modern", options: dumpOptions{FirstVersion: 1, LastVersion: 10, StopAfterLegacy: true}, + latest: 10, legacy: legacy, message: "requires a modern suffix", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + err := validateLegacyPreparation( + testCase.options, testCase.latest, testCase.legacy, testCase.modern, + ) + require.ErrorContains(t, err, testCase.message) + }) + } + + require.NoError(t, validateLegacyPreparation(dumpOptions{}, 0, nil, nil)) +} + +func TestSequentialDumpWriterWritesAndReusesRanges(t *testing.T) { + evmDir := t.TempDir() + ranges := []versionRange{{First: 1, Last: 2}, {First: 3, Last: 4}} + key := legacyStorageKey(1) + changes := map[int64]*iavl.ChangeSet{ + 2: {Pairs: []*iavl.KVPair{{Key: key, Value: []byte{1}}}}, + 4: {Pairs: []*iavl.KVPair{{Delete: true, Key: key}}}, + } + + writer, err := newSequentialDumpWriter(evmDir, ranges, zlib.BestSpeed, 1) + require.NoError(t, err) + for version := int64(1); version <= 4; version++ { + changeSet := changes[version] + if changeSet == nil { + changeSet = &iavl.ChangeSet{} + } + require.NoError(t, writer.write(version, changeSet)) + } + require.NoError(t, writer.finish()) + require.Equal(t, int64(2), writer.generatedFiles) + require.Equal(t, int64(4), writer.generatedRecords) + for _, blockRange := range ranges { + require.NoError(t, validateDumpRangeFile(dumpRangePath(evmDir, blockRange), blockRange)) + } + + reused, err := newSequentialDumpWriter(evmDir, ranges, zlib.BestSpeed, 1) + require.NoError(t, err) + for version := int64(1); version <= 4; version++ { + require.NoError(t, reused.write(version, &iavl.ChangeSet{})) + } + require.NoError(t, reused.finish()) + require.Equal(t, int64(2), reused.reusedFiles) + require.Equal(t, int64(4), reused.reusedRecords) + require.Zero(t, reused.generatedFiles) +} + +func TestWriteLegacyDumpRangesSkipsScanWhenAllRangesReusable(t *testing.T) { + evmDir := t.TempDir() + ranges := []versionRange{{First: 1, Last: 2}, {First: 3, Last: 4}} + writer, err := newSequentialDumpWriter(evmDir, ranges, zlib.BestSpeed, 1) + require.NoError(t, err) + for version := int64(1); version <= 4; version++ { + require.NoError(t, writer.write(version, &iavl.ChangeSet{})) + } + require.NoError(t, writer.finish()) + stalePartial := dumpRangePath(evmDir, ranges[0]) + ".partial" + require.NoError(t, os.WriteFile(stalePartial, []byte("stale"), 0o600)) + + source := &failingLegacySource{} + result, err := writeLegacyDumpRanges( + context.Background(), source, + legacyScanOptions{ + TempDir: t.TempDir(), FirstVersion: 1, LastVersion: 4, + SortChunkSize: externalRecordMemoryBytes, MaxSortChunks: 2, MinFree: 1, + }, + evmDir, ranges, zlib.BestSpeed, 1, + ) + require.NoError(t, err) + require.Zero(t, source.calls) + require.False(t, result.Scanned) + require.Equal(t, int64(2), result.ReusedFiles) + require.Equal(t, int64(4), result.ReusedRecords) + require.Zero(t, result.GeneratedFiles) + require.NoFileExists(t, stalePartial) + + require.NoError(t, os.Remove(dumpRangePath(evmDir, ranges[1]))) + source = &failingLegacySource{} + _, err = writeLegacyDumpRanges( + context.Background(), source, + legacyScanOptions{ + TempDir: t.TempDir(), FirstVersion: 1, LastVersion: 4, + SortChunkSize: externalRecordMemoryBytes, MaxSortChunks: 2, MinFree: 1, + }, + evmDir, ranges, zlib.BestSpeed, 1, + ) + require.ErrorContains(t, err, "legacy source must not be scanned") + require.Equal(t, 1, source.calls) +} + +func TestWriteDumpRangeUsesExistingChangeSetFormat(t *testing.T) { + key := append([]byte{0x02}, make([]byte, 52)...) + tree := &fakeTraverser{ + versions: []int64{100, 101, 102}, + changes: map[int64]*iavl.ChangeSet{ + 100: {Pairs: []*iavl.KVPair{{Key: key, Value: []byte{1}}}}, + 101: {Pairs: []*iavl.KVPair{{Delete: true, Key: key}}}, + }, + } + path := filepath.Join(t.TempDir(), "block-100.zz") + require.NoError(t, writeDumpRange(context.Background(), path, tree, versionRange{First: 100, Last: 102}, zlib.BestSpeed)) + require.Equal(t, int64(100), tree.start) + require.Equal(t, int64(102), tree.end) + require.FileExists(t, path) + require.NoFileExists(t, path+".partial") + + var versions []int64 + var changeSets []*iavl.ChangeSet + manifest, err := scanZlibChangeSets(path, func(version int64, changeSet *iavl.ChangeSet) error { + versions = append(versions, version) + changeSets = append(changeSets, changeSet) + return nil + }) + require.NoError(t, err) + require.Equal(t, []int64{100, 101, 102}, versions) + require.Equal(t, int64(3), manifest.Records) + require.Equal(t, tree.changes[100], changeSets[0]) + require.Equal(t, tree.changes[101], changeSets[1]) + require.Empty(t, changeSets[2].Pairs) +} + +func TestWriteDumpRangeTraversesRealIAVLUpdateDeleteAndEqualRoot(t *testing.T) { + database := iavldb.NewMemDB() + tree := iavl.NewMutableTree(database, 100, true, log.NewNopLogger()) + keyA := append([]byte{0x02}, make([]byte, 52)...) + keyB := append([]byte{0x02}, make([]byte, 52)...) + keyB[len(keyB)-1] = 1 + + _, err := tree.Set(keyA, []byte{1}) + require.NoError(t, err) + _, err = tree.Set(keyB, []byte{2}) + require.NoError(t, err) + _, version, err := tree.SaveVersion() + require.NoError(t, err) + require.Equal(t, int64(1), version) + + _, err = tree.Set(keyA, []byte{3}) + require.NoError(t, err) + _, version, err = tree.SaveVersion() + require.NoError(t, err) + require.Equal(t, int64(2), version) + + _, removed, err := tree.Remove(keyB) + require.NoError(t, err) + require.True(t, removed) + _, version, err = tree.SaveVersion() + require.NoError(t, err) + require.Equal(t, int64(3), version) + + _, version, err = tree.SaveVersion() + require.NoError(t, err) + require.Equal(t, int64(4), version) + + immutable := iavl.NewImmutableTree(database, 100, true, log.NewNopLogger()) + path := filepath.Join(t.TempDir(), "block-2.zz") + require.NoError(t, writeDumpRange(context.Background(), path, immutable, versionRange{First: 2, Last: 4}, zlib.BestSpeed)) + + changes := map[int64]*iavl.ChangeSet{} + _, err = scanZlibChangeSets(path, func(version int64, changeSet *iavl.ChangeSet) error { + changes[version] = changeSet + return nil + }) + require.NoError(t, err) + require.Equal(t, []*iavl.KVPair{{Key: keyA, Value: []byte{3}}}, changes[2].Pairs) + require.Equal(t, []*iavl.KVPair{{Delete: true, Key: keyB}}, changes[3].Pairs) + require.Empty(t, changes[4].Pairs) +} + +func TestWriteChangeSetMatchesGoldenEncoding(t *testing.T) { + pairs := []*iavl.KVPair{ + {Key: []byte("key"), Value: []byte("value")}, + {Delete: true, Key: []byte("deleted")}, + } + var compressedBuffer bytes.Buffer + writer := zlib.NewWriter(&compressedBuffer) + require.NoError(t, writeChangeSet(writer, 42, &iavl.ChangeSet{Pairs: pairs})) + require.NoError(t, writer.Close()) + reader, err := zlib.NewReader(&compressedBuffer) + require.NoError(t, err) + actual, err := io.ReadAll(reader) + require.NoError(t, err) + require.NoError(t, reader.Close()) + require.Equal(t, encodeChangeSet(t, 42, pairs...), actual) +} + +func TestWriteDumpRangeRejectsIncompleteOrOutOfOrderTraversal(t *testing.T) { + for _, testCase := range []struct { + name string + versions []int64 + message string + }{ + {name: "missing final", versions: []int64{10, 11}, message: "ended at version 11"}, + {name: "out of order", versions: []int64{10, 12}, message: "returned version 12, want 11"}, + } { + t.Run(testCase.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "block-10.zz") + err := writeDumpRange(context.Background(), path, &fakeTraverser{versions: testCase.versions}, versionRange{First: 10, Last: 12}, zlib.BestSpeed) + require.ErrorContains(t, err, testCase.message) + require.NoFileExists(t, path) + require.FileExists(t, path+".partial") + }) + } +} + +func TestWriteDumpRangeRequiresStablePredecessorAndBoundaryRoots(t *testing.T) { + t.Run("missing predecessor", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "block-10.zz") + tree := &fakeTraverser{ + versions: []int64{10}, hashResults: map[int64][][]byte{9: {nil}}, + } + err := writeDumpRange(context.Background(), path, tree, versionRange{First: 10, Last: 10}, zlib.BestSpeed) + require.ErrorContains(t, err, "version 9 root before traversal") + require.Zero(t, tree.start) + require.NoFileExists(t, path) + }) + + t.Run("predecessor changed", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "block-10.zz") + tree := &fakeTraverser{ + versions: []int64{10}, + hashResults: map[int64][][]byte{9: { + bytes.Repeat([]byte{1}, legacyHashLength), bytes.Repeat([]byte{2}, legacyHashLength), + }}, + } + err := writeDumpRange(context.Background(), path, tree, versionRange{First: 10, Last: 10}, zlib.BestSpeed) + require.ErrorContains(t, err, "version 9 root changed during traversal") + require.NoFileExists(t, path) + }) +} + +func TestWriteDumpRangePropagatesTraversalErrorAndCancellation(t *testing.T) { + t.Run("traversal error", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "block-1.zz") + boom := errors.New("boom") + err := writeDumpRange(context.Background(), path, &fakeTraverser{err: boom}, versionRange{First: 1, Last: 1}, zlib.BestSpeed) + require.ErrorIs(t, err, boom) + }) + + t.Run("canceled", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "block-1.zz") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := writeDumpRange(ctx, path, &fakeTraverser{versions: []int64{1}}, versionRange{First: 1, Last: 1}, zlib.BestSpeed) + require.ErrorIs(t, err, context.Canceled) + }) +} + +func TestValidateDumpRangeFileRejectsWrongRangeAndCorruption(t *testing.T) { + path := filepath.Join(t.TempDir(), "block-5.zz") + tree := &fakeTraverser{versions: []int64{5, 6}} + require.NoError(t, writeDumpRange(context.Background(), path, tree, versionRange{First: 5, Last: 6}, zlib.BestSpeed)) + require.NoError(t, validateDumpRangeFile(path, versionRange{First: 5, Last: 6})) + require.Error(t, validateDumpRangeFile(path, versionRange{First: 5, Last: 7})) + require.NoError(t, os.WriteFile(path, []byte("corrupt"), 0o600)) + require.Error(t, validateDumpRangeFile(path, versionRange{First: 5, Last: 6})) +} diff --git a/cmd/statediff-rewriter/fs.go b/cmd/statediff-rewriter/fs.go new file mode 100644 index 00000000..ed1d5a99 --- /dev/null +++ b/cmd/statediff-rewriter/fs.go @@ -0,0 +1,506 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "math" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "syscall" +) + +type freeSpaceReserver struct { + path string + minFree uint64 + mu sync.Mutex + reserved uint64 +} + +type reservedWriter struct { + writer io.Writer + reserver *freeSpaceReserver +} + +var statFilesystem = syscall.Statfs + +func filesystemSpace(path string) (uint64, uint64, error) { + var stat syscall.Statfs_t + if err := statFilesystem(path, &stat); err != nil { + return 0, 0, err + } + if stat.Bsize <= 0 { + return 0, 0, fmt.Errorf("filesystem block size must be positive") + } + blockSize := uint64(stat.Bsize) + if stat.Bavail > math.MaxUint64/blockSize { + return 0, 0, fmt.Errorf("filesystem free-space value overflows uint64") + } + return stat.Bavail * blockSize, blockSize, nil +} + +func newFreeSpaceReserver(path string, minFree uint64) (*freeSpaceReserver, error) { + if minFree == 0 { + return nil, fmt.Errorf("min-free-bytes must be greater than zero") + } + return &freeSpaceReserver{path: path, minFree: minFree}, nil +} + +func (reserver *freeSpaceReserver) reserve(bytes uint64) (func(), error) { + reserver.mu.Lock() + defer reserver.mu.Unlock() + available, blockSize, err := filesystemSpace(reserver.path) + if err != nil { + return nil, err + } + blocks := bytes / blockSize + if bytes%blockSize != 0 { + blocks++ + } + maxBlocks := math.MaxUint64 / blockSize + if maxBlocks < 2 || blocks > maxBlocks-2 { + return nil, fmt.Errorf("filesystem write reservation overflows uint64") + } + allocation := (blocks + 2) * blockSize + if reserver.reserved > math.MaxUint64-allocation { + return nil, fmt.Errorf("filesystem concurrent reservations overflow uint64") + } + totalReserved := reserver.reserved + allocation + if reserver.minFree > math.MaxUint64-totalReserved { + return nil, fmt.Errorf("filesystem free-space requirement overflows uint64") + } + required := reserver.minFree + totalReserved + if available < required { + return nil, fmt.Errorf("filesystem free space %d cannot preserve min-free-bytes %d with %d bytes reserved", available, reserver.minFree, totalReserved) + } + reserver.reserved = totalReserved + return func() { + reserver.mu.Lock() + reserver.reserved -= allocation + reserver.mu.Unlock() + }, nil +} + +func (writer reservedWriter) Write(body []byte) (int, error) { + release, err := writer.reserver.reserve(uint64(len(body))) + if err != nil { + return 0, err + } + defer release() + return writer.writer.Write(body) +} + +func acquireStagingLocks(paths ...string) ([]*os.File, error) { + unique := make(map[string]struct{}, len(paths)) + for _, path := range paths { + base := filepath.Clean(path) + if strings.HasSuffix(base, ".staging") { + base = strings.TrimSuffix(base, ".staging") + } else if strings.HasSuffix(base, ".sealed") { + base = strings.TrimSuffix(base, ".sealed") + } + unique[base+".lock"] = struct{}{} + } + lockPaths := make([]string, 0, len(unique)) + for path := range unique { + lockPaths = append(lockPaths, path) + } + sort.Strings(lockPaths) + locks := make([]*os.File, 0, len(lockPaths)) + for _, path := range lockPaths { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0o600) + if err == nil { + info, statErr := file.Stat() + if statErr != nil { + err = statErr + } else { + stat, ok := info.Sys().(*syscall.Stat_t) + if !info.Mode().IsRegular() || !ok || stat.Nlink != 1 { + err = fmt.Errorf("staging lock must be a regular file with one link") + } + } + } + if err == nil { + err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + } + if err != nil { + if file != nil { + _ = file.Close() + } + for _, lock := range locks { + _ = lock.Close() + } + return nil, fmt.Errorf("acquire staging lock %s: %w", path, err) + } + locks = append(locks, file) + } + return locks, nil +} + +func releaseStagingLocks(locks []*os.File) error { + var errs []error + for _, lock := range locks { + errs = append(errs, lock.Close()) + } + return errors.Join(errs...) +} + +func openLockedPartial(path string) (*os.File, error) { + if info, err := os.Lstat(path); err == nil { + stat, ok := info.Sys().(*syscall.Stat_t) + if !info.Mode().IsRegular() || !ok || stat.Nlink != 1 { + return nil, fmt.Errorf("partial file must be a regular non-symlink file with one link: %s", path) + } + if err := os.Remove(path); err != nil { + return nil, err + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY|syscall.O_NOFOLLOW, 0o600) + if err != nil { + return nil, err + } + closeWithError := func(cause error) (*os.File, error) { + return nil, errors.Join(cause, file.Close()) + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + return closeWithError(fmt.Errorf("lock partial file %s: %w", path, err)) + } + return file, nil +} + +func commitFileNoReplace(partial, target string) error { + if filepath.Dir(partial) != filepath.Dir(target) { + return fmt.Errorf("partial and target files must share a directory") + } + if err := os.Link(partial, target); err != nil { + return err + } + if err := syncDir(filepath.Dir(target)); err != nil { + return err + } + if err := os.Remove(partial); err != nil { + return err + } + return syncDir(filepath.Dir(target)) +} + +type noReplacePartialState uint8 + +const ( + noReplaceMissing noReplacePartialState = iota + noReplacePartialOnly + noReplaceTargetOnly + noReplaceLinkedBoth +) + +func inspectNoReplacePartial(partial, target, label string) (noReplacePartialState, error) { + partialInfo, partialErr := os.Lstat(partial) + targetInfo, targetErr := os.Lstat(target) + if partialErr != nil && !errors.Is(partialErr, os.ErrNotExist) { + return noReplaceMissing, partialErr + } + if targetErr != nil && !errors.Is(targetErr, os.ErrNotExist) { + return noReplaceMissing, targetErr + } + partialFound := partialErr == nil + targetFound := targetErr == nil + if !partialFound && !targetFound { + return noReplaceMissing, nil + } + validate := func(path string, info os.FileInfo, wantLinks uint64) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !info.Mode().IsRegular() || !ok || uint64(stat.Nlink) != wantLinks { + return fmt.Errorf("%s must be a regular non-symlink file with %d link(s): %s", label, wantLinks, path) + } + return nil + } + switch { + case partialFound && targetFound: + if !os.SameFile(partialInfo, targetInfo) { + return noReplaceMissing, fmt.Errorf("%s partial and final files contain conflicting inodes", label) + } + if err := validate(partial, partialInfo, 2); err != nil { + return noReplaceMissing, err + } + return noReplaceLinkedBoth, nil + case partialFound: + if err := validate(partial, partialInfo, 1); err != nil { + return noReplaceMissing, err + } + return noReplacePartialOnly, nil + case targetFound: + if err := validate(target, targetInfo, 1); err != nil { + return noReplaceMissing, err + } + return noReplaceTargetOnly, nil + } + return noReplaceMissing, fmt.Errorf("inspect %s reached an invalid state", label) +} + +func restoreNoReplacePartialState(partial, target string, state noReplacePartialState) error { + switch state { + case noReplaceMissing, noReplacePartialOnly: + return nil + case noReplaceTargetOnly: + if err := renameNoReplace(target, partial); err != nil { + return err + } + case noReplaceLinkedBoth: + if err := os.Remove(target); err != nil { + return err + } + default: + return fmt.Errorf("unknown no-replace recovery state %d", state) + } + return syncDir(filepath.Dir(partial)) +} + +func restoreNoReplacePartial(partial, target, label string) (bool, error) { + state, err := inspectNoReplacePartial(partial, target, label) + if err != nil { + return false, err + } + if state == noReplaceMissing { + return false, nil + } + if err := restoreNoReplacePartialState(partial, target, state); err != nil { + return false, err + } + return true, nil +} + +func readRegularFileNoFollow(path, label string) ([]byte, error) { + file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0) + if err != nil { + return nil, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("%s must be a regular non-symlink file: %s", label, path) + } + return io.ReadAll(file) +} + +func readMutableStateFile(path, label string) ([]byte, bool, error) { + file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("open %s: %w", label, err) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, false, fmt.Errorf("stat %s: %w", label, err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !info.Mode().IsRegular() || !ok || stat.Nlink != 1 { + return nil, false, fmt.Errorf("%s must be a regular non-symlink file with one link: %s", label, path) + } + body, err := io.ReadAll(file) + if err != nil { + return nil, false, fmt.Errorf("read %s: %w", label, err) + } + return body, true, nil +} + +func validateMutableStatePath(path, label string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("stat %s: %w", label, err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !info.Mode().IsRegular() || !ok || stat.Nlink != 1 { + return fmt.Errorf("%s must be absent or a regular non-symlink file with one link: %s", label, path) + } + return nil +} + +func canonicalFileInExistingDirectory(path, label string) (string, error) { + absolute, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + return "", err + } + parent := filepath.Dir(absolute) + info, err := os.Lstat(parent) + if err != nil { + return "", fmt.Errorf("stat %s directory: %w", label, err) + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("%s directory must be a non-symlink directory: %s", label, parent) + } + parent, err = filepath.EvalSymlinks(parent) + if err != nil { + return "", fmt.Errorf("resolve %s directory: %w", label, err) + } + return filepath.Join(parent, filepath.Base(absolute)), nil +} + +func ensureNonSymlinkDirectory(path, label string) (string, error) { + absolute, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + return "", err + } + if info, err := os.Lstat(absolute); err == nil { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("%s must be a non-symlink directory: %s", label, absolute) + } + return filepath.EvalSymlinks(absolute) + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + parent, err := filepath.EvalSymlinks(filepath.Dir(absolute)) + if err != nil { + return "", fmt.Errorf("resolve %s parent: %w", label, err) + } + created := filepath.Join(parent, filepath.Base(absolute)) + if err := os.Mkdir(created, 0o700); err != nil { + return "", err + } + if err := syncDir(parent); err != nil { + return "", err + } + return created, nil +} + +func atomicWrite(path string, body []byte, mode os.FileMode) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + ok := false + defer func() { + _ = tmp.Close() + if !ok { + _ = os.Remove(tmpName) + } + }() + if err := tmp.Chmod(mode); err != nil { + return err + } + if _, err := tmp.Write(body); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpName, path); err != nil { + return err + } + if err := syncDir(dir); err != nil { + return err + } + ok = true + return nil +} + +func atomicWriteNoReplace(path string, body []byte, mode os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpName) + }() + if err := tmp.Chmod(mode); err != nil { + return err + } + if _, err := tmp.Write(body); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Link(tmpName, path); err != nil { + return err + } + if err := os.Remove(tmpName); err != nil { + return err + } + return syncDir(dir) +} + +func atomicJSON(path string, value any) ([]byte, error) { + body, err := json.MarshalIndent(value, "", " ") + if err != nil { + return nil, err + } + body = append(body, '\n') + if err := atomicWrite(path, body, 0o644); err != nil { + return nil, err + } + return body, nil +} + +func atomicJSONNoReplace(path string, value any) ([]byte, error) { + body, err := json.MarshalIndent(value, "", " ") + if err != nil { + return nil, err + } + body = append(body, '\n') + if err := atomicWriteNoReplace(path, body, 0o600); err != nil { + return nil, err + } + return body, nil +} + +func atomicJSONWithMinFree(path string, value any, minFree uint64) ([]byte, error) { + body, err := json.MarshalIndent(value, "", " ") + if err != nil { + return nil, err + } + body = append(body, '\n') + reserver, err := newFreeSpaceReserver(filepath.Dir(path), minFree) + if err != nil { + return nil, err + } + release, err := reserver.reserve(uint64(len(body))) + if err != nil { + return nil, err + } + defer release() + if err := atomicWrite(path, body, 0o600); err != nil { + return nil, err + } + return body, nil +} + +func syncDir(path string) error { + dir, err := os.Open(path) + if err != nil { + return err + } + defer dir.Close() + if err := dir.Sync(); err != nil { + return fmt.Errorf("fsync directory %s: %w", path, err) + } + return nil +} diff --git a/cmd/statediff-rewriter/fs_test.go b/cmd/statediff-rewriter/fs_test.go new file mode 100644 index 00000000..b1336f84 --- /dev/null +++ b/cmd/statediff-rewriter/fs_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOpenLockedPartialRejectsAliasesAndNonRegularFiles(t *testing.T) { + for _, kind := range []string{"symlink", "hardlink", "fifo"} { + t.Run(kind, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "artifact.partial") + target := filepath.Join(dir, "target") + require.NoError(t, os.WriteFile(target, []byte("keep"), 0o600)) + switch kind { + case "symlink": + require.NoError(t, os.Symlink(target, path)) + case "hardlink": + require.NoError(t, os.Link(target, path)) + case "fifo": + require.NoError(t, syscall.Mkfifo(path, 0o600)) + } + + file, err := openLockedPartial(path) + require.ErrorContains(t, err, "regular non-symlink file with one link") + require.Nil(t, file) + body, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, []byte("keep"), body) + }) + } +} + +func TestOpenLockedPartialReplacesStaleRegularFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "artifact.partial") + require.NoError(t, os.WriteFile(path, []byte("stale"), 0o600)) + file, err := openLockedPartial(path) + require.NoError(t, err) + _, err = file.WriteString("fresh") + require.NoError(t, err) + require.NoError(t, file.Close()) + body, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, []byte("fresh"), body) +} + +func TestAcquireStagingLocksIsExclusive(t *testing.T) { + path := filepath.Join(t.TempDir(), "plan.staging") + first, err := acquireStagingLocks(path) + require.NoError(t, err) + _, err = acquireStagingLocks(path) + require.ErrorContains(t, err, "acquire staging lock") + require.NoError(t, releaseStagingLocks(first)) + third, err := acquireStagingLocks(path) + require.NoError(t, err) + require.NoError(t, releaseStagingLocks(third)) +} + +func TestCommitFileNoReplacePreservesExistingTarget(t *testing.T) { + dir := t.TempDir() + partial := filepath.Join(dir, "artifact.partial") + target := filepath.Join(dir, "artifact") + require.NoError(t, os.WriteFile(partial, []byte("new"), 0o600)) + require.NoError(t, os.WriteFile(target, []byte("old"), 0o600)) + require.Error(t, commitFileNoReplace(partial, target)) + body, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, []byte("old"), body) + body, err = os.ReadFile(partial) + require.NoError(t, err) + require.Equal(t, []byte("new"), body) +} + +func TestCommitFileNoReplaceCommitsNewTarget(t *testing.T) { + dir := t.TempDir() + partial := filepath.Join(dir, "artifact.partial") + target := filepath.Join(dir, "artifact") + require.NoError(t, os.WriteFile(partial, []byte("new"), 0o600)) + require.NoError(t, commitFileNoReplace(partial, target)) + body, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, []byte("new"), body) + _, err = os.Lstat(partial) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestRenameNoReplacePreservesConflictingDirectories(t *testing.T) { + base := t.TempDir() + source := filepath.Join(base, "plan.staging") + target := filepath.Join(base, "plan.sealed") + require.NoError(t, os.Mkdir(source, 0o700)) + require.NoError(t, os.Mkdir(target, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(source, "source"), []byte("source"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(target, "target"), []byte("target"), 0o600)) + require.Error(t, renameNoReplace(source, target)) + _, err := os.Stat(filepath.Join(source, "source")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(target, "target")) + require.NoError(t, err) +} + +func TestMutableStateLoadersRejectSymlinkAndHardlinkAliases(t *testing.T) { + for _, kind := range []string{"symlink", "hardlink"} { + t.Run(kind, func(t *testing.T) { + dir := t.TempDir() + checkpointTarget := filepath.Join(dir, "checkpoint-target.json") + checkpointAlias := filepath.Join(dir, "checkpoint-alias.json") + cp := checkpoint{RunID: "run", ManifestHash: "manifest", Mode: verifyMode} + require.NoError(t, saveCheckpoint(checkpointTarget, cp)) + linkTestArtifact(t, kind, checkpointTarget, checkpointAlias) + _, err := loadCheckpoint(checkpointAlias, cp.RunID, cp.ManifestHash, cp.Mode) + require.Error(t, err) + require.Error(t, saveCheckpoint(checkpointAlias, cp)) + + journalTarget := filepath.Join(dir, "journal-target.json") + journalAlias := filepath.Join(dir, "journal-alias.json") + journal := testWriteJournal() + require.NoError(t, saveWriteJournal(journalTarget, journal)) + linkTestArtifact(t, kind, journalTarget, journalAlias) + _, _, err = loadWriteJournal(journalAlias, journal.RunID, journal.ManifestHash, journal.Operation) + require.Error(t, err) + require.Error(t, saveWriteJournal(journalAlias, journal)) + }) + } +} + +func TestFilesystemSpaceRejectsOverflow(t *testing.T) { + previous := statFilesystem + statFilesystem = func(_ string, stat *syscall.Statfs_t) error { + stat.Bsize = 2 + stat.Bavail = ^uint64(0) + return nil + } + t.Cleanup(func() { statFilesystem = previous }) + _, _, err := filesystemSpace(t.TempDir()) + require.ErrorContains(t, err, "overflows uint64") +} + +func TestAtomicJSONMinFreeFailurePreservesExistingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "checkpoint.json") + require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600)) + _, err := atomicJSONWithMinFree(path, map[string]string{"new": "value"}, ^uint64(0)) + require.ErrorContains(t, err, "overflows uint64") + body, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, []byte("keep"), body) +} + +func linkTestArtifact(t *testing.T, kind, target, alias string) { + t.Helper() + if kind == "symlink" { + require.NoError(t, os.Symlink(target, alias)) + return + } + require.NoError(t, os.Link(target, alias)) +} diff --git a/cmd/statediff-rewriter/legacy_delta.go b/cmd/statediff-rewriter/legacy_delta.go new file mode 100644 index 00000000..56d2a896 --- /dev/null +++ b/cmd/statediff-rewriter/legacy_delta.go @@ -0,0 +1,184 @@ +package main + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + + "github.com/cosmos/iavl" +) + +const legacyStorageKeyLength = 1 + 20 + 32 + +type legacyDeltaKind byte + +const ( + legacyDeltaOld legacyDeltaKind = iota + legacyDeltaNew +) + +type legacyDelta struct { + Version int64 + Key []byte + Kind legacyDeltaKind + Value []byte +} + +type byteRecordReader interface { + Next() ([]byte, error) +} + +func encodeLegacyDelta(delta legacyDelta) ([]byte, error) { + if delta.Version < 1 { + return nil, fmt.Errorf("legacy delta has invalid version %d", delta.Version) + } + if len(delta.Key) != legacyStorageKeyLength || delta.Key[0] != 0x02 { + return nil, fmt.Errorf("legacy delta has invalid storage key length or prefix") + } + if delta.Kind != legacyDeltaOld && delta.Kind != legacyDeltaNew { + return nil, fmt.Errorf("legacy delta has invalid kind %d", delta.Kind) + } + if delta.Kind == legacyDeltaOld && len(delta.Value) != 0 { + return nil, fmt.Errorf("legacy old delta contains a value") + } + if delta.Kind == legacyDeltaNew && (len(delta.Value) == 0 || len(delta.Value) > 32) { + return nil, fmt.Errorf("legacy new delta has invalid value length %d", len(delta.Value)) + } + + body := make([]byte, 8+legacyStorageKeyLength+2+len(delta.Value)) + binary.BigEndian.PutUint64(body[:8], uint64(delta.Version)) + copy(body[8:8+legacyStorageKeyLength], delta.Key) + body[8+legacyStorageKeyLength] = byte(delta.Kind) + body[8+legacyStorageKeyLength+1] = byte(len(delta.Value)) + copy(body[8+legacyStorageKeyLength+2:], delta.Value) + return body, nil +} + +func decodeLegacyDelta(body []byte) (legacyDelta, error) { + const headerSize = 8 + legacyStorageKeyLength + 2 + if len(body) < headerSize { + return legacyDelta{}, fmt.Errorf("legacy delta record is %d bytes, want at least %d", len(body), headerSize) + } + delta := legacyDelta{ + Version: int64(binary.BigEndian.Uint64(body[:8])), + Key: append([]byte(nil), body[8:8+legacyStorageKeyLength]...), + Kind: legacyDeltaKind(body[8+legacyStorageKeyLength]), + } + valueLength := int(body[8+legacyStorageKeyLength+1]) + if len(body) != headerSize+valueLength { + return legacyDelta{}, fmt.Errorf("legacy delta record has value length %d and total size %d", valueLength, len(body)) + } + delta.Value = append([]byte(nil), body[headerSize:]...) + if _, err := encodeLegacyDelta(delta); err != nil { + return legacyDelta{}, err + } + return delta, nil +} + +func legacyDeltaLess(left, right []byte) bool { + return bytes.Compare(left, right) < 0 +} + +func reduceLegacyDeltas( + ctx context.Context, + reader byteRecordReader, + firstVersion, lastVersion int64, + emit func(int64, *iavl.ChangeSet) error, +) error { + if firstVersion < 1 || lastVersion < firstVersion { + return fmt.Errorf("invalid legacy output range %d-%d", firstVersion, lastVersion) + } + + nextBody, err := nextLegacyRecord(reader) + if err != nil { + return err + } + for version := firstVersion; version <= lastVersion; version++ { + if err := ctx.Err(); err != nil { + return err + } + changeSet := &iavl.ChangeSet{} + for nextBody != nil { + first, err := decodeLegacyDelta(nextBody) + if err != nil { + return err + } + if first.Version < version { + return fmt.Errorf("legacy delta version %d precedes output version %d", first.Version, version) + } + if first.Version > lastVersion { + return fmt.Errorf("legacy delta version %d exceeds output range ending at %d", first.Version, lastVersion) + } + if first.Version != version { + break + } + + key := first.Key + var oldSeen, newSeen bool + var newValue []byte + for nextBody != nil { + delta, err := decodeLegacyDelta(nextBody) + if err != nil { + return err + } + if delta.Version != version || !bytes.Equal(delta.Key, key) { + break + } + switch delta.Kind { + case legacyDeltaOld: + if oldSeen { + return fmt.Errorf("legacy version %d has duplicate old storage key %x", version, key) + } + oldSeen = true + case legacyDeltaNew: + if newSeen { + return fmt.Errorf("legacy version %d has duplicate new storage key %x", version, key) + } + newSeen = true + newValue = append(newValue[:0], delta.Value...) + } + nextBody, err = nextLegacyRecord(reader) + if err != nil { + return err + } + } + if !oldSeen && !newSeen { + return fmt.Errorf("legacy version %d has empty delta group for key %x", version, key) + } + pair := &iavl.KVPair{Key: append([]byte(nil), key...)} + if newSeen { + pair.Value = append([]byte(nil), newValue...) + } else { + pair.Delete = true + } + changeSet.Pairs = append(changeSet.Pairs, pair) + } + if err := ctx.Err(); err != nil { + return err + } + if err := emit(version, changeSet); err != nil { + return err + } + } + if nextBody != nil { + return fmt.Errorf("legacy delta stream contains records after version %d", lastVersion) + } + return nil +} + +func nextLegacyRecord(reader byteRecordReader) ([]byte, error) { + body, err := reader.Next() + if errors.Is(err, io.EOF) { + return nil, nil + } + if err != nil { + return nil, err + } + if body == nil { + return nil, fmt.Errorf("legacy delta reader returned a nil record without EOF") + } + return body, nil +} diff --git a/cmd/statediff-rewriter/legacy_delta_test.go b/cmd/statediff-rewriter/legacy_delta_test.go new file mode 100644 index 00000000..7c8241ec --- /dev/null +++ b/cmd/statediff-rewriter/legacy_delta_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "errors" + "io" + "testing" + + "github.com/cosmos/iavl" + "github.com/stretchr/testify/require" +) + +type sliceByteRecordReader struct { + records [][]byte + index int + err error +} + +func (reader *sliceByteRecordReader) Next() ([]byte, error) { + if reader.index >= len(reader.records) { + if reader.err != nil { + return nil, reader.err + } + return nil, io.EOF + } + record := reader.records[reader.index] + reader.index++ + return record, nil +} + +func legacyStorageKey(marker byte) []byte { + key := make([]byte, legacyStorageKeyLength) + key[0] = 0x02 + key[len(key)-1] = marker + return key +} + +func mustLegacyDelta(t *testing.T, version int64, key []byte, kind legacyDeltaKind, value ...byte) []byte { + t.Helper() + body, err := encodeLegacyDelta(legacyDelta{Version: version, Key: key, Kind: kind, Value: value}) + require.NoError(t, err) + return body +} + +func TestReduceLegacyDeltas(t *testing.T) { + keyA, keyB, keyC := legacyStorageKey(1), legacyStorageKey(2), legacyStorageKey(3) + reader := &sliceByteRecordReader{records: [][]byte{ + mustLegacyDelta(t, 2, keyA, legacyDeltaNew, 1), + mustLegacyDelta(t, 3, keyA, legacyDeltaOld), + mustLegacyDelta(t, 3, keyA, legacyDeltaNew, 2), + mustLegacyDelta(t, 3, keyB, legacyDeltaNew, 3), + mustLegacyDelta(t, 5, keyB, legacyDeltaOld), + mustLegacyDelta(t, 5, keyC, legacyDeltaNew, 4), + }} + + changes := make(map[int64]*iavl.ChangeSet) + require.NoError(t, reduceLegacyDeltas(context.Background(), reader, 1, 5, func(version int64, changeSet *iavl.ChangeSet) error { + changes[version] = changeSet + return nil + })) + require.Empty(t, changes[1].Pairs) + require.Equal(t, []*iavl.KVPair{{Key: keyA, Value: []byte{1}}}, changes[2].Pairs) + require.Equal(t, []*iavl.KVPair{{Key: keyA, Value: []byte{2}}, {Key: keyB, Value: []byte{3}}}, changes[3].Pairs) + require.Empty(t, changes[4].Pairs) + require.Equal(t, []*iavl.KVPair{{Delete: true, Key: keyB}, {Key: keyC, Value: []byte{4}}}, changes[5].Pairs) +} + +func TestReduceLegacyDeltasRejectsDuplicatesAndReaderErrors(t *testing.T) { + key := legacyStorageKey(1) + t.Run("duplicate new", func(t *testing.T) { + reader := &sliceByteRecordReader{records: [][]byte{ + mustLegacyDelta(t, 2, key, legacyDeltaNew, 1), + mustLegacyDelta(t, 2, key, legacyDeltaNew, 2), + }} + err := reduceLegacyDeltas(context.Background(), reader, 1, 2, func(int64, *iavl.ChangeSet) error { return nil }) + require.ErrorContains(t, err, "duplicate new") + }) + + t.Run("reader", func(t *testing.T) { + boom := errors.New("boom") + err := reduceLegacyDeltas(context.Background(), &sliceByteRecordReader{err: boom}, 1, 2, func(int64, *iavl.ChangeSet) error { return nil }) + require.ErrorIs(t, err, boom) + }) + + t.Run("emit", func(t *testing.T) { + boom := errors.New("boom") + err := reduceLegacyDeltas(context.Background(), &sliceByteRecordReader{}, 1, 2, func(int64, *iavl.ChangeSet) error { return boom }) + require.ErrorIs(t, err, boom) + }) +} + +func TestReduceLegacyDeltasHonorsCancellationAcrossEmptyVersions(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + emitted := 0 + err := reduceLegacyDeltas(ctx, &sliceByteRecordReader{}, 1, 1_000_000, func(int64, *iavl.ChangeSet) error { + emitted++ + cancel() + return nil + }) + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, 1, emitted) +} + +func TestLegacyDeltaEncodingRejectsMalformedRecords(t *testing.T) { + key := legacyStorageKey(1) + _, err := encodeLegacyDelta(legacyDelta{Version: 1, Key: key, Kind: legacyDeltaOld, Value: []byte{1}}) + require.ErrorContains(t, err, "old delta contains a value") + + body := mustLegacyDelta(t, 1, key, legacyDeltaNew, 1) + body[len(body)-2] = 2 + _, err = decodeLegacyDelta(body) + require.ErrorContains(t, err, "value length") +} diff --git a/cmd/statediff-rewriter/legacy_extract.go b/cmd/statediff-rewriter/legacy_extract.go new file mode 100644 index 00000000..fa0631b5 --- /dev/null +++ b/cmd/statediff-rewriter/legacy_extract.go @@ -0,0 +1,255 @@ +package main + +import ( + "compress/zlib" + "context" + "errors" + "fmt" + "os" + + "github.com/cosmos/iavl" +) + +type legacyDumpResult struct { + Scan legacyScanReport + Scanned bool + GeneratedFiles int64 + ReusedFiles int64 + GeneratedRecords int64 + ReusedRecords int64 +} + +func writeLegacyDumpRanges( + ctx context.Context, + source legacyRecordSource, + scanOptions legacyScanOptions, + evmDir string, + ranges []versionRange, + zlibLevel int, + minFree uint64, +) (result legacyDumpResult, returnErr error) { + if len(ranges) == 0 { + return result, fmt.Errorf("legacy dump ranges are empty") + } + if ranges[0].First != scanOptions.FirstVersion || ranges[len(ranges)-1].Last != scanOptions.LastVersion { + return result, fmt.Errorf( + "legacy dump ranges cover %d-%d, scan covers %d-%d", + ranges[0].First, ranges[len(ranges)-1].Last, + scanOptions.FirstVersion, scanOptions.LastVersion, + ) + } + writer, err := newSequentialDumpWriterContext(ctx, evmDir, ranges, zlibLevel, minFree) + if err != nil { + return result, err + } + if writer.reusedFiles == int64(len(ranges)) { + result.ReusedFiles = writer.reusedFiles + result.ReusedRecords = writer.reusedRecords + return result, nil + } + defer func() { returnErr = errors.Join(returnErr, writer.abort()) }() + + report, err := scanLegacyChangeSets(ctx, source, scanOptions, writer.write) + if err != nil { + return result, err + } + if err := writer.finish(); err != nil { + return result, err + } + result.Scan = report + result.Scanned = true + result.GeneratedFiles = writer.generatedFiles + result.ReusedFiles = writer.reusedFiles + result.GeneratedRecords = writer.generatedRecords + result.ReusedRecords = writer.reusedRecords + return result, nil +} + +type sequentialDumpWriter struct { + evmDir string + ranges []versionRange + reused []bool + zlibLevel int + minFree uint64 + space *freeSpaceReserver + rangeIndex int + expected int64 + file *os.File + compressed *zlib.Writer + partialPath string + finalPath string + finished bool + generatedFiles int64 + reusedFiles int64 + generatedRecords int64 + reusedRecords int64 +} + +func newSequentialDumpWriter( + evmDir string, + ranges []versionRange, + zlibLevel int, + minFree uint64, +) (*sequentialDumpWriter, error) { + return newSequentialDumpWriterContext(context.Background(), evmDir, ranges, zlibLevel, minFree) +} + +func newSequentialDumpWriterContext( + ctx context.Context, + evmDir string, + ranges []versionRange, + zlibLevel int, + minFree uint64, +) (*sequentialDumpWriter, error) { + if ctx == nil { + return nil, fmt.Errorf("sequential dump writer context is required") + } + if len(ranges) == 0 { + return nil, fmt.Errorf("sequential dump ranges are empty") + } + for index, blockRange := range ranges { + if blockRange.First < 1 || blockRange.Last < blockRange.First { + return nil, fmt.Errorf("invalid sequential dump range %d-%d", blockRange.First, blockRange.Last) + } + if index > 0 && blockRange.First != ranges[index-1].Last+1 { + return nil, fmt.Errorf("non-contiguous sequential dump ranges at %d", blockRange.First) + } + } + space, err := newFreeSpaceReserver(evmDir, minFree) + if err != nil { + return nil, err + } + writer := &sequentialDumpWriter{ + evmDir: evmDir, ranges: ranges, reused: make([]bool, len(ranges)), + zlibLevel: zlibLevel, minFree: minFree, space: space, expected: ranges[0].First, + } + for index, blockRange := range ranges { + path := dumpRangePath(evmDir, blockRange) + if _, err := os.Lstat(path); err == nil { + if err := validateReusableDumpRange(ctx, path, blockRange); err != nil { + return nil, err + } + writer.reused[index] = true + writer.reusedFiles++ + writer.reusedRecords += blockRange.Last - blockRange.First + 1 + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + } + return writer, nil +} + +func (writer *sequentialDumpWriter) write(version int64, changeSet *iavl.ChangeSet) error { + if writer.finished { + return fmt.Errorf("sequential dump writer is finished") + } + if version != writer.expected { + return fmt.Errorf("sequential dump received version %d, want %d", version, writer.expected) + } + if writer.rangeIndex >= len(writer.ranges) { + return fmt.Errorf("sequential dump received version %d after final range", version) + } + blockRange := writer.ranges[writer.rangeIndex] + if version < blockRange.First || version > blockRange.Last { + return fmt.Errorf("sequential dump version %d is outside current range %d-%d", version, blockRange.First, blockRange.Last) + } + if !writer.reused[writer.rangeIndex] { + if writer.file == nil { + if version != blockRange.First { + return fmt.Errorf("sequential dump opens range %d-%d at version %d", blockRange.First, blockRange.Last, version) + } + if err := writer.openRange(blockRange); err != nil { + return err + } + } + if err := writeChangeSet(writer.compressed, version, changeSet); err != nil { + return err + } + writer.generatedRecords++ + } + writer.expected++ + if version == blockRange.Last { + if !writer.reused[writer.rangeIndex] { + if err := writer.closeRange(); err != nil { + return err + } + writer.generatedFiles++ + } + writer.rangeIndex++ + } + return nil +} + +func (writer *sequentialDumpWriter) openRange(blockRange versionRange) error { + if err := ensureFreeSpace(writer.evmDir, writer.minFree); err != nil { + return err + } + writer.finalPath = dumpRangePath(writer.evmDir, blockRange) + writer.partialPath = writer.finalPath + ".partial" + file, err := openLockedPartial(writer.partialPath) + if err != nil { + return err + } + compressed, err := zlib.NewWriterLevel(reservedWriter{writer: file, reserver: writer.space}, writer.zlibLevel) + if err != nil { + _ = file.Close() + return err + } + writer.file, writer.compressed = file, compressed + return nil +} + +func (writer *sequentialDumpWriter) closeRange() error { + compressed, file := writer.compressed, writer.file + writer.compressed, writer.file = nil, nil + if compressed == nil || file == nil { + return fmt.Errorf("sequential dump range is not open") + } + defer func() { _ = file.Close() }() + if err := compressed.Close(); err != nil { + _ = file.Close() + return err + } + if err := file.Sync(); err != nil { + _ = file.Close() + return err + } + if err := commitFileNoReplace(writer.partialPath, writer.finalPath); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + writer.partialPath, writer.finalPath = "", "" + return nil +} + +func (writer *sequentialDumpWriter) finish() error { + if writer.file != nil || writer.compressed != nil { + return fmt.Errorf("sequential dump finished with an open range") + } + if writer.rangeIndex != len(writer.ranges) { + return fmt.Errorf("sequential dump finished after %d of %d ranges", writer.rangeIndex, len(writer.ranges)) + } + if writer.expected != writer.ranges[len(writer.ranges)-1].Last+1 { + return fmt.Errorf("sequential dump finished at version %d", writer.expected-1) + } + writer.finished = true + return nil +} + +func (writer *sequentialDumpWriter) abort() error { + if writer == nil || writer.finished { + return nil + } + var errs []error + if writer.compressed != nil { + errs = append(errs, writer.compressed.Close()) + writer.compressed = nil + } + if writer.file != nil { + errs = append(errs, writer.file.Close()) + writer.file = nil + } + return errors.Join(errs...) +} diff --git a/cmd/statediff-rewriter/legacy_golden.go b/cmd/statediff-rewriter/legacy_golden.go new file mode 100644 index 00000000..ade68ad4 --- /dev/null +++ b/cmd/statediff-rewriter/legacy_golden.go @@ -0,0 +1,113 @@ +package main + +import ( + "bytes" + "context" + "fmt" + + "github.com/cosmos/iavl" + "github.com/ethereum/go-ethereum/rlp" + "github.com/evmos/ethermint/debank/statediff" +) + +func legacySampleVersions(first, last int64, count int) ([]int64, error) { + if first < 1 || last < first || count < 1 { + return nil, fmt.Errorf("invalid legacy sample range/count %d-%d/%d", first, last, count) + } + total := last - first + 1 + if int64(count) >= total { + versions := make([]int64, 0, total) + for version := first; version <= last; version++ { + versions = append(versions, version) + } + return versions, nil + } + if count == 1 { + return []int64{first}, nil + } + versions := make([]int64, 0, count) + for index := 0; index < count; index++ { + version := first + int64(index)*(total-1)/int64(count-1) + if len(versions) == 0 || versions[len(versions)-1] != version { + versions = append(versions, version) + } + } + return versions, nil +} + +func verifyLegacyDumpSamples( + ctx context.Context, + evmDir string, + ranges []versionRange, + reference stateChangeSource, + count int, +) (int, error) { + if len(ranges) == 0 || reference == nil { + return 0, fmt.Errorf("legacy sample ranges and reference traverser are required") + } + versions, err := legacySampleVersions(ranges[0].First, ranges[len(ranges)-1].Last, count) + if err != nil { + return 0, err + } + wanted := make(map[int64]struct{}, len(versions)) + actual := make(map[int64][]byte, len(versions)) + for _, version := range versions { + wanted[version] = struct{}{} + } + for _, blockRange := range ranges { + _, err := scanZlibChangeSetsContext(ctx, dumpRangePath(evmDir, blockRange), func(version int64, changeSet *iavl.ChangeSet) error { + if err := ctx.Err(); err != nil { + return err + } + if _, ok := wanted[version]; !ok { + return nil + } + canonical, err := statediff.CanonicalStorageDiff(changeSet) + if err != nil { + return fmt.Errorf("decode sequential legacy version %d: %w", version, err) + } + body, err := rlp.EncodeToBytes(canonical) + if err != nil { + return err + } + actual[version] = body + return nil + }) + if err != nil { + return 0, err + } + } + for _, version := range versions { + if err := ctx.Err(); err != nil { + return 0, err + } + actualBody, ok := actual[version] + if !ok { + return 0, fmt.Errorf("legacy sample version %d is missing from dump", version) + } + callbacks := 0 + var referenceBody []byte + err := traverseVerifiedStateChanges(reference, version, version, func(gotVersion int64, changeSet *iavl.ChangeSet) error { + callbacks++ + if callbacks != 1 || gotVersion != version { + return fmt.Errorf("legacy reference returned version %d callback %d, want %d once", gotVersion, callbacks, version) + } + canonical, err := statediff.CanonicalStorageDiff(changeSet) + if err != nil { + return err + } + referenceBody, err = rlp.EncodeToBytes(canonical) + return err + }) + if err != nil { + return 0, fmt.Errorf("legacy reference version %d: %w", version, err) + } + if callbacks != 1 { + return 0, fmt.Errorf("legacy reference version %d invoked %d callbacks", version, callbacks) + } + if !bytes.Equal(actualBody, referenceBody) { + return 0, fmt.Errorf("legacy sequential/reference storage mismatch at version %d", version) + } + } + return len(versions), nil +} diff --git a/cmd/statediff-rewriter/legacy_golden_test.go b/cmd/statediff-rewriter/legacy_golden_test.go new file mode 100644 index 00000000..03e57f6c --- /dev/null +++ b/cmd/statediff-rewriter/legacy_golden_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "bytes" + "context" + "testing" + + "github.com/cosmos/iavl" + "github.com/stretchr/testify/require" +) + +type versionMapTraverser map[int64]*iavl.ChangeSet + +func (traverser versionMapTraverser) VersionHash(version int64) ([]byte, error) { + return bytes.Repeat([]byte{byte(version)}, legacyHashLength), nil +} + +func (traverser versionMapTraverser) TraverseStateChanges( + start, end int64, + callback func(int64, *iavl.ChangeSet) error, +) error { + for version := start; version <= end; version++ { + changeSet := traverser[version] + if changeSet == nil { + changeSet = &iavl.ChangeSet{} + } + if err := callback(version, changeSet); err != nil { + return err + } + } + return nil +} + +func TestLegacySampleVersionsIncludesBoundaries(t *testing.T) { + versions, err := legacySampleVersions(10, 20, 3) + require.NoError(t, err) + require.Equal(t, []int64{10, 15, 20}, versions) + versions, err = legacySampleVersions(10, 12, 10) + require.NoError(t, err) + require.Equal(t, []int64{10, 11, 12}, versions) +} + +func TestVerifyLegacyDumpSamplesComparesCanonicalStorageOnly(t *testing.T) { + evmDir := t.TempDir() + blockRange := versionRange{First: 1, Last: 5} + storageKey := legacyStorageKey(1) + nonStorageKey := []byte{0x01, 0x02} + actual := versionMapTraverser{ + 3: {Pairs: []*iavl.KVPair{{Key: storageKey, Value: []byte{3}}}}, + 5: {Pairs: []*iavl.KVPair{{Delete: true, Key: storageKey}}}, + } + reference := versionMapTraverser{ + 1: {Pairs: []*iavl.KVPair{{Key: nonStorageKey, Value: []byte{1}}}}, + 3: {Pairs: []*iavl.KVPair{ + {Key: nonStorageKey, Value: []byte{2}}, {Key: storageKey, Value: []byte{3}}, + }}, + 5: {Pairs: []*iavl.KVPair{{Delete: true, Key: storageKey}}}, + } + require.NoError(t, writeDumpRange( + context.Background(), dumpRangePath(evmDir, blockRange), actual, blockRange, 1, + )) + verified, err := verifyLegacyDumpSamples( + context.Background(), evmDir, []versionRange{blockRange}, reference, 3, + ) + require.NoError(t, err) + require.Equal(t, 3, verified) + + reference[5] = &iavl.ChangeSet{} + _, err = verifyLegacyDumpSamples(context.Background(), evmDir, []versionRange{blockRange}, reference, 3) + require.ErrorContains(t, err, "mismatch at version 5") +} diff --git a/cmd/statediff-rewriter/legacy_graph.go b/cmd/statediff-rewriter/legacy_graph.go new file mode 100644 index 00000000..fb3def9e --- /dev/null +++ b/cmd/statediff-rewriter/legacy_graph.go @@ -0,0 +1,178 @@ +package main + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + + "github.com/cosmos/iavl" +) + +const ( + legacyCoverageSelf byte = iota + legacyCoverageRoot + legacyCoverageLeft + legacyCoverageRight + + legacyCoverageRecordLength = legacyHashLength + 1 + 8 + 8 +) + +// legacyCoverageRecord describes either a node's complete lifetime (self), or +// one root/parent reference to that node. A valid persistent tree has exactly +// one incoming reference for every saved version in the node's lifetime. +type legacyCoverageRecord struct { + Hash []byte + Kind byte + FromVersion int64 + ToVersion int64 +} + +func encodeLegacyCoverage(record legacyCoverageRecord) ([]byte, error) { + if len(record.Hash) != legacyHashLength || record.FromVersion < 1 || + record.ToVersion < record.FromVersion || record.Kind > legacyCoverageRight { + return nil, fmt.Errorf("invalid legacy coverage record") + } + body := make([]byte, legacyCoverageRecordLength) + copy(body, record.Hash) + body[legacyHashLength] = record.Kind + binary.BigEndian.PutUint64(body[legacyHashLength+1:], uint64(record.FromVersion)) + binary.BigEndian.PutUint64(body[legacyHashLength+9:], uint64(record.ToVersion)) + return body, nil +} + +func decodeLegacyCoverage(body []byte) (legacyCoverageRecord, error) { + if len(body) != legacyCoverageRecordLength { + return legacyCoverageRecord{}, fmt.Errorf( + "legacy coverage has length %d, want %d", len(body), legacyCoverageRecordLength, + ) + } + record := legacyCoverageRecord{ + Hash: append([]byte(nil), body[:legacyHashLength]...), + Kind: body[legacyHashLength], + FromVersion: int64(binary.BigEndian.Uint64(body[legacyHashLength+1 : legacyHashLength+9])), + ToVersion: int64(binary.BigEndian.Uint64(body[legacyHashLength+9:])), + } + if record.Kind > legacyCoverageRight || record.FromVersion < 1 || record.ToVersion < record.FromVersion { + return legacyCoverageRecord{}, fmt.Errorf("invalid legacy coverage lifetime or kind") + } + return record, nil +} + +func legacyCoverageLess(left, right []byte) bool { + if order := bytes.Compare(left[:legacyHashLength], right[:legacyHashLength]); order != 0 { + return order < 0 + } + leftKind, rightKind := left[legacyHashLength], right[legacyHashLength] + if leftKind == legacyCoverageSelf || rightKind == legacyCoverageSelf { + return leftKind < rightKind + } + if order := bytes.Compare(left[legacyHashLength+1:], right[legacyHashLength+1:]); order != 0 { + return order < 0 + } + return leftKind < rightKind +} + +func validateLegacyCoverage( + ctx context.Context, + source legacyRecordSource, + coverage byteRecordReader, + expectedNodes int64, +) (int64, error) { + coverageBody, err := nextOptionalRecord(coverage) + if err != nil { + return 0, err + } + var previousHash []byte + var checked int64 + err = source.InspectNodes(func(node iavl.LegacyNodeRecord) error { + if err := ctx.Err(); err != nil { + return err + } + if previousHash != nil && bytes.Compare(previousHash, node.Hash) >= 0 { + return fmt.Errorf("legacy graph node hashes are not strictly increasing at %x", node.Hash) + } + previousHash = append(previousHash[:0], node.Hash...) + if coverageBody == nil { + return fmt.Errorf("legacy node %x has no reachability record", node.Hash) + } + self, err := decodeLegacyCoverage(coverageBody) + if err != nil { + return err + } + if order := bytes.Compare(self.Hash, node.Hash); order != 0 { + if order < 0 { + return fmt.Errorf("legacy reachability references missing node %x", self.Hash) + } + return fmt.Errorf("legacy node %x has no reachability record", node.Hash) + } + if self.Kind != legacyCoverageSelf || self.FromVersion != node.Version { + return fmt.Errorf("legacy node %x has invalid self lifetime", node.Hash) + } + coverageBody, err = nextOptionalRecord(coverage) + if err != nil { + return err + } + coveredTo := int64(0) + for coverageBody != nil { + record, decodeErr := decodeLegacyCoverage(coverageBody) + if decodeErr != nil { + return decodeErr + } + if !bytes.Equal(record.Hash, node.Hash) { + break + } + if record.Kind == legacyCoverageSelf { + return fmt.Errorf("legacy node %x has duplicate self lifetimes", node.Hash) + } + if record.FromVersion < node.Version || record.ToVersion > self.ToVersion { + return fmt.Errorf( + "legacy node %x coverage %d-%d is outside lifetime %d-%d", + node.Hash, record.FromVersion, record.ToVersion, node.Version, self.ToVersion, + ) + } + if coveredTo == 0 { + if record.FromVersion != node.Version { + return fmt.Errorf( + "legacy node %x reachability starts at %d, want %d", + node.Hash, record.FromVersion, node.Version, + ) + } + } else if record.FromVersion != coveredTo+1 { + if record.FromVersion <= coveredTo { + return fmt.Errorf( + "legacy node %x has overlapping reachability at version %d", + node.Hash, record.FromVersion, + ) + } + return fmt.Errorf("legacy node %x has reachability gap after version %d", node.Hash, coveredTo) + } + coveredTo = record.ToVersion + coverageBody, err = nextOptionalRecord(coverage) + if err != nil { + return err + } + } + if coveredTo != self.ToVersion { + return fmt.Errorf( + "legacy node %x reachability ends at %d, want %d", node.Hash, coveredTo, self.ToVersion, + ) + } + checked++ + return nil + }) + if err != nil { + return checked, fmt.Errorf("validate legacy graph reachability: %w", err) + } + if coverageBody != nil { + record, err := decodeLegacyCoverage(coverageBody) + if err != nil { + return checked, err + } + return checked, fmt.Errorf("legacy reachability references missing node %x", record.Hash) + } + if checked != expectedNodes { + return checked, fmt.Errorf("legacy graph checked %d nodes, want %d", checked, expectedNodes) + } + return checked, nil +} diff --git a/cmd/statediff-rewriter/legacy_scan.go b/cmd/statediff-rewriter/legacy_scan.go new file mode 100644 index 00000000..677c836f --- /dev/null +++ b/cmd/statediff-rewriter/legacy_scan.go @@ -0,0 +1,466 @@ +package main + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/cosmos/iavl" + iavldb "github.com/cosmos/iavl/db" +) + +const ( + defaultLegacySortChunkBytes = int64(512 << 20) + defaultLegacySortMaxChunks = 512 + legacyHashLength = 32 + legacyOrphanRecordLength = legacyHashLength + 8 + 8 +) + +type legacyScanOptions struct { + TempDir string + FirstVersion int64 + LastVersion int64 + SortChunkSize int64 + MaxSortChunks int + MinFree uint64 +} + +type legacyScanReport struct { + LegacyFirstVersion int64 + LegacyLastVersion int64 + Roots int64 + Nodes int64 + Leaves int64 + StorageLeaves int64 + Orphans int64 + StorageOrphans int64 + Branches int64 + GraphNodes int64 + CoverageRecords int64 +} + +type legacyRecordSource interface { + InspectRoots(func(iavl.LegacyRootRecord) error) error + InspectNodes(func(iavl.LegacyNodeRecord) error) error + InspectOrphans(func(iavl.LegacyOrphanRecord) error) error +} + +type iavlLegacyRecordSource struct{ db iavldb.DB } + +func (source iavlLegacyRecordSource) InspectRoots(callback func(iavl.LegacyRootRecord) error) error { + return iavl.InspectLegacyRoots(source.db, callback) +} + +func (source iavlLegacyRecordSource) InspectNodes(callback func(iavl.LegacyNodeRecord) error) error { + return iavl.InspectLegacyNodes(source.db, callback) +} + +func (source iavlLegacyRecordSource) InspectOrphans(callback func(iavl.LegacyOrphanRecord) error) error { + return iavl.InspectLegacyOrphans(source.db, callback) +} + +func legacyLatestRootVersion(db iavldb.DB) (returnVersion int64, returnErr error) { + if db == nil { + return 0, fmt.Errorf("legacy database is required") + } + iterator, err := db.ReverseIterator([]byte{'r'}, []byte{'s'}) + if err != nil { + return 0, fmt.Errorf("open legacy root iterator: %w", err) + } + defer func() { returnErr = errors.Join(returnErr, iterator.Close()) }() + if !iterator.Valid() { + if err := iterator.Error(); err != nil { + return 0, fmt.Errorf("read legacy root iterator: %w", err) + } + return 0, nil + } + key, value := iterator.Key(), iterator.Value() + if len(key) != 9 || key[0] != 'r' { + return 0, fmt.Errorf("invalid latest legacy root key %x", key) + } + version := int64(binary.BigEndian.Uint64(key[1:])) + if version < 1 { + return 0, fmt.Errorf("invalid latest legacy root version %d", version) + } + if len(value) != 0 && len(value) != legacyHashLength { + return 0, fmt.Errorf("latest legacy root %d has hash length %d", version, len(value)) + } + return version, nil +} + +func scanLegacyChangeSets( + ctx context.Context, + source legacyRecordSource, + options legacyScanOptions, + emit func(int64, *iavl.ChangeSet) error, +) (report legacyScanReport, returnErr error) { + if source == nil { + return report, fmt.Errorf("legacy record source is required") + } + if options.FirstVersion < 1 || options.LastVersion < options.FirstVersion { + return report, fmt.Errorf("invalid legacy scan range %d-%d", options.FirstVersion, options.LastVersion) + } + if options.TempDir == "" { + return report, fmt.Errorf("legacy scan temporary directory is required") + } + if options.SortChunkSize < externalRecordMemoryBytes { + return report, fmt.Errorf("legacy sort chunk size must be at least %d", externalRecordMemoryBytes) + } + if options.MaxSortChunks < 2 { + return report, fmt.Errorf("legacy max sort chunks must be at least 2") + } + if emit == nil { + return report, fmt.Errorf("legacy changeset callback is required") + } + workDir, err := os.MkdirTemp(options.TempDir, ".statediff-legacy-*") + if err != nil { + return report, fmt.Errorf("create legacy scan directory: %w", err) + } + defer func() { + returnErr = errors.Join(returnErr, removeLegacyWorkDir(workDir)) + }() + + deltaSorter, err := newExternalSorterWithContext( + ctx, workDir, options.SortChunkSize, options.MaxSortChunks, options.MinFree, legacyDeltaLess, + ) + if err != nil { + return report, err + } + defer func() { returnErr = errors.Join(returnErr, deltaSorter.Close()) }() + orphanSorter, err := newExternalSorterWithContext( + ctx, workDir, options.SortChunkSize, options.MaxSortChunks, options.MinFree, legacyOrphanLess, + ) + if err != nil { + return report, err + } + defer func() { returnErr = errors.Join(returnErr, orphanSorter.Close()) }() + coverageSorter, err := newExternalSorterWithContext( + ctx, workDir, options.SortChunkSize, options.MaxSortChunks, options.MinFree, legacyCoverageLess, + ) + if err != nil { + return report, err + } + defer func() { returnErr = errors.Join(returnErr, coverageSorter.Close()) }() + expectedRoot := int64(1) + err = source.InspectRoots(func(root iavl.LegacyRootRecord) error { + if err := ctx.Err(); err != nil { + return err + } + if root.Version != expectedRoot { + return fmt.Errorf("legacy root version %d, want %d", root.Version, expectedRoot) + } + if report.Roots == 0 { + report.LegacyFirstVersion = root.Version + } + report.LegacyLastVersion = root.Version + report.Roots++ + expectedRoot++ + if len(root.Hash) == 0 { + return nil + } + body, err := encodeLegacyCoverage(legacyCoverageRecord{ + Hash: root.Hash, Kind: legacyCoverageRoot, FromVersion: root.Version, ToVersion: root.Version, + }) + if err != nil { + return err + } + report.CoverageRecords++ + return coverageSorter.Feed(body) + }) + if err != nil { + return report, fmt.Errorf("inspect legacy roots: %w", err) + } + if report.Roots == 0 { + return report, fmt.Errorf("legacy root set is empty") + } + if options.LastVersion > report.LegacyLastVersion { + return report, fmt.Errorf( + "legacy scan ends at %d, beyond last legacy root %d", + options.LastVersion, report.LegacyLastVersion, + ) + } + err = source.InspectOrphans(func(orphan iavl.LegacyOrphanRecord) error { + if err := ctx.Err(); err != nil { + return err + } + report.Orphans++ + body, err := encodeLegacyOrphanRecord(orphan) + if err != nil { + return err + } + return orphanSorter.Feed(body) + }) + if err != nil { + return report, fmt.Errorf("inspect legacy orphans: %w", err) + } + orphanReader, err := orphanSorter.Finalize() + if err != nil { + return report, fmt.Errorf("finalize legacy orphan sort: %w", err) + } + if err := scanLegacyNodesWithOrphans( + ctx, source, orphanReader, report.LegacyLastVersion, + options.FirstVersion, options.LastVersion, deltaSorter, &report, + coverageSorter, + ); err != nil { + return report, err + } + if err := orphanReader.Close(); err != nil { + return report, err + } + coverageReader, err := coverageSorter.Finalize() + if err != nil { + return report, fmt.Errorf("finalize legacy reachability sort: %w", err) + } + report.GraphNodes, err = validateLegacyCoverage(ctx, source, coverageReader, report.Nodes) + if err != nil { + return report, err + } + if err := coverageReader.Close(); err != nil { + return report, err + } + + deltaReader, err := deltaSorter.Finalize() + if err != nil { + return report, fmt.Errorf("finalize legacy delta sort: %w", err) + } + if err := reduceLegacyDeltas(ctx, deltaReader, options.FirstVersion, options.LastVersion, emit); err != nil { + return report, fmt.Errorf("reduce legacy storage deltas: %w", err) + } + if err := deltaReader.Close(); err != nil { + return report, err + } + return report, nil +} + +func removeLegacyWorkDir(path string) error { + if path == "" || filepath.Base(path) == "." || filepath.Base(path) == string(filepath.Separator) { + return fmt.Errorf("refuse to remove invalid legacy work directory %q", path) + } + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("remove legacy work directory: %w", err) + } + return nil +} + +type legacyOrphanRecord struct { + Hash []byte + ToVersion int64 + FromVersion int64 +} + +func encodeLegacyOrphanRecord(orphan iavl.LegacyOrphanRecord) ([]byte, error) { + if len(orphan.Hash) != legacyHashLength || orphan.FromVersion < 1 || orphan.ToVersion < orphan.FromVersion { + return nil, fmt.Errorf( + "invalid legacy orphan %d-%d hash length %d", + orphan.FromVersion, orphan.ToVersion, len(orphan.Hash), + ) + } + body := make([]byte, legacyOrphanRecordLength) + copy(body[:legacyHashLength], orphan.Hash) + binary.BigEndian.PutUint64(body[legacyHashLength:legacyHashLength+8], uint64(orphan.ToVersion)) + binary.BigEndian.PutUint64(body[legacyHashLength+8:], uint64(orphan.FromVersion)) + return body, nil +} + +func decodeLegacyOrphanRecord(body []byte) (legacyOrphanRecord, error) { + if len(body) != legacyOrphanRecordLength { + return legacyOrphanRecord{}, fmt.Errorf("legacy orphan record has length %d, want %d", len(body), legacyOrphanRecordLength) + } + record := legacyOrphanRecord{ + Hash: append([]byte(nil), body[:legacyHashLength]...), + ToVersion: int64(binary.BigEndian.Uint64(body[legacyHashLength : legacyHashLength+8])), + FromVersion: int64(binary.BigEndian.Uint64(body[legacyHashLength+8:])), + } + if record.FromVersion < 1 || record.ToVersion < record.FromVersion { + return legacyOrphanRecord{}, fmt.Errorf("invalid legacy orphan lifetime %d-%d", record.FromVersion, record.ToVersion) + } + return record, nil +} + +func legacyOrphanLess(left, right []byte) bool { return bytes.Compare(left, right) < 0 } + +func scanLegacyNodesWithOrphans( + ctx context.Context, + source legacyRecordSource, + orphans byteRecordReader, + legacyLast, firstVersion, lastVersion int64, + deltas *externalSorter, + report *legacyScanReport, + coverage *externalSorter, +) error { + orphanBody, err := nextOptionalRecord(orphans) + if err != nil { + return err + } + var previousOrphanHash []byte + advanceOrphan := func() error { + if orphanBody != nil { + orphan, decodeErr := decodeLegacyOrphanRecord(orphanBody) + if decodeErr != nil { + return decodeErr + } + if previousOrphanHash != nil && bytes.Equal(previousOrphanHash, orphan.Hash) { + return fmt.Errorf("legacy node hash %x has multiple orphan lifetimes", orphan.Hash) + } + previousOrphanHash = append(previousOrphanHash[:0], orphan.Hash...) + } + var readErr error + orphanBody, readErr = nextOptionalRecord(orphans) + if readErr == nil && orphanBody != nil { + next, decodeErr := decodeLegacyOrphanRecord(orphanBody) + if decodeErr != nil { + return decodeErr + } + if bytes.Equal(previousOrphanHash, next.Hash) { + return fmt.Errorf("legacy node hash %x has multiple orphan lifetimes", previousOrphanHash) + } + } + return readErr + } + + var previousNodeHash []byte + err = source.InspectNodes(func(node iavl.LegacyNodeRecord) error { + if err := ctx.Err(); err != nil { + return err + } + report.Nodes++ + if len(node.Hash) != legacyHashLength { + return fmt.Errorf("legacy node has hash length %d", len(node.Hash)) + } + if previousNodeHash != nil && bytes.Compare(previousNodeHash, node.Hash) >= 0 { + return fmt.Errorf("legacy node hashes are not strictly increasing at %x", node.Hash) + } + previousNodeHash = append(previousNodeHash[:0], node.Hash...) + if node.Version < 1 || node.Version > legacyLast { + return fmt.Errorf("legacy node %x has version %d outside 1-%d", node.Hash, node.Version, legacyLast) + } + lifetimeEnd := legacyLast + if orphanBody != nil { + orphan, decodeErr := decodeLegacyOrphanRecord(orphanBody) + if decodeErr != nil { + return decodeErr + } + switch bytes.Compare(orphan.Hash, node.Hash) { + case -1: + return fmt.Errorf("legacy orphan %x references a missing node", orphan.Hash) + case 0: + if orphan.FromVersion != node.Version { + return fmt.Errorf( + "legacy node %x was created at %d but orphan lifetime starts at %d", + node.Hash, node.Version, orphan.FromVersion, + ) + } + if orphan.ToVersion >= legacyLast { + return fmt.Errorf( + "legacy node %x orphan ends at %d, not before latest legacy root %d", + node.Hash, orphan.ToVersion, legacyLast, + ) + } + lifetimeEnd = orphan.ToVersion + if node.IsLeaf() && len(node.Key) > 0 && node.Key[0] == 0x02 { + if len(node.Key) != legacyStorageKeyLength { + return fmt.Errorf("legacy storage node %x has key length %d", node.Hash, len(node.Key)) + } + report.StorageOrphans++ + deleteVersion := orphan.ToVersion + 1 + if deleteVersion >= firstVersion && deleteVersion <= lastVersion { + delta, encodeErr := encodeLegacyDelta(legacyDelta{ + Version: deleteVersion, Key: node.Key, Kind: legacyDeltaOld, + }) + if encodeErr != nil { + return encodeErr + } + if feedErr := deltas.Feed(delta); feedErr != nil { + return feedErr + } + } + } + if err := advanceOrphan(); err != nil { + return err + } + } + } + self, encodeErr := encodeLegacyCoverage(legacyCoverageRecord{ + Hash: node.Hash, Kind: legacyCoverageSelf, FromVersion: node.Version, ToVersion: lifetimeEnd, + }) + if encodeErr != nil { + return encodeErr + } + if err := coverage.Feed(self); err != nil { + return err + } + report.CoverageRecords++ + + if !node.IsLeaf() { + report.Branches++ + children := []struct { + hash []byte + kind byte + }{{node.LeftHash, legacyCoverageLeft}, {node.RightHash, legacyCoverageRight}} + for _, child := range children { + record, err := encodeLegacyCoverage(legacyCoverageRecord{ + Hash: child.hash, Kind: child.kind, FromVersion: node.Version, ToVersion: lifetimeEnd, + }) + if err != nil { + return err + } + if err := coverage.Feed(record); err != nil { + return err + } + report.CoverageRecords++ + } + return nil + } + report.Leaves++ + if len(node.Key) == 0 || node.Key[0] != 0x02 { + return nil + } + if len(node.Key) != legacyStorageKeyLength { + return fmt.Errorf("legacy storage node %x has key length %d", node.Hash, len(node.Key)) + } + if len(node.Value) < 1 || len(node.Value) > 32 { + return fmt.Errorf("legacy storage node %x has value length %d", node.Hash, len(node.Value)) + } + report.StorageLeaves++ + if node.Version < firstVersion || node.Version > lastVersion { + return nil + } + delta, encodeErr := encodeLegacyDelta(legacyDelta{ + Version: node.Version, Key: node.Key, Kind: legacyDeltaNew, Value: node.Value, + }) + if encodeErr != nil { + return encodeErr + } + return deltas.Feed(delta) + }) + if err != nil { + return fmt.Errorf("inspect legacy nodes: %w", err) + } + if orphanBody != nil { + orphan, err := decodeLegacyOrphanRecord(orphanBody) + if err != nil { + return err + } + return fmt.Errorf("legacy orphan %x references a missing node", orphan.Hash) + } + return nil +} + +func nextOptionalRecord(reader byteRecordReader) ([]byte, error) { + body, err := reader.Next() + if errors.Is(err, io.EOF) { + return nil, nil + } + if err != nil { + return nil, err + } + if body == nil { + return nil, fmt.Errorf("record reader returned nil without EOF") + } + return body, nil +} diff --git a/cmd/statediff-rewriter/legacy_scan_test.go b/cmd/statediff-rewriter/legacy_scan_test.go new file mode 100644 index 00000000..ef632c8a --- /dev/null +++ b/cmd/statediff-rewriter/legacy_scan_test.go @@ -0,0 +1,357 @@ +package main + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "testing" + + "github.com/cosmos/iavl" + iavldb "github.com/cosmos/iavl/db" + "github.com/stretchr/testify/require" +) + +type fakeLegacyRecordSource struct { + roots []iavl.LegacyRootRecord + nodes []iavl.LegacyNodeRecord + orphans []iavl.LegacyOrphanRecord + rootsErr error + nodesErr error + orphansErr error +} + +func (source fakeLegacyRecordSource) InspectRoots(callback func(iavl.LegacyRootRecord) error) error { + for _, record := range source.roots { + if err := callback(record); err != nil { + return err + } + } + return source.rootsErr +} + +func (source fakeLegacyRecordSource) InspectNodes(callback func(iavl.LegacyNodeRecord) error) error { + for _, record := range source.nodes { + if err := callback(record); err != nil { + return err + } + } + return source.nodesErr +} + +func (source fakeLegacyRecordSource) InspectOrphans(callback func(iavl.LegacyOrphanRecord) error) error { + for _, record := range source.orphans { + if err := callback(record); err != nil { + return err + } + } + return source.orphansErr +} + +func legacyRoots(last int64) []iavl.LegacyRootRecord { + records := make([]iavl.LegacyRootRecord, 0, last) + for version := int64(1); version <= last; version++ { + records = append(records, iavl.LegacyRootRecord{Version: version}) + } + return records +} + +func legacyRootsWithHash(last int64, hash []byte) []iavl.LegacyRootRecord { + records := legacyRoots(last) + for index := range records { + records[index].Hash = append([]byte(nil), hash...) + } + return records +} + +func legacyHash(marker byte) []byte { return bytes.Repeat([]byte{marker}, legacyHashLength) } + +func legacyStorageNode(hashMarker byte, version int64, keyMarker byte, value ...byte) iavl.LegacyNodeRecord { + return iavl.LegacyNodeRecord{ + Hash: legacyHash(hashMarker), Version: version, Height: 0, Size: 1, + Key: legacyStorageKey(keyMarker), Value: value, + } +} + +func testLegacyScanOptions(t *testing.T, first, last int64) legacyScanOptions { + t.Helper() + return legacyScanOptions{ + TempDir: t.TempDir(), FirstVersion: first, LastVersion: last, + SortChunkSize: 512, MaxSortChunks: 32, + } +} + +func TestLegacyLatestRootVersion(t *testing.T) { + db := iavldb.NewMemDB() + version, err := legacyLatestRootVersion(db) + require.NoError(t, err) + require.Zero(t, version) + + for _, current := range []int64{1, 10, 3} { + key := make([]byte, 9) + key[0] = 'r' + binary.BigEndian.PutUint64(key[1:], uint64(current)) + require.NoError(t, db.Set(key, bytes.Repeat([]byte{byte(current)}, legacyHashLength))) + } + version, err = legacyLatestRootVersion(db) + require.NoError(t, err) + require.Equal(t, int64(10), version) +} + +func TestScanLegacyChangeSetsReconstructsStorageUpdates(t *testing.T) { + keyA, keyC := legacyStorageKey(1), legacyStorageKey(3) + roots := legacyRoots(5) + roots[1].Hash = legacyHash(1) + roots[2].Hash = legacyHash(2) + roots[4].Hash = legacyHash(3) + source := fakeLegacyRecordSource{ + roots: roots, + nodes: []iavl.LegacyNodeRecord{ + legacyStorageNode(1, 2, 1, 1), + legacyStorageNode(2, 3, 1, 2), + legacyStorageNode(3, 5, 3, 4), + }, + // Raw legacy orphan order is by to-version, not node hash. The scanner + // must external-sort these before joining them to hash-ordered nodes. + orphans: []iavl.LegacyOrphanRecord{ + {Hash: legacyHash(1), FromVersion: 2, ToVersion: 2}, + {Hash: legacyHash(2), FromVersion: 3, ToVersion: 3}, + }, + } + + changes := make(map[int64]*iavl.ChangeSet) + report, err := scanLegacyChangeSets( + context.Background(), source, testLegacyScanOptions(t, 1, 5), + func(version int64, changeSet *iavl.ChangeSet) error { + changes[version] = changeSet + return nil + }, + ) + require.NoError(t, err) + require.Equal(t, legacyScanReport{ + LegacyFirstVersion: 1, LegacyLastVersion: 5, Roots: 5, + Nodes: 3, Leaves: 3, StorageLeaves: 3, Orphans: 2, StorageOrphans: 2, + GraphNodes: 3, CoverageRecords: 6, + }, report) + require.Empty(t, changes[1].Pairs) + require.Equal(t, []*iavl.KVPair{{Key: keyA, Value: []byte{1}}}, changes[2].Pairs) + require.Equal(t, []*iavl.KVPair{{Key: keyA, Value: []byte{2}}}, changes[3].Pairs) + require.Equal(t, []*iavl.KVPair{{Delete: true, Key: keyA}}, changes[4].Pairs) + require.Equal(t, []*iavl.KVPair{{Key: keyC, Value: []byte{4}}}, changes[5].Pairs) +} + +func TestScanLegacyChangeSetsSupportsSubrangeAndEmptySorts(t *testing.T) { + source := fakeLegacyRecordSource{ + roots: legacyRootsWithHash(4, legacyHash(1)), + nodes: []iavl.LegacyNodeRecord{ + {Hash: legacyHash(1), Version: 1, Height: 0, Size: 1, Key: []byte{0x01}, Value: []byte{1}}, + }, + } + var versions []int64 + _, err := scanLegacyChangeSets( + context.Background(), source, testLegacyScanOptions(t, 2, 4), + func(version int64, changeSet *iavl.ChangeSet) error { + versions = append(versions, version) + require.Empty(t, changeSet.Pairs) + return nil + }, + ) + require.NoError(t, err) + require.Equal(t, []int64{2, 3, 4}, versions) +} + +func TestScanLegacyChangeSetsFailsClosed(t *testing.T) { + validNode := legacyStorageNode(2, 2, 1, 1) + tests := []struct { + name string + source fakeLegacyRecordSource + want string + }{ + { + name: "root missing node", + source: func() fakeLegacyRecordSource { + roots := legacyRoots(2) + roots[1].Hash = legacyHash(2) + return fakeLegacyRecordSource{roots: roots} + }(), + want: "reachability references missing node", + }, + { + name: "root gap", + source: fakeLegacyRecordSource{roots: []iavl.LegacyRootRecord{{Version: 1}, {Version: 3}}}, + want: "root version 3, want 2", + }, + { + name: "node order", + source: fakeLegacyRecordSource{roots: legacyRoots(2), nodes: []iavl.LegacyNodeRecord{ + legacyStorageNode(3, 2, 1, 1), validNode, + }}, + want: "not strictly increasing", + }, + { + name: "bad storage value", + source: fakeLegacyRecordSource{roots: legacyRoots(2), nodes: []iavl.LegacyNodeRecord{ + legacyStorageNode(2, 2, 1), + }}, + want: "value length 0", + }, + { + name: "orphan missing node", + source: fakeLegacyRecordSource{ + roots: legacyRoots(2), + orphans: []iavl.LegacyOrphanRecord{{Hash: legacyHash(2), FromVersion: 1, ToVersion: 1}}, + }, + want: "references a missing node", + }, + { + name: "orphan creation mismatch", + source: fakeLegacyRecordSource{ + roots: legacyRoots(3), nodes: []iavl.LegacyNodeRecord{validNode}, + orphans: []iavl.LegacyOrphanRecord{{Hash: legacyHash(2), FromVersion: 1, ToVersion: 2}}, + }, + want: "created at 2 but orphan lifetime starts at 1", + }, + { + name: "orphan reaches latest root", + source: fakeLegacyRecordSource{ + roots: legacyRoots(2), nodes: []iavl.LegacyNodeRecord{validNode}, + orphans: []iavl.LegacyOrphanRecord{{Hash: legacyHash(2), FromVersion: 2, ToVersion: 2}}, + }, + want: "not before latest legacy root", + }, + { + name: "multiple orphan lifetimes", + source: fakeLegacyRecordSource{ + roots: legacyRoots(3), nodes: []iavl.LegacyNodeRecord{validNode}, + orphans: []iavl.LegacyOrphanRecord{ + {Hash: legacyHash(2), FromVersion: 2, ToVersion: 2}, + {Hash: legacyHash(2), FromVersion: 2, ToVersion: 3}, + }, + }, + want: "multiple orphan lifetimes", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := scanLegacyChangeSets( + context.Background(), test.source, testLegacyScanOptions(t, 1, 2), + func(int64, *iavl.ChangeSet) error { return nil }, + ) + require.ErrorContains(t, err, test.want) + }) + } +} + +func TestScanLegacyChangeSetsValidatesReachableBranch(t *testing.T) { + left := iavl.LegacyNodeRecord{ + Hash: legacyHash(1), Version: 1, Height: 0, Size: 1, Key: []byte{0x01}, Value: []byte{1}, + } + right := iavl.LegacyNodeRecord{ + Hash: legacyHash(2), Version: 1, Height: 0, Size: 1, Key: []byte{0x03}, Value: []byte{2}, + } + branch := iavl.LegacyNodeRecord{ + Hash: legacyHash(3), Version: 1, Height: 1, Size: 2, Key: []byte{0x03}, + LeftHash: left.Hash, RightHash: right.Hash, + } + report, err := scanLegacyChangeSets( + context.Background(), + fakeLegacyRecordSource{roots: legacyRootsWithHash(1, branch.Hash), nodes: []iavl.LegacyNodeRecord{left, right, branch}}, + testLegacyScanOptions(t, 1, 1), func(int64, *iavl.ChangeSet) error { return nil }, + ) + require.NoError(t, err) + require.Equal(t, int64(1), report.Branches) + require.Equal(t, int64(3), report.GraphNodes) + require.Equal(t, int64(6), report.CoverageRecords) +} + +func TestScanLegacyChangeSetsRejectsDisconnectedAndIncompleteGraphs(t *testing.T) { + leaf := iavl.LegacyNodeRecord{ + Hash: legacyHash(1), Version: 1, Height: 0, Size: 1, Key: []byte{0x01}, Value: []byte{1}, + } + branch := iavl.LegacyNodeRecord{ + Hash: legacyHash(3), Version: 1, Height: 1, Size: 2, Key: []byte{0x03}, + LeftHash: leaf.Hash, RightHash: legacyHash(2), + } + tests := []struct { + name string + source fakeLegacyRecordSource + want string + }{ + { + name: "disconnected leaf", + source: fakeLegacyRecordSource{roots: legacyRoots(1), nodes: []iavl.LegacyNodeRecord{leaf}}, + want: "reachability ends at 0, want 1", + }, + { + name: "missing branch child", + source: fakeLegacyRecordSource{ + roots: legacyRootsWithHash(1, branch.Hash), nodes: []iavl.LegacyNodeRecord{leaf, branch}, + }, + want: "reachability references missing node", + }, + { + name: "lifetime gap", + source: fakeLegacyRecordSource{ + roots: []iavl.LegacyRootRecord{{Version: 1, Hash: leaf.Hash}, {Version: 2}}, + nodes: []iavl.LegacyNodeRecord{leaf}, + }, + want: "reachability ends at 1, want 2", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := scanLegacyChangeSets( + context.Background(), test.source, testLegacyScanOptions(t, 1, int64(len(test.source.roots))), + func(int64, *iavl.ChangeSet) error { return nil }, + ) + require.ErrorContains(t, err, test.want) + }) + } +} + +func TestScanLegacyChangeSetsRejectsOverlappingParentReferences(t *testing.T) { + left := iavl.LegacyNodeRecord{ + Hash: legacyHash(1), Version: 1, Height: 0, Size: 1, Key: []byte{0x01}, Value: []byte{1}, + } + right := iavl.LegacyNodeRecord{ + Hash: legacyHash(2), Version: 1, Height: 0, Size: 1, Key: []byte{0x03}, Value: []byte{2}, + } + root := iavl.LegacyNodeRecord{ + Hash: legacyHash(3), Version: 1, Height: 1, Size: 2, Key: []byte{0x03}, + LeftHash: left.Hash, RightHash: right.Hash, + } + duplicateParent := root + duplicateParent.Hash = legacyHash(4) + _, err := scanLegacyChangeSets( + context.Background(), + fakeLegacyRecordSource{ + roots: legacyRootsWithHash(1, root.Hash), + nodes: []iavl.LegacyNodeRecord{left, right, root, duplicateParent}, + }, + testLegacyScanOptions(t, 1, 1), func(int64, *iavl.ChangeSet) error { return nil }, + ) + require.ErrorContains(t, err, "overlapping reachability") +} + +func TestScanLegacyChangeSetsReturnsSourceContextAndEmitErrors(t *testing.T) { + boom := errors.New("boom") + _, err := scanLegacyChangeSets( + context.Background(), fakeLegacyRecordSource{roots: legacyRoots(2), nodesErr: boom}, + testLegacyScanOptions(t, 1, 2), func(int64, *iavl.ChangeSet) error { return nil }, + ) + require.ErrorIs(t, err, boom) + + canceled, cancel := context.WithCancel(context.Background()) + cancel() + _, err = scanLegacyChangeSets( + canceled, fakeLegacyRecordSource{roots: legacyRoots(2)}, + testLegacyScanOptions(t, 1, 2), func(int64, *iavl.ChangeSet) error { return nil }, + ) + require.ErrorIs(t, err, context.Canceled) + + _, err = scanLegacyChangeSets( + context.Background(), fakeLegacyRecordSource{roots: legacyRoots(2)}, + testLegacyScanOptions(t, 1, 2), func(int64, *iavl.ChangeSet) error { return boom }, + ) + require.ErrorIs(t, err, boom) +} diff --git a/cmd/statediff-rewriter/main.go b/cmd/statediff-rewriter/main.go new file mode 100644 index 00000000..8671797d --- /dev/null +++ b/cmd/statediff-rewriter/main.go @@ -0,0 +1,194 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/spf13/cobra" + + sdkversion "github.com/cosmos/cosmos-sdk/version" +) + +func main() { + if !rocksDBBuild { + fmt.Fprintln(os.Stderr, "statediff-rewriter was not built with rocksdb and grocksdb_clean_link tags") + os.Exit(1) + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + if err := newRootCommand().ExecuteContext(ctx); err != nil { + cancel() + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + cancel() +} + +func newRootCommand() *cobra.Command { + root := &cobra.Command{ + Use: "statediff-rewriter", + Short: "Plan, verify, conditionally apply, or roll back canonical Cronos storage diffs", + Args: cobra.NoArgs, + Version: buildCommit(), + SilenceUsage: true, + PersistentPreRunE: func(*cobra.Command, []string) error { + if !rocksDBBuild { + return fmt.Errorf("this binary was not built with rocksdb and grocksdb_clean_link tags") + } + return nil + }, + } + root.SetVersionTemplate("{{.Version}}\n") + commands := []*cobra.Command{newDumpCommand(), newPlanCommand(), newApplyCommand(), newVerifyCommand(), newRollbackCommand()} + for _, command := range commands { + command.Args = cobra.NoArgs + } + root.AddCommand(commands...) + return root +} + +func buildCommit() string { + if sdkversion.Commit == "" { + return unknownBuildIdentity + } + return sdkversion.Commit +} + +func newPlanCommand() *cobra.Command { + options := planOptions{ + Bucket: defaultBucket, Prefix: defaultPrefix, Region: defaultRegion, + IAVLConcurrency: defaultDirectIAVLConcurrency, Parallel: defaultParallelOptions(), + } + command := &cobra.Command{ + Use: "plan", + Short: "Build a read-only changed-object plan from a sealed dump or frozen IAVL", + RunE: func(command *cobra.Command, _ []string) error { + firstSet := command.Flags().Changed("pilot-first-height") + finalSet := command.Flags().Changed("pilot-final-height") + if firstSet != finalSet { + return fmt.Errorf("pilot-first-height and pilot-final-height must be set together") + } + options.Pilot = firstSet + manifest, location, err := runPlan(command.Context(), options, nil) + if err != nil { + return err + } + manifestHash, _, err := hashFile(location) + if err != nil { + return err + } + return printJSON(struct { + Manifest planManifest `json:"manifest"` + Location string `json:"location"` + ManifestSHA256 string `json:"manifest_sha256"` + }{manifest, location, manifestHash}) + }, + } + command.Flags().StringVar(&options.DumpStaging, "dump", "", "changeset dump directory ending in .staging or .sealed") + command.Flags().BoolVar(&options.Direct, "direct", false, "read changesets directly from the frozen archive IAVL") + command.Flags().IntVar(&options.IAVLCacheSize, "iavl-cache-size", defaultDumpCacheSize, "IAVL node-cache entries per direct traversal worker") + command.Flags().IntVar(&options.IAVLConcurrency, "iavl-concurrency", options.IAVLConcurrency, "parallel IAVL shards for direct traversal") + command.Flags().StringVar(&options.ArchiveHome, "home", "", "frozen Cronos archive home") + command.Flags().StringVar(&options.Output, "output", "", "plan directory ending in .staging") + command.Flags().Uint64Var(&options.MinFree, "min-free-bytes", 0, "stop before filesystem free space falls below this value") + command.Flags().StringVar(&options.SnapshotID, "snapshot-id", "", "completed final-F EBS snapshot ID") + command.Flags().StringVar(&options.ImageDigest, "image-digest", "", "immutable rewriter image digest") + command.Flags().Int64Var(&options.PilotFirstHeight, "pilot-first-height", 0, "first height of a read-only pilot plan; requires pilot-final-height") + command.Flags().Int64Var(&options.PilotFinalHeight, "pilot-final-height", 0, "final height of a read-only pilot plan; requires pilot-first-height") + addParallelFlags(command, &options.Parallel, true, true) + _ = command.MarkFlagRequired("home") + _ = command.MarkFlagRequired("output") + _ = command.MarkFlagRequired("min-free-bytes") + _ = command.MarkFlagRequired("snapshot-id") + _ = command.MarkFlagRequired("image-digest") + return command +} + +func newApplyCommand() *cobra.Command { + var manifest, checkpoint, backup string + parallel := defaultParallelOptions() + command := &cobra.Command{ + Use: "apply", + Short: "Conditionally write the new bodies from a sealed plan", + RunE: func(command *cobra.Command, _ []string) error { + report, err := runWriteModeWithOptions(command.Context(), manifest, checkpoint, backup, applyMode, parallel, nil) + if err != nil { + return err + } + return printJSON(report) + }, + } + command.Flags().StringVar(&manifest, "manifest", "", "sealed manifest.v1.json") + command.Flags().StringVar(&checkpoint, "checkpoint", "", "apply checkpoint path") + command.Flags().StringVar(&backup, "backup-proof", "", "completed independent backup proof JSON") + addParallelFlags(command, ¶llel, true, false) + _ = command.MarkFlagRequired("manifest") + _ = command.MarkFlagRequired("checkpoint") + _ = command.MarkFlagRequired("backup-proof") + return command +} + +func newVerifyCommand() *cobra.Command { + var manifest, checkpointDir string + parallel := defaultParallelOptions() + command := &cobra.Command{ + Use: "verify", + Short: "GET and validate every height in a sealed plan", + RunE: func(command *cobra.Command, _ []string) error { + report, err := runVerifyWithOptions(command.Context(), manifest, checkpointDir, parallel, nil) + if err != nil { + return err + } + return printJSON(report) + }, + } + command.Flags().StringVar(&manifest, "manifest", "", "sealed manifest.v1.json") + command.Flags().StringVar(&checkpointDir, "checkpoint-dir", "", "verify checkpoint directory") + addParallelFlags(command, ¶llel, true, true) + _ = command.MarkFlagRequired("manifest") + _ = command.MarkFlagRequired("checkpoint-dir") + return command +} + +func newRollbackCommand() *cobra.Command { + var manifest, checkpoint string + parallel := defaultParallelOptions() + command := &cobra.Command{ + Use: "rollback", + Short: "Conditionally restore old bodies from a sealed plan", + RunE: func(command *cobra.Command, _ []string) error { + report, err := runWriteModeWithOptions(command.Context(), manifest, checkpoint, "", rollbackMode, parallel, nil) + if err != nil { + return err + } + return printJSON(report) + }, + } + command.Flags().StringVar(&manifest, "manifest", "", "sealed manifest.v1.json") + command.Flags().StringVar(&checkpoint, "checkpoint", "", "rollback checkpoint path") + addParallelFlags(command, ¶llel, true, false) + _ = command.MarkFlagRequired("manifest") + _ = command.MarkFlagRequired("checkpoint") + return command +} + +func addParallelFlags(command *cobra.Command, options *parallelOptions, withCheckpoint, withCheckpointInterval bool) { + command.Flags().IntVar(&options.Concurrency, "concurrency", options.Concurrency, "maximum concurrent object operations") + command.Flags().IntVar(&options.Window, "window", options.Window, "maximum emitted but not continuously committed operations") + command.Flags().Int64Var(&options.MaxInFlightBytes, "max-inflight-bytes", options.MaxInFlightBytes, "maximum estimated memory held by in-flight object operations") + if withCheckpoint { + command.Flags().Uint64Var(&options.CheckpointEvery, "checkpoint-every", options.CheckpointEvery, "persist a continuous checkpoint after this many operations") + } + if withCheckpointInterval { + command.Flags().DurationVar(&options.CheckpointInterval, "checkpoint-interval", options.CheckpointInterval, "maximum time between continuous checkpoint writes") + } +} + +func printJSON(value any) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} diff --git a/cmd/statediff-rewriter/main_test.go b/cmd/statediff-rewriter/main_test.go new file mode 100644 index 00000000..a6335e30 --- /dev/null +++ b/cmd/statediff-rewriter/main_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCommandsRejectPositionalArguments(t *testing.T) { + root := newRootCommand() + commands := append([]string{root.Name()}, "dump", "plan", "apply", "verify", "rollback") + for _, name := range commands { + command := root + if name != root.Name() { + found, _, err := root.Find([]string{name}) + require.NoError(t, err) + command = found + } + require.Error(t, command.Args(command, []string{"unexpected"}), name) + } +} + +func TestRootVersionOutput(t *testing.T) { + var output bytes.Buffer + root := newRootCommand() + root.SetOut(&output) + root.SetArgs([]string{"--version"}) + require.NoError(t, root.Execute()) + require.Equal(t, buildCommit()+"\n", output.String()) +} + +func TestCheckpointIntervalOnlyAppearsOnTimeBasedCheckpointCommands(t *testing.T) { + root := newRootCommand() + for _, name := range []string{"plan", "verify"} { + command, _, err := root.Find([]string{name}) + require.NoError(t, err) + require.NotNil(t, command.Flags().Lookup("checkpoint-interval"), name) + } + for _, name := range []string{"apply", "rollback"} { + command, _, err := root.Find([]string{name}) + require.NoError(t, err) + require.Nil(t, command.Flags().Lookup("checkpoint-interval"), name) + } +} + +func TestStopAfterLegacyOnlyAppearsOnDump(t *testing.T) { + root := newRootCommand() + dump, _, err := root.Find([]string{"dump"}) + require.NoError(t, err) + require.NotNil(t, dump.Flags().Lookup("stop-after-legacy")) + for _, name := range []string{"plan", "apply", "verify", "rollback"} { + command, _, findErr := root.Find([]string{name}) + require.NoError(t, findErr) + require.Nil(t, command.Flags().Lookup("stop-after-legacy"), name) + } +} diff --git a/cmd/statediff-rewriter/model.go b/cmd/statediff-rewriter/model.go new file mode 100644 index 00000000..973ab125 --- /dev/null +++ b/cmd/statediff-rewriter/model.go @@ -0,0 +1,293 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rlp" + dtypes "github.com/evmos/ethermint/debank/types" +) + +const ( + manifestSchema = "statediff-rewriter-manifest/v1" + pilotManifestSchema = "statediff-rewriter-pilot-manifest/v1" + planSourceDumpV1 = "dump-v1" + planSourceDirectV1 = "direct-iavl-v1" + packSchema = uint64(1) + defaultBucket = "chaintable-nodex-pipeline--apne1-az4--x-s3" + defaultPrefix = "25/4cc51d2e" + defaultRegion = "ap-northeast-1" + maxObjectSize = int64(64 << 20) + chunkSize = int64(512 << 20) +) + +type dumpFileManifest struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + FirstVersion int64 `json:"first_version"` + LastVersion int64 `json:"last_version"` + Records int64 `json:"records"` +} + +type dumpManifest struct { + Schema string `json:"schema"` + CreatedAt string `json:"created_at"` + SnapshotID string `json:"snapshot_id,omitempty"` + ArchiveIdentity archiveIdentity `json:"archive_identity,omitempty"` + CronosCommit string `json:"cronos_commit,omitempty"` + EthermintCommit string `json:"ethermint_commit,omitempty"` + IAVLCommit string `json:"iavl_commit,omitempty"` + ImageDigest string `json:"image_digest,omitempty"` + BuildTags string `json:"build_tags,omitempty"` + SourceManifestSHA256 string `json:"source_manifest_sha256,omitempty"` + FirstVersion int64 `json:"first_version"` + LastVersion int64 `json:"last_version"` + Records int64 `json:"records"` + Files []dumpFileManifest `json:"files"` +} + +type chunkManifest struct { + Number int `json:"number"` + Pack string `json:"pack"` + PackSHA256 string `json:"pack_sha256"` + PackSize int64 `json:"pack_size"` + Index string `json:"index"` + IndexSHA256 string `json:"index_sha256"` + Records int64 `json:"records"` +} + +type planManifest struct { + Schema string `json:"schema"` + Sealed bool `json:"sealed"` + RunID string `json:"run_id"` + CreatedAt string `json:"created_at"` + Bucket string `json:"bucket"` + Prefix string `json:"prefix"` + Region string `json:"region"` + FirstHeight int64 `json:"first_height"` + FinalHeight int64 `json:"final_height"` + CronosCommit string `json:"cronos_commit"` + EthermintCommit string `json:"ethermint_commit"` + IAVLCommit string `json:"iavl_commit"` + SnapshotID string `json:"snapshot_id"` + ImageDigest string `json:"image_digest"` + BuildTags string `json:"build_tags"` + SourceMode string `json:"source_mode,omitempty"` + DumpPath string `json:"dump_path,omitempty"` + DumpManifestHash string `json:"dump_manifest_sha256,omitempty"` + ArchiveIdentity archiveIdentity `json:"archive_identity"` + Processed int64 `json:"processed"` + Changed int64 `json:"changed"` + ChangedCanonical int64 `json:"changed_noncanonical"` + ChangedConflict int64 `json:"changed_conflicting_old_values"` + Unchanged int64 `json:"unchanged"` + SkippedEqualRoot int64 `json:"skipped_equal_root"` + SlotsAdded uint64 `json:"slots_added"` + SlotsRemoved uint64 `json:"slots_removed"` + SlotsChanged uint64 `json:"slots_changed"` + OldBytes int64 `json:"old_bytes"` + NewBytes int64 `json:"new_bytes"` + HeightRootIndex string `json:"height_root_index"` + HeightRootIndexSHA256 string `json:"height_root_index_sha256"` + RootIndex string `json:"root_index"` + RootIndexSHA256 string `json:"root_index_sha256"` + RootMultisetSHA256 string `json:"root_multiset_sha256"` + Chunks []chunkManifest `json:"chunks"` +} + +func effectivePlanSourceMode(manifest planManifest) string { + if manifest.SourceMode == "" { + return planSourceDumpV1 + } + return manifest.SourceMode +} + +type archiveIdentity struct { + Home string `json:"home"` + DatabaseIdentity string `json:"database_identity"` + LatestVersion int64 `json:"latest_version"` + FinalCommitHash string `json:"final_commit_hash"` +} + +type objectHeaders struct { + Metadata []byte + CacheControl string + ContentDisposition string + ContentEncoding string + ContentLanguage string + ContentType string + WebsiteRedirect string + StorageClass string + ServerSideEncryption string +} + +type packRecord struct { + Schema uint64 + Ordinal uint64 + Height uint64 + Key string + OldETag string + OldBody []byte + NewBody []byte + OldSHA256 common.Hash + NewSHA256 common.Hash + Headers objectHeaders + RetainedSHA256 common.Hash + SlotsAdded uint64 + SlotsRemoved uint64 + SlotsChanged uint64 + NoncanonicalOld bool + ConflictingOld bool +} + +type retainedFields struct { + Hash common.Hash + ParentHash common.Hash + NewAccounts []dtypes.NewAccount + DeletedAccounts []common.Hash + NewCodes []dtypes.NewCode +} + +type storageKey struct { + Address common.Hash + Index common.Hash +} + +type normalizedStorage struct { + Values map[storageKey][32]byte + Noncanonical bool + Conflict bool +} + +type slotStats struct { + Added uint64 + Removed uint64 + Changed uint64 +} + +func normalizeStorage(storage []dtypes.AccountStorageDiff) (normalizedStorage, error) { + result := normalizedStorage{Values: make(map[storageKey][32]byte)} + addresses := make(map[common.Hash]struct{}) + var previousAddress common.Hash + for accountIndex, account := range storage { + if accountIndex > 0 && bytes.Compare(previousAddress[:], account.Address[:]) >= 0 { + result.Noncanonical = true + } + previousAddress = account.Address + if len(account.Values) == 0 { + result.Noncanonical = true + } + if _, ok := addresses[account.Address]; ok { + result.Noncanonical = true + } + addresses[account.Address] = struct{}{} + var previousIndex common.Hash + for valueIndex, pair := range account.Values { + if valueIndex > 0 && bytes.Compare(previousIndex[:], pair.Index[:]) >= 0 { + result.Noncanonical = true + } + previousIndex = pair.Index + if pair.Value == nil { + return result, fmt.Errorf("storage account %d value %d is nil", accountIndex, valueIndex) + } + key := storageKey{Address: account.Address, Index: pair.Index} + var value [32]byte + pair.Value.WriteToSlice(value[:]) + if previous, ok := result.Values[key]; ok { + result.Noncanonical = true + if previous != value { + result.Conflict = true + } + continue + } + result.Values[key] = value + } + } + return result, nil +} + +func compareStorage(oldStorage, canonical []dtypes.AccountStorageDiff) (bool, bool, bool, slotStats, error) { + oldValues, err := normalizeStorage(oldStorage) + if err != nil { + return false, false, false, slotStats{}, err + } + newValues, err := normalizeStorage(canonical) + if err != nil { + return false, false, false, slotStats{}, err + } + if newValues.Noncanonical { + return false, false, false, slotStats{}, fmt.Errorf("canonical storage is noncanonical") + } + var stats slotStats + for key, newValue := range newValues.Values { + oldValue, ok := oldValues.Values[key] + switch { + case !ok: + stats.Added++ + case oldValue != newValue: + stats.Changed++ + } + } + for key := range oldValues.Values { + if _, ok := newValues.Values[key]; !ok { + stats.Removed++ + } + } + equal := !oldValues.Noncanonical && stats == (slotStats{}) + return equal, oldValues.Noncanonical, oldValues.Conflict, stats, nil +} + +func retainedDigest(diff dtypes.BlockStorageDiff) (common.Hash, error) { + body, err := rlp.EncodeToBytes(retainedFields{ + Hash: diff.Hash, ParentHash: diff.ParentHash, NewAccounts: diff.NewAccounts, + DeletedAccounts: diff.DeletedAccounts, NewCodes: diff.NewCodes, + }) + if err != nil { + return common.Hash{}, err + } + return sha256Hash(body), nil +} + +func sha256Hash(body []byte) common.Hash { + sum := sha256.Sum256(body) + return common.BytesToHash(sum[:]) +} + +func sha256Hex(body []byte) string { + sum := sha256.Sum256(body) + return hex.EncodeToString(sum[:]) +} + +func canonicalMetadata(metadata map[string]string) ([]byte, error) { + keys := make([]string, 0, len(metadata)) + for key := range metadata { + keys = append(keys, key) + } + sort.Strings(keys) + ordered := make([][2]string, 0, len(keys)) + for _, key := range keys { + ordered = append(ordered, [2]string{key, metadata[key]}) + } + return json.Marshal(ordered) +} + +func decodeMetadata(body []byte) (map[string]string, error) { + var ordered [][2]string + if err := json.Unmarshal(body, &ordered); err != nil { + return nil, err + } + result := make(map[string]string, len(ordered)) + for _, item := range ordered { + if _, ok := result[item[0]]; ok { + return nil, fmt.Errorf("duplicate metadata key %q", item[0]) + } + result[item[0]] = item[1] + } + return result, nil +} diff --git a/cmd/statediff-rewriter/model_test.go b/cmd/statediff-rewriter/model_test.go new file mode 100644 index 00000000..440f8226 --- /dev/null +++ b/cmd/statediff-rewriter/model_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + dtypes "github.com/evmos/ethermint/debank/types" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" +) + +func testStorage(address, index byte, value uint64) dtypes.AccountStorageDiff { + return dtypes.AccountStorageDiff{ + Address: common.BytesToHash([]byte{address}), + Values: []dtypes.IndexValuePair{{Index: common.BytesToHash([]byte{index}), Value: uint256.NewInt(value)}}, + } +} + +func TestCompareStorage(t *testing.T) { + canonical := []dtypes.AccountStorageDiff{testStorage(1, 1, 2)} + equal, noncanonical, conflict, stats, err := compareStorage(canonical, canonical) + require.NoError(t, err) + require.True(t, equal) + require.False(t, noncanonical) + require.False(t, conflict) + + reversedAccounts := []dtypes.AccountStorageDiff{testStorage(2, 1, 2), testStorage(1, 1, 2)} + canonicalAccounts := []dtypes.AccountStorageDiff{testStorage(1, 1, 2), testStorage(2, 1, 2)} + equal, noncanonical, conflict, _, err = compareStorage(reversedAccounts, canonicalAccounts) + require.NoError(t, err) + require.False(t, equal) + require.True(t, noncanonical) + require.False(t, conflict) + + reversedSlots := testStorage(1, 2, 2) + reversedSlots.Values = append(reversedSlots.Values, testStorage(1, 1, 2).Values[0]) + canonicalSlots := testStorage(1, 1, 2) + canonicalSlots.Values = append(canonicalSlots.Values, testStorage(1, 2, 2).Values[0]) + equal, noncanonical, conflict, _, err = compareStorage( + []dtypes.AccountStorageDiff{reversedSlots}, []dtypes.AccountStorageDiff{canonicalSlots}, + ) + require.NoError(t, err) + require.False(t, equal) + require.True(t, noncanonical) + require.False(t, conflict) + require.Equal(t, slotStats{}, stats) + + duplicate := []dtypes.AccountStorageDiff{testStorage(1, 1, 2), testStorage(1, 1, 2)} + equal, noncanonical, conflict, _, err = compareStorage(duplicate, canonical) + require.NoError(t, err) + require.False(t, equal) + require.True(t, noncanonical) + require.False(t, conflict) + + duplicate[1].Values[0].Value = uint256.NewInt(3) + equal, noncanonical, conflict, _, err = compareStorage(duplicate, canonical) + require.NoError(t, err) + require.False(t, equal) + require.True(t, noncanonical) + require.True(t, conflict) + + conflictingStorage := []dtypes.AccountStorageDiff{testStorage(1, 1, 2), testStorage(1, 1, 3)} + normalized, err := normalizeStorage(conflictingStorage) + require.NoError(t, err) + require.True(t, normalized.Noncanonical) + require.True(t, normalized.Conflict) + + empty := []dtypes.AccountStorageDiff{{Address: common.BytesToHash([]byte{1})}} + equal, noncanonical, conflict, _, err = compareStorage(empty, nil) + require.NoError(t, err) + require.False(t, equal) + require.True(t, noncanonical) + require.False(t, conflict) +} diff --git a/cmd/statediff-rewriter/operations.go b/cmd/statediff-rewriter/operations.go new file mode 100644 index 00000000..5a56684d --- /dev/null +++ b/cmd/statediff-rewriter/operations.go @@ -0,0 +1,1384 @@ +package main + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "time" + + "github.com/cosmos/iavl" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rlp" + "github.com/evmos/ethermint/debank/statediff" + dtypes "github.com/evmos/ethermint/debank/types" + "golang.org/x/sync/errgroup" +) + +const ( + backupProofSchema = "statediff-rewriter-backup-proof/v1" + applyMode = "apply" + rollbackMode = "rollback" + verifyMode = "verify" +) + +type backupProof struct { + Schema string `json:"schema"` + ManifestSHA256 string `json:"manifest_sha256"` + Kind string `json:"kind"` + SnapshotID string `json:"snapshot_id"` + Location string `json:"location"` + Status string `json:"status"` + Independent bool `json:"independent_restore"` +} + +type writeReport struct { + PlannedChanged int64 `json:"planned_changed"` + CheckpointFrontier int64 `json:"checkpoint_frontier"` + CompletedThisRun int64 `json:"completed_this_run"` + AlreadyTarget int64 `json:"already_target"` + PUTs int64 `json:"put_attempts"` + ConfirmedPUTs int64 `json:"confirmed_puts"` + UncertainPUTs int64 `json:"uncertain_puts_reconciled"` + Conflicts int64 `json:"conditional_conflicts_observed_this_run"` + CumulativePUTs uint64 `json:"cumulative_put_attempts"` + CumulativeAlready uint64 `json:"cumulative_already_target"` + CumulativeUncertain uint64 `json:"cumulative_uncertain_puts_reconciled"` + CumulativeConflicts uint64 `json:"cumulative_conditional_conflicts_observed"` + Concurrency int `json:"concurrency"` + DurationSeconds float64 `json:"duration_seconds"` + ObjectsPerSecond float64 `json:"objects_per_second"` +} + +type preparedWrite struct { + record packRecord + needsPUT bool + ifMatch string + observedETag string +} + +type writeOutcome struct { + ordinal uint64 + postETag string + putOutcome string + putAttempted bool + conflict bool +} + +type writeAudit struct { + PUTAttempts uint64 + ConfirmedPUTs uint64 + UncertainPUTs uint64 + AlreadyTarget uint64 + Conflicts uint64 +} + +type writeMetrics struct { + completed atomic.Int64 + alreadyTarget atomic.Int64 + putAttempts atomic.Int64 + confirmedPUTs atomic.Int64 + uncertainPUTs atomic.Int64 + conflicts atomic.Int64 +} + +func checkedAddUint64(left, right uint64, label string) (uint64, error) { + if left > ^uint64(0)-right { + return 0, fmt.Errorf("%s overflows uint64", label) + } + return left + right, nil +} + +func validateBackupProof(ctx context.Context, path, manifestPath, manifestHash, sourceSnapshotID string, activeFilesystem filesystemStatus) error { + if err := requireRegularFile(path, "backup proof"); err != nil { + return err + } + body, err := os.ReadFile(path) + if err != nil { + return err + } + var proof backupProof + if err := decodeStrictJSON(body, &proof, "backup proof"); err != nil { + return err + } + if proof.Schema != backupProofSchema || proof.ManifestSHA256 != manifestHash || proof.Kind != "ebs-snapshot-restore" || + proof.SnapshotID == "" || proof.SnapshotID == sourceSnapshotID || proof.Location == "" || + proof.Status != "completed" || !proof.Independent { + return fmt.Errorf("backup proof is incomplete or belongs to another manifest") + } + sourceDir, err := filepath.EvalSymlinks(filepath.Dir(manifestPath)) + if err != nil { + return fmt.Errorf("resolve source plan: %w", err) + } + backupInfo, err := os.Lstat(proof.Location) + if err != nil { + return fmt.Errorf("stat restored backup: %w", err) + } + if !backupInfo.IsDir() || backupInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("restored backup location must be a non-symlink directory") + } + backupDir, err := filepath.EvalSymlinks(proof.Location) + if err != nil { + return fmt.Errorf("resolve restored backup: %w", err) + } + if sourceDir == backupDir { + return fmt.Errorf("backup proof points to the active plan directory") + } + backupFilesystem, err := requireReadOnlyFilesystem(backupDir, "restored backup") + if err != nil { + return err + } + if backupFilesystem.Device == activeFilesystem.Device { + return fmt.Errorf("restored backup and active plan are on the same filesystem device") + } + if err := requireReadOnlyPlanManifest(backupDir, backupFilesystem); err != nil { + return err + } + backupManifest, backupHash, err := loadPlanManifestContext(ctx, filepath.Join(backupDir, filepath.Base(manifestPath))) + if err != nil { + return fmt.Errorf("validate restored backup: %w", err) + } + if err := requireReadOnlyPlanArtifacts(backupDir, backupManifest, backupFilesystem); err != nil { + return fmt.Errorf("validate restored backup artifacts: %w", err) + } + if backupHash != manifestHash { + return fmt.Errorf("restored backup manifest differs from active manifest") + } + return nil +} + +func runWriteMode(ctx context.Context, manifestPath, checkpointPath, backupPath, mode string, objects objectStore) (writeReport, error) { + options := defaultParallelOptions() + options.Concurrency, options.Window = 1, 1 + options.CheckpointEvery = 1 + return runWriteModeWithOptions(ctx, manifestPath, checkpointPath, backupPath, mode, options, objects) +} + +func runWriteModeWithOptions( + ctx context.Context, + manifestPath, checkpointPath, backupPath, mode string, + options parallelOptions, + objects objectStore, +) (writeReport, error) { + started := time.Now() + if mode != applyMode && mode != rollbackMode { + return writeReport{}, fmt.Errorf("unsupported write mode %q", mode) + } + if err := options.validate(true); err != nil { + return writeReport{}, err + } + planDir, err := requireSealedPlanDirectory(manifestPath) + if err != nil { + return writeReport{}, err + } + activeFilesystem, err := requireReadOnlyFilesystem(planDir, "active sealed plan") + if err != nil { + return writeReport{}, err + } + if err := requireReadOnlyPlanManifest(planDir, activeFilesystem); err != nil { + return writeReport{}, err + } + manifest, manifestHash, err := loadPlanManifestContext(ctx, manifestPath) + if err != nil { + return writeReport{}, err + } + if err := requireFullPlan(manifest, mode); err != nil { + return writeReport{}, err + } + if err := requireRuntimeBuildIdentity(manifest); err != nil { + return writeReport{}, err + } + if err := requireReadOnlyPlanArtifacts(planDir, manifest, activeFilesystem); err != nil { + return writeReport{}, err + } + if mode == applyMode { + if err := validateBackupProof(ctx, backupPath, manifestPath, manifestHash, manifest.SnapshotID, activeFilesystem); err != nil { + return writeReport{}, fmt.Errorf("apply requires completed independent backup proof: %w", err) + } + } + checkpointPath, err = canonicalFileInExistingDirectory(checkpointPath, mode+" checkpoint") + if err != nil { + return writeReport{}, err + } + locks, err := acquireStagingLocks(checkpointPath, filepath.Join(filepath.Dir(checkpointPath), "statediff-rewriter-write")) + if err != nil { + return writeReport{}, err + } + defer func() { _ = releaseStagingLocks(locks) }() + if err := syncDir(filepath.Dir(checkpointPath)); err != nil { + return writeReport{}, fmt.Errorf("sync %s checkpoint directory: %w", mode, err) + } + cp, err := loadCheckpoint(checkpointPath, manifest.RunID, manifestHash, mode) + if err != nil { + return writeReport{}, err + } + if cp.Frontier > uint64(manifest.Changed) || (cp.Frontier == 0) != (cp.Height == 0) { + return writeReport{}, fmt.Errorf("%s checkpoint frontier/height is outside the manifest", mode) + } + checkpointReport := writeReport{ + PlannedChanged: manifest.Changed, CheckpointFrontier: int64(cp.Frontier), Concurrency: options.Concurrency, + CumulativePUTs: cp.PUTAttempts, CumulativeAlready: cp.AlreadyTarget, CumulativeUncertain: cp.UncertainPUTs, + CumulativeConflicts: cp.Conflicts, + } + initialFrontier := cp.Frontier + journalPath := writeJournalPath(checkpointPath) + journal, journalFound, err := loadWriteJournal(journalPath, manifest.RunID, manifestHash, mode) + if err != nil { + return writeReport{}, err + } + if journalFound && (journal.BatchRecords > uint64(options.Window) || journal.BatchBytes > options.MaxInFlightBytes) { + return writeReport{}, fmt.Errorf( + "%s write journal batch needs window >= %d and max-inflight-bytes >= %d", + mode, journal.BatchRecords, journal.BatchBytes, + ) + } + if journalFound && journal.End < cp.Frontier { + return writeReport{}, fmt.Errorf("%s write journal end %d is behind checkpoint %d", mode, journal.End, cp.Frontier) + } + if journalFound && journal.End == cp.Frontier { + if journal.State != writeJournalObserved { + return writeReport{}, fmt.Errorf("%s checkpoint %d crosses unresolved issued journal %d-%d", mode, cp.Frontier, journal.Start, journal.End) + } + if err := removeWriteJournal(journalPath); err != nil { + return writeReport{}, err + } + journalFound = false + } + if manifest.Changed > 0 && cp.Frontier == uint64(manifest.Changed) { + return checkpointReport, fmt.Errorf("%s checkpoint is already complete", mode) + } + if err := validatePlanForWriteContext(ctx, filepath.Dir(manifestPath), manifest); err != nil { + return writeReport{}, fmt.Errorf("%s plan preflight: %w", mode, err) + } + report := checkpointReport + var metrics writeMetrics + stream := newPackStream(filepath.Dir(manifestPath), manifest) + defer stream.Close() + if cp.Frontier > 0 { + if err := advanceWriteCheckpointPrefix(mode, cp, stream, options); err != nil { + report.CompletedThisRun = metrics.completed.Load() + report.AlreadyTarget = metrics.alreadyTarget.Load() + report.DurationSeconds = time.Since(started).Seconds() + return report, err + } + } + + for { + var batch []packRecord + if journalFound { + if journal.Start != cp.Frontier+1 || journal.End > uint64(manifest.Changed) { + return writeReport{}, fmt.Errorf("%s write journal range %d-%d does not follow checkpoint %d", mode, journal.Start, journal.End, cp.Frontier) + } + batch, err = readWriteBatch(stream, options, journal.End) + } else { + batch, err = readWriteBatch(stream, options, 0) + } + if err != nil { + return writeReport{}, err + } + if len(batch) == 0 { + break + } + if journalFound && (batch[0].Ordinal != journal.Start || batch[len(batch)-1].Ordinal != journal.End) { + return writeReport{}, fmt.Errorf("%s write journal range does not match pack records", mode) + } + if journalFound { + if err := validateRecoveredBatch(journal, batch); err != nil { + return writeReport{}, err + } + } + if objects == nil { + objects, err = newS3ObjectStore(ctx, manifest.Region, options.Concurrency) + if err != nil { + return writeReport{}, err + } + } + audit, err := processWriteBatch(ctx, manifest, manifestHash, mode, journalPath, batch, journal, journalFound, options.Concurrency, objects, &metrics) + if err != nil { + report.CompletedThisRun = metrics.completed.Load() + report.AlreadyTarget = metrics.alreadyTarget.Load() + report.PUTs = metrics.putAttempts.Load() + report.ConfirmedPUTs = metrics.confirmedPUTs.Load() + report.UncertainPUTs = metrics.uncertainPUTs.Load() + report.Conflicts = metrics.conflicts.Load() + report.DurationSeconds = time.Since(started).Seconds() + return report, err + } + last := batch[len(batch)-1] + cp.Frontier, cp.Height = last.Ordinal, last.Height + if cp.PUTAttempts, err = checkedAddUint64(cp.PUTAttempts, audit.PUTAttempts, "checkpoint PUT attempts"); err != nil { + return report, err + } + if cp.ConfirmedPUTs, err = checkedAddUint64(cp.ConfirmedPUTs, audit.ConfirmedPUTs, "checkpoint confirmed PUTs"); err != nil { + return report, err + } + if cp.UncertainPUTs, err = checkedAddUint64(cp.UncertainPUTs, audit.UncertainPUTs, "checkpoint uncertain PUTs"); err != nil { + return report, err + } + if cp.AlreadyTarget, err = checkedAddUint64(cp.AlreadyTarget, audit.AlreadyTarget, "checkpoint already-target count"); err != nil { + return report, err + } + if cp.Conflicts, err = checkedAddUint64(cp.Conflicts, audit.Conflicts, "checkpoint conditional conflict count"); err != nil { + return report, err + } + if err := saveCheckpoint(checkpointPath, cp); err != nil { + return report, err + } + if err := removeWriteJournal(journalPath); err != nil { + return report, err + } + report.CheckpointFrontier = int64(cp.Frontier) + report.CumulativePUTs = cp.PUTAttempts + report.CumulativeAlready = cp.AlreadyTarget + report.CumulativeUncertain = cp.UncertainPUTs + report.CumulativeConflicts = cp.Conflicts + journal, journalFound = writeJournal{}, false + } + report.CompletedThisRun = metrics.completed.Load() + report.AlreadyTarget = metrics.alreadyTarget.Load() + report.PUTs = metrics.putAttempts.Load() + report.ConfirmedPUTs = metrics.confirmedPUTs.Load() + report.UncertainPUTs = metrics.uncertainPUTs.Load() + report.Conflicts = metrics.conflicts.Load() + report.DurationSeconds = time.Since(started).Seconds() + if report.DurationSeconds > 0 { + report.ObjectsPerSecond = float64(report.CompletedThisRun) / report.DurationSeconds + } + if report.CheckpointFrontier != manifest.Changed { + return report, fmt.Errorf("%s continuous checkpoint covers %d records, want %d", mode, report.CheckpointFrontier, manifest.Changed) + } + expectedThisRun := manifest.Changed - int64(initialFrontier) + if report.CompletedThisRun != expectedThisRun { + return report, fmt.Errorf("%s completed %d records this run, want %d", mode, report.CompletedThisRun, expectedThisRun) + } + return report, nil +} + +func advanceWriteCheckpointPrefix( + mode string, + cp checkpoint, + stream *packStream, + options parallelOptions, +) error { + var last packRecord + for last.Ordinal < cp.Frontier { + batch, err := readWritePrefixBatch(stream, options, cp.Frontier) + if err != nil { + return err + } + if len(batch) == 0 { + return fmt.Errorf("%s checkpoint ordinal %d was not found", mode, cp.Frontier) + } + last = batch[len(batch)-1] + } + if last.Ordinal != cp.Frontier { + return fmt.Errorf("%s checkpoint ordinal %d was not found", mode, cp.Frontier) + } + if last.Height != cp.Height { + return fmt.Errorf("%s checkpoint ordinal %d maps to height %d, not %d", mode, cp.Frontier, last.Height, cp.Height) + } + return nil +} + +func readWritePrefixBatch(stream *packStream, options parallelOptions, frontier uint64) ([]packRecord, error) { + maxRecords := uint64(options.Window) + var batch []packRecord + var bytes int64 + for uint64(len(batch)) < maxRecords { + record, ok, err := stream.Peek() + if err != nil { + return nil, err + } + if !ok || record.Ordinal > frontier { + break + } + weight := estimatedPackRecordBytes(record) + if weight > options.MaxInFlightBytes { + return nil, fmt.Errorf("pack record %d needs %d in-flight bytes, limit is %d", record.Ordinal, weight, options.MaxInFlightBytes) + } + if len(batch) > 0 && bytes > options.MaxInFlightBytes-weight { + break + } + record, _, err = stream.Next() + if err != nil { + return nil, err + } + batch = append(batch, record) + bytes += weight + if record.Ordinal == frontier { + break + } + } + return batch, nil +} + +func readWriteBatch(stream *packStream, options parallelOptions, forcedEnd uint64) ([]packRecord, error) { + maxRecords := uint64(options.Window) + if forcedEnd == 0 && options.CheckpointEvery < maxRecords { + maxRecords = options.CheckpointEvery + } + var batch []packRecord + var bytes int64 + for uint64(len(batch)) < maxRecords { + record, ok, err := stream.Peek() + if err != nil { + return nil, err + } + if !ok { + break + } + if forcedEnd != 0 && record.Ordinal > forcedEnd { + break + } + weight := estimatedPackRecordBytes(record) + if weight > options.MaxInFlightBytes { + return nil, fmt.Errorf("pack record %d needs %d in-flight bytes, limit is %d", record.Ordinal, weight, options.MaxInFlightBytes) + } + if len(batch) > 0 && bytes > options.MaxInFlightBytes-weight { + if forcedEnd != 0 { + return nil, fmt.Errorf("recovered write journal exceeds max-inflight-bytes %d", options.MaxInFlightBytes) + } + break + } + record, _, err = stream.Next() + if err != nil { + return nil, err + } + batch = append(batch, record) + bytes += weight + if forcedEnd != 0 && record.Ordinal == forcedEnd { + break + } + } + if forcedEnd != 0 && (len(batch) == 0 || batch[len(batch)-1].Ordinal != forcedEnd) { + return nil, fmt.Errorf("write journal end ordinal %d was not found in pack", forcedEnd) + } + return batch, nil +} + +func validateRecoveredBatch(journal writeJournal, records []packRecord) error { + if uint64(len(records)) != journal.BatchRecords { + return fmt.Errorf("recovered batch has %d records, journal has %d", len(records), journal.BatchRecords) + } + var bytes int64 + for _, record := range records { + weight := estimatedPackRecordBytes(record) + if weight <= 0 || weight > journal.BatchBytes-bytes { + return fmt.Errorf("recovered batch byte count is invalid") + } + bytes += weight + } + if bytes != journal.BatchBytes { + return fmt.Errorf("recovered batch has %d estimated bytes, journal has %d", bytes, journal.BatchBytes) + } + return nil +} + +func processWriteBatch( + ctx context.Context, + manifest planManifest, + manifestHash, mode, journalPath string, + records []packRecord, + existing writeJournal, + recovering bool, + concurrency int, + objects objectStore, + metrics *writeMetrics, +) (writeAudit, error) { + var prepared []preparedWrite + var err error + if mode == applyMode && !recovering { + prepared, err = prepareFreshApplyBatch(records) + } else { + prepared, err = prepareWriteBatch(ctx, manifest, records, mode, concurrency, objects) + } + if err != nil { + return writeAudit{}, err + } + journal := existing + if recovering { + if err := matchJournalToPrepared(journal, prepared, mode); err != nil { + return writeAudit{}, err + } + } else { + journal = newIssuedWriteJournal(manifest, manifestHash, mode, prepared) + if len(journal.Intents) > 0 { + if err := saveWriteJournal(journalPath, journal); err != nil { + return writeAudit{}, fmt.Errorf("persist %s write intents: %w", mode, err) + } + } + } + outcomes, writeErr := executeWriteBatch(ctx, manifest, prepared, mode, concurrency, objects, metrics) + if len(journal.Intents) == 0 { + return writeAudit{AlreadyTarget: uint64(len(prepared))}, writeErr + } + if err := updateJournalOutcomes(&journal, outcomes, mode); err != nil { + return writeAudit{}, err + } + if writeErr == nil && len(journal.Results) == len(journal.Intents) { + journal.State = writeJournalObserved + } else { + journal.State = writeJournalIssued + } + journalErr := saveWriteJournal(journalPath, journal) + if writeErr != nil || journalErr != nil { + return writeAudit{}, errors.Join(writeErr, journalErr) + } + if journal.State != writeJournalObserved { + return writeAudit{}, fmt.Errorf("%s batch %d-%d has unresolved write intents", mode, journal.Start, journal.End) + } + return journalAudit(journal) +} + +func prepareFreshApplyBatch(records []packRecord) ([]preparedWrite, error) { + prepared := make([]preparedWrite, len(records)) + for index, record := range records { + if record.OldETag == "" { + return nil, fmt.Errorf("apply height %d has no sealed old ETag", record.Height) + } + prepared[index] = preparedWrite{record: record, needsPUT: true, ifMatch: record.OldETag} + } + return prepared, nil +} + +func prepareWriteBatch( + ctx context.Context, + manifest planManifest, + records []packRecord, + mode string, + concurrency int, + objects objectStore, +) ([]preparedWrite, error) { + prepared := make([]preparedWrite, len(records)) + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(concurrency) + for index := range records { + group.Go(func() error { + item, err := prepareWriteRecord(groupCtx, manifest, records[index], mode, objects) + if err != nil { + return err + } + prepared[index] = item + return nil + }) + } + if err := group.Wait(); err != nil { + return nil, err + } + return prepared, nil +} + +func prepareWriteRecord( + ctx context.Context, + manifest planManifest, + record packRecord, + mode string, + objects objectStore, +) (preparedWrite, error) { + sourceSHA, targetSHA := record.OldSHA256, record.NewSHA256 + if mode == rollbackMode { + sourceSHA, targetSHA = targetSHA, sourceSHA + } + current, err := objects.Get(ctx, manifest.Bucket, record.Key) + if err != nil { + return preparedWrite{}, fmt.Errorf("%s get height %d: %w", mode, record.Height, err) + } + switch sha256Hash(current.Body) { + case targetSHA: + if err := verifyStoredRecordTarget(record, current, targetSHA); err != nil { + return preparedWrite{}, fmt.Errorf("%s verify existing target height %d: %w", mode, record.Height, err) + } + return preparedWrite{record: record, observedETag: current.ETag}, nil + case sourceSHA: + if !reflect.DeepEqual(current.Headers, record.Headers) { + return preparedWrite{}, fmt.Errorf("%s height %d source object metadata changed", mode, record.Height) + } + if mode == applyMode && current.ETag != record.OldETag { + return preparedWrite{}, fmt.Errorf("apply height %d old bytes have changed ETag", record.Height) + } + default: + return preparedWrite{}, fmt.Errorf("%s height %d has third-party content", mode, record.Height) + } + return preparedWrite{record: record, needsPUT: true, ifMatch: current.ETag}, nil +} + +func executeWriteBatch( + ctx context.Context, + manifest planManifest, + prepared []preparedWrite, + mode string, + concurrency int, + objects objectStore, + metrics *writeMetrics, +) ([]writeOutcome, error) { + outcomes := make([]writeOutcome, len(prepared)) + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(concurrency) + for index := range prepared { + if !prepared[index].needsPUT { + outcomes[index] = writeOutcome{ + ordinal: prepared[index].record.Ordinal, + postETag: prepared[index].observedETag, putOutcome: writeOutcomeReconciled, + } + metrics.alreadyTarget.Add(1) + metrics.completed.Add(1) + continue + } + group.Go(func() error { + outcome, err := executePreparedWrite(groupCtx, manifest, prepared[index], mode, objects, metrics) + outcomes[index] = outcome + if err != nil { + return err + } + return nil + }) + } + return outcomes, group.Wait() +} + +func executePreparedWrite( + ctx context.Context, + manifest planManifest, + prepared preparedWrite, + mode string, + objects objectStore, + metrics *writeMetrics, +) (writeOutcome, error) { + record := prepared.record + targetBody, targetSHA := record.NewBody, record.NewSHA256 + if mode == rollbackMode { + targetBody, targetSHA = record.OldBody, record.OldSHA256 + } + + metrics.putAttempts.Add(1) + putResult, putErr := objects.Put(ctx, manifest.Bucket, record.Key, targetBody, prepared.ifMatch, record.Headers) + if mode == applyMode { + return resolveApplyPut(ctx, manifest, prepared, putResult, putErr, objects, metrics) + } + if errors.Is(putErr, errObjectConflict) { + metrics.conflicts.Add(1) + return writeOutcome{ordinal: record.Ordinal, putAttempted: true, conflict: true}, fmt.Errorf("%s PUT height %d has a conditional conflict: %w", mode, record.Height, putErr) + } + if putErr == nil { + metrics.confirmedPUTs.Add(1) + } + observed, getErr := objects.Get(ctx, manifest.Bucket, record.Key) + if getErr != nil { + outcome := "" + if putErr == nil { + outcome = writeOutcomeConfirmed + } + return writeOutcome{ordinal: record.Ordinal, putAttempted: true, putOutcome: outcome}, errors.Join( + fmt.Errorf("%s verify GET height %d: %w", mode, record.Height, getErr), + putErr, + ) + } + if err := verifyStoredRecordTarget(record, observed, targetSHA); err != nil { + outcome := "" + if putErr == nil { + outcome = writeOutcomeConfirmed + } + return writeOutcome{ordinal: record.Ordinal, putAttempted: true, putOutcome: outcome}, errors.Join( + fmt.Errorf("%s verify height %d: %w", mode, record.Height, err), + putErr, + ) + } + if putErr != nil { + metrics.uncertainPUTs.Add(1) + } + metrics.completed.Add(1) + outcome := writeOutcomeConfirmed + if putErr != nil { + outcome = writeOutcomeUncertain + } + return writeOutcome{ + ordinal: record.Ordinal, postETag: observed.ETag, putOutcome: outcome, putAttempted: true, + }, nil +} + +func resolveApplyPut( + ctx context.Context, + manifest planManifest, + prepared preparedWrite, + result putResult, + putErr error, + objects objectStore, + metrics *writeMetrics, +) (writeOutcome, error) { + record := prepared.record + if putErr == nil { + if result.ETag == "" { + putErr = fmt.Errorf("%w: PUT response has no ETag", errObjectWriteUncertain) + } else { + metrics.confirmedPUTs.Add(1) + metrics.completed.Add(1) + return writeOutcome{ + ordinal: record.Ordinal, postETag: result.ETag, putOutcome: writeOutcomeConfirmed, putAttempted: true, + }, nil + } + } + if !errors.Is(putErr, errObjectConflict) && !errors.Is(putErr, errObjectWriteUncertain) { + return writeOutcome{ordinal: record.Ordinal, putAttempted: true}, fmt.Errorf("apply PUT height %d: %w", record.Height, putErr) + } + if errors.Is(putErr, errObjectConflict) { + metrics.conflicts.Add(1) + } + conflict := errors.Is(putErr, errObjectConflict) + + current, getErr := objects.Get(ctx, manifest.Bucket, record.Key) + if getErr != nil { + return writeOutcome{ordinal: record.Ordinal, putAttempted: true, conflict: conflict}, errors.Join( + fmt.Errorf("apply classify GET height %d: %w", record.Height, getErr), + putErr, + ) + } + switch sha256Hash(current.Body) { + case record.NewSHA256: + if err := verifyStoredRecordTarget(record, current, record.NewSHA256); err != nil { + return writeOutcome{ordinal: record.Ordinal, putAttempted: true, conflict: conflict}, errors.Join( + fmt.Errorf("apply classify target height %d: %w", record.Height, err), + putErr, + ) + } + outcome := writeOutcomeUncertain + if errors.Is(putErr, errObjectConflict) { + metrics.alreadyTarget.Add(1) + outcome = writeOutcomeReconciled + } else { + metrics.uncertainPUTs.Add(1) + } + metrics.completed.Add(1) + return writeOutcome{ + ordinal: record.Ordinal, postETag: current.ETag, putOutcome: outcome, putAttempted: true, conflict: conflict, + }, nil + case record.OldSHA256: + if err := verifyStoredRecordTarget(record, current, record.OldSHA256); err != nil { + return writeOutcome{ordinal: record.Ordinal, putAttempted: true, conflict: conflict}, errors.Join( + fmt.Errorf("apply classify source height %d: %w", record.Height, err), + putErr, + ) + } + if current.ETag != prepared.ifMatch { + return writeOutcome{ordinal: record.Ordinal, putAttempted: true, conflict: conflict}, errors.Join( + fmt.Errorf("apply height %d source ETag changed after conditional PUT", record.Height), + putErr, + ) + } + return writeOutcome{ordinal: record.Ordinal, putAttempted: true, conflict: conflict}, errors.Join( + fmt.Errorf("apply PUT height %d left source content; resume can safely retry", record.Height), + putErr, + ) + default: + return writeOutcome{ordinal: record.Ordinal, putAttempted: true, conflict: conflict}, errors.Join( + fmt.Errorf("apply height %d has third-party content after PUT", record.Height), + putErr, + ) + } +} + +func newIssuedWriteJournal(manifest planManifest, manifestHash, mode string, prepared []preparedWrite) writeJournal { + journal := writeJournal{ + Schema: writeJournalSchema, RunID: manifest.RunID, ManifestHash: manifestHash, Operation: mode, + Start: prepared[0].record.Ordinal, End: prepared[len(prepared)-1].record.Ordinal, State: writeJournalIssued, + BatchRecords: uint64(len(prepared)), + } + for _, item := range prepared { + journal.BatchBytes += estimatedPackRecordBytes(item.record) + if !item.needsPUT { + journal.AlreadyTarget++ + continue + } + journal.Intents = append(journal.Intents, writeIntent{ + Operation: mode, Ordinal: item.record.Ordinal, Height: item.record.Height, Key: item.record.Key, + OldSHA256: hex.EncodeToString(item.record.OldSHA256[:]), NewSHA256: hex.EncodeToString(item.record.NewSHA256[:]), + IfMatch: item.ifMatch, + }) + } + return journal +} + +func matchJournalToPrepared(journal writeJournal, prepared []preparedWrite, mode string) error { + byOrdinal := make(map[uint64]writeIntent, len(journal.Intents)) + for _, intent := range journal.Intents { + byOrdinal[intent.Ordinal] = intent + } + observed := make(map[uint64]struct{}, len(journal.Results)) + for _, result := range journal.Results { + observed[result.Ordinal] = struct{}{} + } + for _, item := range prepared { + intent, found := byOrdinal[item.record.Ordinal] + if !found { + if item.needsPUT { + return fmt.Errorf("%s recovered source height %d has no persisted write intent", mode, item.record.Height) + } + continue + } + if intent.Operation != mode || intent.Height != item.record.Height || intent.Key != item.record.Key || + intent.OldSHA256 != hex.EncodeToString(item.record.OldSHA256[:]) || + intent.NewSHA256 != hex.EncodeToString(item.record.NewSHA256[:]) { + return fmt.Errorf("%s write intent %d differs from sealed pack", mode, intent.Ordinal) + } + if item.needsPUT && intent.IfMatch != item.ifMatch { + return fmt.Errorf("%s write intent %d If-Match ETag changed during recovery", mode, intent.Ordinal) + } + if item.needsPUT { + if _, found := observed[intent.Ordinal]; found { + return fmt.Errorf("%s write intent %d previously observed its target but now sees source content", mode, intent.Ordinal) + } + if intent.ConfirmedPUTs != 0 || intent.UncertainPUTs != 0 { + return fmt.Errorf("%s write intent %d has a persisted PUT outcome but now sees source content", mode, intent.Ordinal) + } + } + delete(byOrdinal, item.record.Ordinal) + } + if len(byOrdinal) != 0 { + return fmt.Errorf("%s write journal contains intents outside recovered batch", mode) + } + return nil +} + +func updateJournalOutcomes(journal *writeJournal, outcomes []writeOutcome, mode string) error { + byOrdinal := make(map[uint64]writeOutcome, len(outcomes)) + for _, outcome := range outcomes { + if outcome.ordinal != 0 { + byOrdinal[outcome.ordinal] = outcome + } + } + existing := make(map[uint64]writeObservedResult, len(journal.Results)) + for _, result := range journal.Results { + existing[result.Ordinal] = result + } + results := make([]writeObservedResult, 0, len(journal.Intents)) + for index := range journal.Intents { + intent := &journal.Intents[index] + outcome, found := byOrdinal[intent.Ordinal] + if !found { + continue + } + if result, found := existing[intent.Ordinal]; found { + results = append(results, result) + continue + } + if outcome.putAttempted { + var err error + intent.PUTAttempts, err = checkedAddUint64(intent.PUTAttempts, 1, "write intent PUT attempts") + if err != nil { + return err + } + switch outcome.putOutcome { + case writeOutcomeConfirmed: + intent.ConfirmedPUTs, err = checkedAddUint64(intent.ConfirmedPUTs, 1, "write intent confirmed PUTs") + case writeOutcomeUncertain: + intent.UncertainPUTs, err = checkedAddUint64(intent.UncertainPUTs, 1, "write intent uncertain PUTs") + } + if err != nil { + return err + } + if outcome.conflict { + intent.Conflicts, err = checkedAddUint64(intent.Conflicts, 1, "write intent conditional conflicts") + } + if err != nil { + return err + } + } + if outcome.postETag == "" { + continue + } + resultOutcome := outcome.putOutcome + if resultOutcome == writeOutcomeReconciled { + switch { + case intent.ConfirmedPUTs > 0: + resultOutcome = writeOutcomeConfirmed + case intent.UncertainPUTs > 0: + resultOutcome = writeOutcomeUncertain + case outcome.putAttempted: + // A conditional conflict is a definite non-write. Seeing the target + // afterwards means this object was already complete. + default: + // The intent was durable before the PUT. Seeing its target without a + // durable result proves that an earlier attempt may have committed. + if intent.PUTAttempts == 0 { + intent.PUTAttempts = 1 + } + var err error + intent.UncertainPUTs, err = checkedAddUint64(intent.UncertainPUTs, 1, "write intent uncertain PUTs") + if err != nil { + return err + } + resultOutcome = writeOutcomeUncertain + } + } + observedSHA := intent.NewSHA256 + if mode == rollbackMode { + observedSHA = intent.OldSHA256 + } + results = append(results, writeObservedResult{ + Ordinal: intent.Ordinal, ObservedSHA256: observedSHA, + PostPUTETag: outcome.postETag, Outcome: resultOutcome, + }) + } + journal.Results = results + return nil +} + +func journalAudit(journal writeJournal) (writeAudit, error) { + audit := writeAudit{AlreadyTarget: journal.AlreadyTarget} + for _, intent := range journal.Intents { + var err error + if audit.PUTAttempts, err = checkedAddUint64(audit.PUTAttempts, intent.PUTAttempts, "journal PUT attempts"); err != nil { + return writeAudit{}, err + } + if audit.ConfirmedPUTs, err = checkedAddUint64(audit.ConfirmedPUTs, intent.ConfirmedPUTs, "journal confirmed PUTs"); err != nil { + return writeAudit{}, err + } + if audit.UncertainPUTs, err = checkedAddUint64(audit.UncertainPUTs, intent.UncertainPUTs, "journal uncertain PUTs"); err != nil { + return writeAudit{}, err + } + if audit.Conflicts, err = checkedAddUint64(audit.Conflicts, intent.Conflicts, "journal conditional conflicts"); err != nil { + return writeAudit{}, err + } + } + for _, result := range journal.Results { + if result.Outcome != writeOutcomeReconciled { + continue + } + var err error + audit.AlreadyTarget, err = checkedAddUint64(audit.AlreadyTarget, 1, "journal already-target count") + if err != nil { + return writeAudit{}, err + } + } + return audit, nil +} + +func verifyStoredRecordTarget(record packRecord, object storedObject, expectedSHA [32]byte) error { + if err := verifyRecordTarget(record, object.Body, expectedSHA); err != nil { + return err + } + if !reflect.DeepEqual(object.Headers, record.Headers) { + return fmt.Errorf("object metadata changed") + } + return nil +} + +func verifyRecordTarget(record packRecord, body []byte, expectedSHA [32]byte) error { + if sha256Hash(body) != expectedSHA { + return fmt.Errorf("body SHA-256 mismatch") + } + var diff dtypes.BlockStorageDiff + if err := rlp.DecodeBytes(body, &diff); err != nil { + return err + } + digest, err := retainedDigest(diff) + if err != nil { + return err + } + if digest != record.RetainedSHA256 { + return fmt.Errorf("retained fields changed") + } + return nil +} + +type verifyReport struct { + Processed int64 `json:"processed"` + VerifiedChanged int64 `json:"verified_changed"` + SkippedEqual int64 `json:"skipped_equal_root"` + SemanticChanges int64 `json:"semantic_changes_remaining"` + S3GETs int64 `json:"s3_gets_this_run"` + Concurrency int `json:"concurrency"` + DurationSeconds float64 `json:"duration_seconds"` + ObjectsPerSecond float64 `json:"objects_per_second"` +} + +type verifyTask struct { + height uint64 + root common.Hash + parent common.Hash + key string + canonical []dtypes.AccountStorageDiff + planned *packRecord + bytes int64 +} + +type verifyOutcome struct { + height uint64 + changed bool + skipped bool + got bool + bytes int64 +} + +func runVerify(ctx context.Context, manifestPath, checkpointDir string, objects objectStore) (verifyReport, error) { + options := defaultParallelOptions() + options.Concurrency, options.Window = 1, 1 + options.CheckpointEvery = 1 + return runVerifyWithOptions(ctx, manifestPath, checkpointDir, options, objects) +} + +func runVerifyWithOptions( + ctx context.Context, + manifestPath, checkpointDir string, + options parallelOptions, + objects objectStore, +) (verifyReport, error) { + started := time.Now() + if err := options.validate(true); err != nil { + return verifyReport{}, err + } + if options.MaxInFlightBytes < maxObjectOperationBytes+maxObjectSize { + return verifyReport{}, fmt.Errorf("verify max-inflight-bytes must be at least %d", maxObjectOperationBytes+maxObjectSize) + } + planDir, err := requireSealedPlanDirectory(manifestPath) + if err != nil { + return verifyReport{}, err + } + activeFilesystem, err := requireReadOnlyFilesystem(planDir, "active sealed plan") + if err != nil { + return verifyReport{}, err + } + if err := requireReadOnlyPlanManifest(planDir, activeFilesystem); err != nil { + return verifyReport{}, err + } + manifest, manifestHash, err := loadPlanManifestContext(ctx, manifestPath) + if err != nil { + return verifyReport{}, err + } + if err := requireFullPlan(manifest, "verify"); err != nil { + return verifyReport{}, err + } + if err := requireRuntimeBuildIdentity(manifest); err != nil { + return verifyReport{}, err + } + if err := requireReadOnlyPlanArtifacts(planDir, manifest, activeFilesystem); err != nil { + return verifyReport{}, err + } + var directArchive *archiveReader + var dumpInfo dumpManifest + var iterateVerifySource func(context.Context, func(int64, []dtypes.AccountStorageDiff) error) error + switch effectivePlanSourceMode(manifest) { + case planSourceDumpV1: + dumpDir, err := requireSealedDumpDirectory(manifest.DumpPath) + if err != nil { + return verifyReport{}, err + } + if _, err := requireReadOnlyFilesystem(dumpDir, "sealed dump"); err != nil { + return verifyReport{}, err + } + dumpBody, err := readRegularFileNoFollow(filepath.Join(dumpDir, "dump-manifest.v1.json"), "sealed dump manifest") + if err != nil { + return verifyReport{}, err + } + if sha256Hex(dumpBody) != manifest.DumpManifestHash { + return verifyReport{}, fmt.Errorf("dump manifest hash mismatch") + } + if err := decodeStrictJSON(dumpBody, &dumpInfo, "dump manifest"); err != nil { + return verifyReport{}, err + } + if err := validateDumpContext(dumpInfo, dumpContext{ + Schema: dumpManifestSchema, FirstVersion: 1, LastVersion: manifest.FinalHeight, + SnapshotID: manifest.SnapshotID, ArchiveIdentity: manifest.ArchiveIdentity, + CronosCommit: manifest.CronosCommit, EthermintCommit: manifest.EthermintCommit, + IAVLCommit: manifest.IAVLCommit, + ImageDigest: manifest.ImageDigest, BuildTags: manifest.BuildTags, + }); err != nil { + return verifyReport{}, err + } + if _, err := requireReadOnlySealedDump(manifest.DumpPath, dumpInfo); err != nil { + return verifyReport{}, err + } + iterateVerifySource = func(iterCtx context.Context, callback func(int64, []dtypes.AccountStorageDiff) error) error { + return iterateSealedDumpContext(iterCtx, manifest.DumpPath, dumpInfo, func(height int64, changeSet *iavl.ChangeSet) error { + if height == 1 { + return callback(height, nil) + } + canonical, err := statediff.CanonicalStorageDiff(changeSet) + if err != nil { + return fmt.Errorf("height %d canonical storage: %w", height, err) + } + return callback(height, canonical) + }) + } + case planSourceDirectV1: + directArchive, err = openArchive(manifest.ArchiveIdentity.Home) + if err != nil { + return verifyReport{}, err + } + defer directArchive.Close() + directIdentity, err := directArchive.identity() + if err != nil { + return verifyReport{}, err + } + if directIdentity != manifest.ArchiveIdentity { + return verifyReport{}, fmt.Errorf("direct IAVL archive identity differs from sealed plan") + } + iterateVerifySource = func(iterCtx context.Context, callback func(int64, []dtypes.AccountStorageDiff) error) error { + return iterateArchiveDirectStorageDiffsContext( + iterCtx, directArchive, defaultDumpCacheSize, defaultDirectIAVLConcurrency, + manifest.FirstHeight, manifest.FinalHeight, callback, + ) + } + default: + return verifyReport{}, fmt.Errorf("unsupported plan source mode %q", manifest.SourceMode) + } + checkpointDir, err = ensureNonSymlinkDirectory(checkpointDir, "verify checkpoint directory") + if err != nil { + return verifyReport{}, err + } + checkpointPath := filepath.Join(checkpointDir, "verify.json") + locks, err := acquireStagingLocks(checkpointPath, filepath.Join(checkpointDir, "statediff-rewriter-write")) + if err != nil { + return verifyReport{}, err + } + defer func() { _ = releaseStagingLocks(locks) }() + cpPath := checkpointPath + cp, err := loadCheckpoint(cpPath, manifest.RunID, manifestHash, "verify") + if err != nil { + return verifyReport{}, err + } + if cp.Frontier == 0 { + if cp.Height != 0 || cp.Changed != 0 { + return verifyReport{}, fmt.Errorf("verify checkpoint has a non-zero height or changed frontier at zero") + } + } else if cp.Frontier < uint64(manifest.FirstHeight) || cp.Frontier > uint64(manifest.FinalHeight) || + cp.Height != cp.Frontier || cp.Changed > uint64(manifest.Changed) { + return verifyReport{}, fmt.Errorf("verify checkpoint is outside the manifest") + } + if cp.Frontier == uint64(manifest.FinalHeight) { + return verifyReport{}, fmt.Errorf("verify checkpoint is already complete; use a new checkpoint directory to revalidate current S3 state") + } + if err := validatePlanForWriteContext(ctx, filepath.Dir(manifestPath), manifest); err != nil { + return verifyReport{}, fmt.Errorf("verify plan preflight: %w", err) + } + batcher, err := newCheckpointBatcher(cpPath, cp, options.CheckpointEvery, options.CheckpointInterval) + if err != nil { + return verifyReport{}, err + } + limiter, err := newByteLimiter(options.MaxInFlightBytes) + if err != nil { + return verifyReport{}, err + } + rootStream, err := newHeightRootStream(filepath.Join(filepath.Dir(manifestPath), manifest.HeightRootIndex), uint64(manifest.FirstHeight)) + if err != nil { + return verifyReport{}, err + } + defer rootStream.Close() + packStream := newPackStream(filepath.Dir(manifestPath), manifest) + defer packStream.Close() + + report := verifyReport{Concurrency: options.Concurrency} + var semanticChanges atomic.Int64 + var changedFrontier uint64 + checkpointMatched := cp.Frontier == 0 + firstSequence := uint64(manifest.FirstHeight) + if objects == nil { + objects, err = newS3ObjectStore(ctx, manifest.Region, options.Concurrency) + if err != nil { + return verifyReport{}, err + } + } + pipelineErr := runOrderedPipeline(ctx, firstSequence, options.Concurrency, options.Window, + func(pipelineCtx context.Context, emit func(uint64, verifyTask) error) error { + var previousRoot common.Hash + var changedSeen uint64 + err := iterateVerifySource(pipelineCtx, func(height int64, canonical []dtypes.AccountStorageDiff) error { + if height == 1 { + return nil + } + if height < manifest.FirstHeight || height > manifest.FinalHeight { + return fmt.Errorf("source height %d is outside manifest range %d-%d", height, manifest.FirstHeight, manifest.FinalHeight) + } + rootRecord, err := rootStream.Next() + if err != nil { + return err + } + parent := previousRoot + previousRoot = rootRecord.Root + key := fmt.Sprintf("%s/%s/stateDiff", manifest.Prefix, strings.ToLower(rootRecord.Root.Hex())) + var planned *packRecord + record, ok, err := packStream.Peek() + if err != nil { + return err + } + if ok && record.Height < uint64(height) { + return fmt.Errorf("pack record height %d precedes source height %d", record.Height, height) + } + if ok && record.Height == uint64(height) { + record, _, err = packStream.Next() + if err != nil { + return err + } + if record.Key != key { + return fmt.Errorf("pack key %s does not match height %d root", record.Key, height) + } + planned = &record + changedSeen++ + } + if rootRecord.Root == parent { + if len(canonical) != 0 || planned != nil { + return fmt.Errorf("height %d has changes behind an equal-root short circuit", height) + } + } + if uint64(height) <= cp.Frontier { + if uint64(height) == cp.Frontier { + if changedSeen != cp.Changed { + return fmt.Errorf("verify checkpoint height %d contains %d changed records, not %d", height, changedSeen, cp.Changed) + } + checkpointMatched = true + } + } + if uint64(height) > cp.Frontier && !checkpointMatched { + return fmt.Errorf("verify checkpoint height %d was not found", cp.Frontier) + } + weight, err := canonicalObjectOperationBytes(canonical, rootRecord.Root == parent) + if err != nil { + return fmt.Errorf("height %d canonical storage reservation: %w", height, err) + } + if err := limiter.Acquire(pipelineCtx, weight); err != nil { + return err + } + task := verifyTask{ + height: uint64(height), root: rootRecord.Root, parent: parent, key: key, + canonical: canonical, planned: planned, bytes: weight, + } + if err := emit(uint64(height), task); err != nil { + limiter.Release(weight) + return err + } + return nil + }) + if err != nil { + return err + } + if !checkpointMatched { + return fmt.Errorf("verify checkpoint height %d was not found", cp.Frontier) + } + if err := rootStream.Finish(uint64(manifest.FinalHeight)); err != nil { + return err + } + if _, ok, err := packStream.Peek(); err != nil { + return err + } else if ok { + return fmt.Errorf("pack has records after final height") + } + if int64(changedSeen) != manifest.Changed { + return fmt.Errorf("pack merge saw %d changed records, want %d", changedSeen, manifest.Changed) + } + return nil + }, + func(workerCtx context.Context, task verifyTask) (verifyOutcome, error) { + outcome, err := processVerifyTask(workerCtx, manifest.Bucket, task, objects, &semanticChanges) + if err != nil { + limiter.Release(task.bytes) + return verifyOutcome{}, err + } + outcome.bytes = task.bytes + return outcome, nil + }, + func(_ uint64, outcome verifyOutcome) error { + defer limiter.Release(outcome.bytes) + report.Processed++ + if outcome.skipped { + report.SkippedEqual++ + } + if outcome.got { + report.S3GETs++ + } + if outcome.changed { + changedFrontier++ + report.VerifiedChanged++ + } + if outcome.height <= cp.Frontier { + if outcome.height == cp.Frontier && changedFrontier != cp.Changed { + return fmt.Errorf("verify checkpoint height %d contains %d changed records, not %d", cp.Frontier, changedFrontier, cp.Changed) + } + return nil + } + batcher.checkpoint.Changed = changedFrontier + return batcher.Advance(outcome.height, outcome.height) + }, + ) + flushErr := batcher.Flush() + report.SemanticChanges = semanticChanges.Load() + report.DurationSeconds = time.Since(started).Seconds() + if report.DurationSeconds > 0 { + report.ObjectsPerSecond = float64(report.S3GETs) / report.DurationSeconds + } + if pipelineErr != nil || flushErr != nil { + return report, errors.Join(pipelineErr, flushErr) + } + if directArchive != nil { + identity, err := directArchive.identity() + if err != nil { + return report, err + } + if identity != manifest.ArchiveIdentity { + return report, fmt.Errorf("direct IAVL archive identity changed during verify") + } + } + if report.VerifiedChanged != manifest.Changed || changedFrontier != uint64(manifest.Changed) { + return report, fmt.Errorf("verified changed %d, want %d", report.VerifiedChanged, manifest.Changed) + } + if report.Processed != manifest.Processed || report.SkippedEqual != manifest.SkippedEqualRoot { + return report, fmt.Errorf("verified blocks %d, want %d", report.Processed, manifest.Processed) + } + return report, nil +} + +func processVerifyTask( + ctx context.Context, + bucket string, + task verifyTask, + objects objectStore, + semanticChanges *atomic.Int64, +) (verifyOutcome, error) { + outcome := verifyOutcome{height: task.height, changed: task.planned != nil} + if task.root == task.parent { + if len(task.canonical) != 0 || task.planned != nil { + return verifyOutcome{}, fmt.Errorf("height %d has changes behind an equal-root short circuit", task.height) + } + outcome.skipped = true + return outcome, nil + } + object, err := objects.Get(ctx, bucket, task.key) + if err != nil { + return verifyOutcome{}, fmt.Errorf("verify GET height %d: %w", task.height, err) + } + outcome.got = true + var diff dtypes.BlockStorageDiff + if err := rlp.DecodeBytes(object.Body, &diff); err != nil { + return verifyOutcome{}, fmt.Errorf("verify decode height %d: %w", task.height, err) + } + if diff.Hash != task.root || diff.ParentHash != task.parent { + return verifyOutcome{}, fmt.Errorf("height %d root mismatch", task.height) + } + equal, _, _, _, err := compareStorage(diff.StorageDiff, task.canonical) + if err != nil { + return verifyOutcome{}, err + } + if !equal { + semanticChanges.Add(1) + return verifyOutcome{}, fmt.Errorf("height %d storage is not canonical", task.height) + } + if task.planned != nil { + if sha256Hash(object.Body) != task.planned.NewSHA256 { + return verifyOutcome{}, fmt.Errorf("height %d body SHA-256 mismatch", task.height) + } + digest, err := retainedDigest(diff) + if err != nil { + return verifyOutcome{}, err + } + if digest != task.planned.RetainedSHA256 { + return verifyOutcome{}, fmt.Errorf("height %d retained fields changed", task.height) + } + if !reflect.DeepEqual(object.Headers, task.planned.Headers) { + return verifyOutcome{}, fmt.Errorf("height %d object metadata changed", task.height) + } + } + return outcome, nil +} diff --git a/cmd/statediff-rewriter/operations_test.go b/cmd/statediff-rewriter/operations_test.go new file mode 100644 index 00000000..9f0cf045 --- /dev/null +++ b/cmd/statediff-rewriter/operations_test.go @@ -0,0 +1,1167 @@ +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/cosmos/iavl" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rlp" + "github.com/evmos/ethermint/debank/statediff" + dtypes "github.com/evmos/ethermint/debank/types" + "github.com/stretchr/testify/require" +) + +type fakeObjectStore struct { + mu sync.Mutex + objects map[string]storedObject + puts int + uncertainPut bool + getDelay time.Duration + putDelay time.Duration + getGates map[string]<-chan struct{} + putGates map[string]<-chan struct{} + getErrors map[string]error + putErrors map[string]error + commitOnPutError map[string]bool + gets map[string]int + activeGets int + maxActiveGets int + activePuts int + maxActivePuts int +} + +func allowTestReadonlyFilesystems(t *testing.T) { + t.Helper() + previous := inspectArtifactFilesystem + inspectArtifactFilesystem = func(path string) (filesystemStatus, error) { + device := uint64(1) + if strings.Contains(path, "backup.sealed") { + device = 2 + } + return filesystemStatus{Device: device, ReadOnly: true}, nil + } + t.Cleanup(func() { inspectArtifactFilesystem = previous }) +} + +func (s *fakeObjectStore) Get(ctx context.Context, _, key string) (storedObject, error) { + s.mu.Lock() + if s.gets == nil { + s.gets = make(map[string]int) + } + s.gets[key]++ + s.activeGets++ + if s.activeGets > s.maxActiveGets { + s.maxActiveGets = s.activeGets + } + delay, gate, injectedErr := s.getDelay, s.getGates[key], s.getErrors[key] + s.mu.Unlock() + defer func() { + s.mu.Lock() + s.activeGets-- + s.mu.Unlock() + }() + if delay > 0 { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-timer.C: + case <-ctx.Done(): + return storedObject{}, ctx.Err() + } + } + if gate != nil { + select { + case <-gate: + case <-ctx.Done(): + return storedObject{}, ctx.Err() + } + } + if injectedErr != nil { + return storedObject{}, injectedErr + } + s.mu.Lock() + defer s.mu.Unlock() + object, ok := s.objects[key] + if !ok { + return storedObject{}, errors.New("not found") + } + return cloneStoredObject(object), nil +} + +func (s *fakeObjectStore) getStats() (int, int, map[string]int) { + s.mu.Lock() + defer s.mu.Unlock() + counts := make(map[string]int, len(s.gets)) + var total int + for key, count := range s.gets { + counts[key] = count + total += count + } + return total, s.maxActiveGets, counts +} + +func (s *fakeObjectStore) putStats() (int, int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.puts, s.maxActivePuts +} + +func (s *fakeObjectStore) Put(ctx context.Context, _, key string, body []byte, ifMatch string, headers objectHeaders) (putResult, error) { + s.mu.Lock() + s.activePuts++ + if s.activePuts > s.maxActivePuts { + s.maxActivePuts = s.activePuts + } + delay, gate := s.putDelay, s.putGates[key] + s.mu.Unlock() + defer func() { + s.mu.Lock() + s.activePuts-- + s.mu.Unlock() + }() + if delay > 0 { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-timer.C: + case <-ctx.Done(): + return putResult{}, ctx.Err() + } + } + if gate != nil { + select { + case <-gate: + case <-ctx.Done(): + return putResult{}, ctx.Err() + } + } + s.mu.Lock() + defer s.mu.Unlock() + object, ok := s.objects[key] + if !ok { + return putResult{}, errors.New("not found") + } + if object.ETag != ifMatch { + return putResult{}, fmt.Errorf("%w: precondition failed", errObjectConflict) + } + s.puts++ + injectedErr := s.putErrors[key] + postETag := fmt.Sprintf("etag-%d", s.puts) + if injectedErr == nil || s.commitOnPutError[key] { + s.objects[key] = storedObject{Body: append([]byte(nil), body...), ETag: postETag, Headers: cloneObjectHeaders(headers)} + } + if injectedErr != nil { + return putResult{}, injectedErr + } + if s.uncertainPut { + s.uncertainPut = false + return putResult{}, fmt.Errorf("%w: timeout after write", errObjectWriteUncertain) + } + return putResult{ETag: postETag}, nil +} + +func cloneStoredObject(object storedObject) storedObject { + object.Body = append([]byte(nil), object.Body...) + object.Headers = cloneObjectHeaders(object.Headers) + return object +} + +func cloneObjectHeaders(headers objectHeaders) objectHeaders { + headers.Metadata = append([]byte(nil), headers.Metadata...) + return headers +} + +func (s *fakeObjectStore) targetVisible(record packRecord) bool { + s.mu.Lock() + defer s.mu.Unlock() + object, ok := s.objects[record.Key] + return ok && sha256Hash(object.Body) == record.NewSHA256 && reflect.DeepEqual(object.Headers, record.Headers) +} + +func (s *fakeObjectStore) oldVisible(record packRecord) bool { + s.mu.Lock() + defer s.mu.Unlock() + object, ok := s.objects[record.Key] + return ok && sha256Hash(object.Body) == record.OldSHA256 && reflect.DeepEqual(object.Headers, record.Headers) +} + +func makeSealedPlan(t *testing.T) (string, planManifest, packRecord, storedObject) { + t.Helper() + manifestPath, manifest, records, objects := makeSealedPlanRecords(t, 1) + return manifestPath, manifest, records[0], objects[records[0].Key] +} + +func makeSealedPlanRecords(t *testing.T, count int) (string, planManifest, []packRecord, map[string]storedObject) { + t.Helper() + allowTestReadonlyFilesystems(t) + useTestRuntimeBuildIdentity(t) + base := t.TempDir() + dir := filepath.Join(base, "plan.sealed") + require.NoError(t, os.Mkdir(dir, 0o755)) + metadata, err := canonicalMetadata(map[string]string{"source": "test"}) + require.NoError(t, err) + writer, err := newPackWriter(dir, 1<<30) + require.NoError(t, err) + records := make([]packRecord, 0, count) + objects := make(map[string]storedObject, count) + rootRecords := make([]rootRecord, 0, count) + dumpBody := encodeChangeSet(t, 1) + for index := 0; index < count; index++ { + height := uint64(index + 2) + root := common.BigToHash(new(big.Int).SetUint64(height)) + parent := common.Hash{} + if height > 2 { + parent = common.BigToHash(new(big.Int).SetUint64(height - 1)) + } + key := make([]byte, 53) + key[0], key[20], key[52] = 0x02, byte(index%251+1), 1 + oldCanonical, canonicalErr := statediff.CanonicalStorageDiff(&iavl.ChangeSet{Pairs: []*iavl.KVPair{{Key: key, Value: []byte{1}}}}) + require.NoError(t, canonicalErr) + newCanonical, canonicalErr := statediff.CanonicalStorageDiff(&iavl.ChangeSet{Pairs: []*iavl.KVPair{{Key: key, Value: []byte{2}}}}) + require.NoError(t, canonicalErr) + oldDiff := dtypes.BlockStorageDiff{Hash: root, ParentHash: parent, StorageDiff: oldCanonical} + newDiff := oldDiff + newDiff.StorageDiff = newCanonical + oldBody, encodeErr := rlp.EncodeToBytes(oldDiff) + require.NoError(t, encodeErr) + newBody, encodeErr := rlp.EncodeToBytes(newDiff) + require.NoError(t, encodeErr) + retained, digestErr := retainedDigest(oldDiff) + require.NoError(t, digestErr) + objectKey := fmt.Sprintf("%s/%s/stateDiff", defaultPrefix, root.Hex()) + record := packRecord{ + Schema: packSchema, Ordinal: uint64(index + 1), Height: height, Key: objectKey, OldETag: fmt.Sprintf("etag-old-%d", height), + OldBody: oldBody, NewBody: newBody, OldSHA256: sha256Hash(oldBody), NewSHA256: sha256Hash(newBody), + Headers: objectHeaders{Metadata: metadata, ContentType: "application/octet-stream"}, RetainedSHA256: retained, + } + require.NoError(t, writer.Write(record)) + records = append(records, record) + objects[objectKey] = storedObject{Body: oldBody, ETag: record.OldETag, Headers: cloneObjectHeaders(record.Headers)} + rootRecords = append(rootRecords, rootRecord{Root: root, Height: height}) + dumpBody = append(dumpBody, encodeChangeSet(t, int64(height), &iavl.KVPair{Key: key, Value: []byte{2}})...) + } + chunks, err := writer.Close() + require.NoError(t, err) + manifest := planManifest{ + Schema: manifestSchema, Sealed: true, RunID: "test-run", Bucket: defaultBucket, Prefix: defaultPrefix, Region: defaultRegion, + FirstHeight: 2, FinalHeight: int64(count + 1), Processed: int64(count), Changed: int64(count), Chunks: chunks, + CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), + HeightRootIndex: "roots.by-height", RootIndex: "roots.sorted", SnapshotID: "snap-test", ImageDigest: testImageDigest, + CronosCommit: testCronosCommit, EthermintCommit: testEthermintCommit, IAVLCommit: testIAVLCommit, + ArchiveIdentity: archiveIdentity{ + Home: "/archive", DatabaseIdentity: "db-test", + LatestVersion: int64(count + 1), FinalCommitHash: fmt.Sprintf("0x%02x", count+1), + }, + BuildTags: testBuildTags, + } + for _, record := range records { + manifest.OldBytes += int64(len(record.OldBody)) + manifest.NewBytes += int64(len(record.NewBody)) + manifest.SlotsChanged += record.SlotsChanged + } + dumpStaging := filepath.Join(base, "dump.staging") + writeDumpFile(t, dumpStaging, "all.zz", zlibBody(t, dumpBody)) + dumpContext := dumpContext{ + Schema: dumpManifestSchema, FirstVersion: 1, LastVersion: manifest.FinalHeight, + SnapshotID: manifest.SnapshotID, ArchiveIdentity: manifest.ArchiveIdentity, + CronosCommit: manifest.CronosCommit, EthermintCommit: manifest.EthermintCommit, + IAVLCommit: manifest.IAVLCommit, + ImageDigest: manifest.ImageDigest, BuildTags: manifest.BuildTags, + } + writeTestDumpSource(t, dumpStaging, dumpContext) + manifest.DumpPath, _, manifest.DumpManifestHash, err = sealDump(dumpStaging, dumpContext) + require.NoError(t, err) + heightRootFile := filepath.Join(dir, manifest.HeightRootIndex) + writeRootIndex(t, heightRootFile, rootRecords...) + manifest.HeightRootIndexSHA256, _, err = hashFile(heightRootFile) + require.NoError(t, err) + rootFile := filepath.Join(dir, manifest.RootIndex) + writeRootIndex(t, rootFile, rootRecords...) + manifest.RootIndexSHA256, _, err = hashFile(rootFile) + require.NoError(t, err) + manifest.RootMultisetSHA256, _, err = rootMultisetSHA256(rootFile) + require.NoError(t, err) + manifestPath := filepath.Join(dir, "manifest.v1.json") + _, err = atomicJSON(manifestPath, manifest) + require.NoError(t, err) + return manifestPath, manifest, records, objects +} + +func copyPlanDirectory(t *testing.T, source string) string { + t.Helper() + target := filepath.Join(t.TempDir(), "backup.sealed") + require.NoError(t, filepath.Walk(source, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + relative, err := filepath.Rel(source, path) + if err != nil { + return err + } + destination := filepath.Join(target, relative) + if info.IsDir() { + return os.MkdirAll(destination, info.Mode()) + } + body, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(destination, body, info.Mode()) + })) + return target +} + +func writeBackupProof(t *testing.T, path, manifestHash string) string { + t.Helper() + proofPath := filepath.Join(t.TempDir(), "backup.json") + backup := copyPlanDirectory(t, filepath.Dir(path)) + body, err := json.Marshal(backupProof{ + Schema: backupProofSchema, ManifestSHA256: manifestHash, Kind: "ebs-snapshot-restore", + SnapshotID: "snap-backup", Location: backup, Status: "completed", Independent: true, + }) + require.NoError(t, err) + require.NoError(t, os.WriteFile(proofPath, body, 0o600)) + return proofPath +} + +func runtimeArtifactPath(manifestPath, name string) string { + return filepath.Join(filepath.Dir(filepath.Dir(manifestPath)), name) +} + +func TestApplyRejectsBackupWithoutIndependentReadableCopy(t *testing.T) { + manifestPath, _, record, oldObject := makeSealedPlan(t) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + proofPath := runtimeArtifactPath(manifestPath, "backup.json") + body, err := json.Marshal(backupProof{ + Schema: backupProofSchema, ManifestSHA256: manifestHash, Kind: "ebs-snapshot-restore", SnapshotID: "snap-backup", + Location: filepath.Dir(manifestPath), Status: "completed", Independent: true, + }) + require.NoError(t, err) + require.NoError(t, os.WriteFile(proofPath, body, 0o600)) + store := &fakeObjectStore{objects: map[string]storedObject{record.Key: oldObject}} + _, err = runWriteMode(context.Background(), manifestPath, runtimeArtifactPath(manifestPath, "apply.json"), proofPath, "apply", store) + require.ErrorContains(t, err, "active plan directory") +} + +func TestWriteModesRejectPilotPlan(t *testing.T) { + manifestPath, manifest, _, _ := makeSealedPlan(t) + manifest.Schema = pilotManifestSchema + _, err := atomicJSON(manifestPath, manifest) + require.NoError(t, err) + + store := &fakeObjectStore{objects: map[string]storedObject{}} + _, err = runWriteMode(context.Background(), manifestPath, runtimeArtifactPath(manifestPath, "apply.json"), "", "apply", store) + require.ErrorContains(t, err, "pilot plans support plan only") + require.Zero(t, store.puts) + + _, err = runWriteMode(context.Background(), manifestPath, runtimeArtifactPath(manifestPath, "rollback.json"), "", "rollback", store) + require.ErrorContains(t, err, "pilot plans support plan only") + require.Zero(t, store.puts) + + _, err = runVerify(context.Background(), manifestPath, runtimeArtifactPath(manifestPath, "verify"), store) + require.ErrorContains(t, err, "pilot plans support plan only") + require.Zero(t, store.puts) +} + +func TestApplyAndRollbackLifecycle(t *testing.T) { + manifestPath, manifest, record, oldObject := makeSealedPlan(t) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + backup := writeBackupProof(t, manifestPath, manifestHash) + store := &fakeObjectStore{objects: map[string]storedObject{record.Key: oldObject}, uncertainPut: true} + + applyCheckpoint := runtimeArtifactPath(manifestPath, "apply.json") + report, err := runWriteMode(context.Background(), manifestPath, applyCheckpoint, backup, "apply", store) + require.NoError(t, err) + require.Equal(t, int64(1), report.PlannedChanged) + require.Equal(t, int64(1), report.CheckpointFrontier) + require.Equal(t, 1, store.puts) + require.Equal(t, record.NewSHA256, sha256Hash(store.objects[record.Key].Body)) + require.Equal(t, record.Headers, store.objects[record.Key].Headers) + applyCP, err := loadCheckpoint(applyCheckpoint, manifest.RunID, manifestHash, "apply") + require.NoError(t, err) + require.Equal(t, uint64(1), applyCP.PUTAttempts) + require.Equal(t, uint64(1), applyCP.UncertainPUTs) + require.Zero(t, applyCP.AlreadyTarget) + totalGETs, _, _ := store.getStats() + require.Equal(t, 1, totalGETs) + + report, err = runWriteMode(context.Background(), manifestPath, runtimeArtifactPath(manifestPath, "apply-again.json"), backup, "apply", store) + require.NoError(t, err) + require.Zero(t, report.UncertainPUTs) + require.Equal(t, int64(1), report.AlreadyTarget) + require.Equal(t, int64(1), report.Conflicts) + require.Equal(t, 1, store.puts) + + report, err = runWriteMode(context.Background(), manifestPath, runtimeArtifactPath(manifestPath, "rollback.json"), "", "rollback", store) + require.NoError(t, err) + require.Equal(t, manifest.Changed, report.CheckpointFrontier) + require.Equal(t, record.OldSHA256, sha256Hash(store.objects[record.Key].Body)) +} + +func TestCompletedCheckpointsRemainTerminalWithoutRemoteReads(t *testing.T) { + t.Run("apply checkpoint after rollback", func(t *testing.T) { + manifestPath, manifest, record, oldObject := makeSealedPlan(t) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + backup := writeBackupProof(t, manifestPath, manifestHash) + store := &fakeObjectStore{objects: map[string]storedObject{record.Key: oldObject}} + applyCheckpoint := runtimeArtifactPath(manifestPath, "apply-complete.json") + _, err = runWriteMode(context.Background(), manifestPath, applyCheckpoint, backup, "apply", store) + require.NoError(t, err) + _, err = runWriteMode( + context.Background(), manifestPath, runtimeArtifactPath(manifestPath, "rollback-after-apply.json"), "", "rollback", store, + ) + require.NoError(t, err) + require.True(t, store.oldVisible(record)) + getsBefore := store.gets[record.Key] + _, err = runWriteMode(context.Background(), manifestPath, applyCheckpoint, backup, "apply", store) + require.ErrorContains(t, err, "checkpoint is already complete") + require.Equal(t, getsBefore, store.gets[record.Key]) + checkpoint, err := loadCheckpoint(applyCheckpoint, manifest.RunID, manifestHash, "apply") + require.NoError(t, err) + require.Equal(t, uint64(manifest.Changed), checkpoint.Frontier) + }) + + t.Run("verify checkpoint after object mutation", func(t *testing.T) { + manifestPath, _, record, oldObject := makeSealedPlan(t) + store := &fakeObjectStore{objects: map[string]storedObject{ + record.Key: {Body: record.NewBody, ETag: "etag-new", Headers: cloneObjectHeaders(record.Headers)}, + }} + checkpointDir := runtimeArtifactPath(manifestPath, "verify-complete") + _, err := runVerify(context.Background(), manifestPath, checkpointDir, store) + require.NoError(t, err) + store.objects[record.Key] = oldObject + getsBefore := store.gets[record.Key] + _, err = runVerify(context.Background(), manifestPath, checkpointDir, store) + require.ErrorContains(t, err, "checkpoint is already complete") + require.Equal(t, getsBefore, store.gets[record.Key]) + }) +} + +func TestPartialWriteCheckpointAdvancesPackWithoutS3GET(t *testing.T) { + manifestPath, manifest, records, objects := makeSealedPlanRecords(t, 3) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + backup := writeBackupProof(t, manifestPath, manifestHash) + checkpointPath := runtimeArtifactPath(manifestPath, "apply-partial.json") + require.NoError(t, saveCheckpoint(checkpointPath, checkpoint{ + RunID: manifest.RunID, ManifestHash: manifestHash, Mode: "apply", + Frontier: 1, Height: records[0].Height, ConfirmedPUTs: 1, PUTAttempts: 1, + })) + store := &fakeObjectStore{objects: objects} + + report, err := runWriteModeWithOptions( + context.Background(), manifestPath, checkpointPath, backup, "apply", defaultParallelOptions(), store, + ) + require.NoError(t, err) + require.Equal(t, int64(2), report.CompletedThisRun) + require.Zero(t, report.AlreadyTarget) + totalGETs, _, _ := store.getStats() + require.Zero(t, totalGETs) + puts, _ := store.putStats() + require.Equal(t, 2, puts) + require.True(t, store.oldVisible(records[0])) + for _, record := range records[1:] { + require.True(t, store.targetVisible(record), "height %d", record.Height) + } +} + +func TestApplyInterruptionRecoveryGETsOnlyIssuedJournalBatch(t *testing.T) { + manifestPath, manifest, records, objects := makeSealedPlanRecords(t, 4) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + backup := writeBackupProof(t, manifestPath, manifestHash) + checkpointPath := runtimeArtifactPath(manifestPath, "apply-interrupted.json") + require.NoError(t, saveCheckpoint(checkpointPath, checkpoint{ + RunID: manifest.RunID, ManifestHash: manifestHash, Mode: applyMode, + Frontier: 2, Height: records[1].Height, PUTAttempts: 2, ConfirmedPUTs: 2, + })) + journal := newIssuedWriteJournal(manifest, manifestHash, applyMode, []preparedWrite{ + {record: records[2], needsPUT: true, ifMatch: records[2].OldETag}, + {record: records[3], needsPUT: true, ifMatch: records[3].OldETag}, + }) + require.NoError(t, saveWriteJournal(writeJournalPath(checkpointPath), journal)) + for _, record := range records[:3] { + objects[record.Key] = storedObject{ + Body: record.NewBody, ETag: "etag-new-" + record.Key, Headers: cloneObjectHeaders(record.Headers), + } + } + store := &fakeObjectStore{objects: objects} + + report, err := runWriteModeWithOptions( + context.Background(), manifestPath, checkpointPath, backup, applyMode, defaultParallelOptions(), store, + ) + require.NoError(t, err) + require.Equal(t, int64(2), report.CompletedThisRun) + require.Equal(t, int64(1), report.AlreadyTarget) + totalGETs, _, counts := store.getStats() + require.Equal(t, 2, totalGETs) + require.Zero(t, counts[records[0].Key]) + require.Zero(t, counts[records[1].Key]) + require.Equal(t, 1, counts[records[2].Key]) + require.Equal(t, 1, counts[records[3].Key]) + puts, _ := store.putStats() + require.Equal(t, 1, puts) + for _, record := range records { + require.True(t, store.targetVisible(record), "height %d", record.Height) + } +} + +func TestPartialVerifyCheckpointRevalidatesPrefix(t *testing.T) { + t.Run("detects mutated prefix", func(t *testing.T) { + manifestPath, manifest, records, objects := makeSealedPlanRecords(t, 3) + for _, record := range records { + objects[record.Key] = storedObject{ + Body: record.NewBody, ETag: "etag-new", Headers: cloneObjectHeaders(record.Headers), + } + } + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + checkpointDir := runtimeArtifactPath(manifestPath, "verify-partial") + checkpointPath := filepath.Join(checkpointDir, "verify.json") + require.NoError(t, saveCheckpoint(checkpointPath, checkpoint{ + RunID: manifest.RunID, ManifestHash: manifestHash, Mode: "verify", + Frontier: records[1].Height, Height: records[1].Height, Changed: 2, + })) + objects[records[0].Key] = storedObject{ + Body: records[0].OldBody, ETag: records[0].OldETag, Headers: cloneObjectHeaders(records[0].Headers), + } + store := &fakeObjectStore{objects: objects} + + _, err = runVerify(context.Background(), manifestPath, checkpointDir, store) + require.ErrorContains(t, err, "storage is not canonical") + require.Equal(t, 1, store.gets[records[0].Key]) + persisted, err := loadCheckpoint(checkpointPath, manifest.RunID, manifestHash, "verify") + require.NoError(t, err) + require.Equal(t, records[1].Height, persisted.Frontier) + }) + + t.Run("counts the full current verification", func(t *testing.T) { + manifestPath, manifest, records, objects := makeSealedPlanRecords(t, 3) + for _, record := range records { + objects[record.Key] = storedObject{ + Body: record.NewBody, ETag: "etag-new", Headers: cloneObjectHeaders(record.Headers), + } + } + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + checkpointDir := runtimeArtifactPath(manifestPath, "verify-partial") + require.NoError(t, saveCheckpoint(filepath.Join(checkpointDir, "verify.json"), checkpoint{ + RunID: manifest.RunID, ManifestHash: manifestHash, Mode: "verify", + Frontier: records[1].Height, Height: records[1].Height, Changed: 2, + })) + store := &fakeObjectStore{objects: objects} + + report, err := runVerify(context.Background(), manifestPath, checkpointDir, store) + require.NoError(t, err) + require.Equal(t, manifest.Processed, report.Processed) + require.Equal(t, manifest.Changed, report.VerifiedChanged) + require.Equal(t, manifest.Changed, report.S3GETs) + for _, record := range records { + require.Equal(t, 1, store.gets[record.Key]) + } + }) +} + +func TestVerifyParallelUsesOneGETPerObjectAtTargetRate(t *testing.T) { + manifestPath, manifest, records, objects := makeSealedPlanRecords(t, 1000) + for _, record := range records { + objects[record.Key] = storedObject{ + Body: record.NewBody, ETag: "etag-new", Headers: cloneObjectHeaders(record.Headers), + } + } + store := &fakeObjectStore{objects: objects, getDelay: 20 * time.Millisecond} + options := defaultParallelOptions() + options.Concurrency = 64 + options.Window = 256 + report, err := runVerifyWithOptions( + context.Background(), manifestPath, runtimeArtifactPath(manifestPath, "verify-rate"), options, store, + ) + require.NoError(t, err) + require.Equal(t, manifest.Processed, report.Processed) + require.Equal(t, manifest.Changed, report.VerifiedChanged) + require.Equal(t, int64(1000), report.S3GETs) + t.Logf("verify rate %.2f objects/s", report.ObjectsPerSecond) + require.GreaterOrEqual(t, report.ObjectsPerSecond, float64(1000)) + total, maximum, counts := store.getStats() + require.Equal(t, 1000, total) + require.GreaterOrEqual(t, maximum, 32) + require.LessOrEqual(t, maximum, options.Concurrency) + for _, record := range records { + require.Equal(t, 1, counts[record.Key]) + } +} + +func TestVerifyRejectsTargetMetadataDrift(t *testing.T) { + manifestPath, _, records, objects := makeSealedPlanRecords(t, 1) + record := records[0] + objects[record.Key] = storedObject{Body: record.NewBody, ETag: "etag-new", Headers: cloneObjectHeaders(record.Headers)} + object := objects[record.Key] + object.Headers.ContentType = "text/plain" + objects[record.Key] = object + store := &fakeObjectStore{objects: objects} + _, err := runVerify(context.Background(), manifestPath, runtimeArtifactPath(manifestPath, "verify-metadata"), store) + require.ErrorContains(t, err, "object metadata changed") +} + +func TestVerifyRejectsCheckpointChangedHeightMismatch(t *testing.T) { + manifestPath, manifest, records, objects := makeSealedPlanRecords(t, 16) + for _, record := range records { + objects[record.Key] = storedObject{Body: record.NewBody, ETag: "etag-new", Headers: cloneObjectHeaders(record.Headers)} + } + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + cpPath := filepath.Join(runtimeArtifactPath(manifestPath, "verify-invalid"), "verify.json") + require.NoError(t, saveCheckpoint(cpPath, checkpoint{ + RunID: manifest.RunID, ManifestHash: manifestHash, Mode: "verify", + Frontier: 10, Height: 10, Changed: 2, + })) + _, err = runVerify(context.Background(), manifestPath, filepath.Dir(cpPath), &fakeObjectStore{objects: objects}) + require.ErrorContains(t, err, "contains 9 changed records, not 2") +} + +func TestApplyParallelizesAtTwentyMillisecondObjectLatency(t *testing.T) { + manifestPath, _, _, objects := makeSealedPlanRecords(t, 1000) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + backup := writeBackupProof(t, manifestPath, manifestHash) + store := &fakeObjectStore{objects: objects, getDelay: 20 * time.Millisecond, putDelay: 20 * time.Millisecond} + options := defaultParallelOptions() + options.Concurrency = 96 + options.Window = 384 + report, err := runWriteModeWithOptions( + context.Background(), manifestPath, runtimeArtifactPath(manifestPath, "apply-rate.json"), backup, "apply", options, store, + ) + require.NoError(t, err) + require.Equal(t, int64(1000), report.CompletedThisRun) + require.Equal(t, int64(1000), report.PUTs) + t.Logf("apply rate %.2f objects/s", report.ObjectsPerSecond) + totalGETs, _, _ := store.getStats() + require.Zero(t, totalGETs) + puts, maximum := store.putStats() + require.Equal(t, 1000, puts) + require.GreaterOrEqual(t, maximum, 32) +} + +func TestConcurrentApplyCheckpointNeverCrossesFailureGap(t *testing.T) { + manifestPath, manifest, records, objects := makeSealedPlanRecords(t, 160) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + backup := writeBackupProof(t, manifestPath, manifestHash) + gate := make(chan struct{}) + boom := errors.New("blocked ordinal failed") + store := &fakeObjectStore{ + objects: objects, + putGates: map[string]<-chan struct{}{records[4].Key: gate}, + putErrors: map[string]error{records[4].Key: boom}, + } + checkpointPath := runtimeArtifactPath(manifestPath, "apply-concurrent.json") + options := defaultParallelOptions() + options.Concurrency = 8 + options.Window = 160 + options.CheckpointEvery = 160 + options.CheckpointInterval = time.Hour + type result struct { + report writeReport + err error + } + done := make(chan result, 1) + go func() { + report, runErr := runWriteModeWithOptions(context.Background(), manifestPath, checkpointPath, backup, "apply", options, store) + done <- result{report: report, err: runErr} + }() + require.Eventually(t, func() bool { return store.targetVisible(records[9]) }, 5*time.Second, 10*time.Millisecond) + close(gate) + failed := <-done + require.ErrorIs(t, failed.err, boom) + totalGETs, _, _ := store.getStats() + require.Zero(t, totalGETs) + + cp, err := loadCheckpoint(checkpointPath, manifest.RunID, manifestHash, "apply") + require.NoError(t, err) + require.Zero(t, cp.Frontier) + require.Zero(t, cp.Height) + require.Zero(t, failed.report.CheckpointFrontier) + journal, found, err := loadWriteJournal(writeJournalPath(checkpointPath), manifest.RunID, manifestHash, "apply") + require.NoError(t, err) + require.True(t, found) + require.Equal(t, writeJournalIssued, journal.State) + require.Equal(t, uint64(1), journal.Start) + require.Equal(t, uint64(160), journal.End) + require.Len(t, journal.Intents, 160) + require.Equal(t, "apply", journal.Intents[4].Operation) + require.Equal(t, records[4].Ordinal, journal.Intents[4].Ordinal) + require.Equal(t, records[4].Key, journal.Intents[4].Key) + require.Equal(t, records[4].OldETag, journal.Intents[4].IfMatch) + require.Equal(t, hex.EncodeToString(records[4].OldSHA256[:]), journal.Intents[4].OldSHA256) + require.Equal(t, hex.EncodeToString(records[4].NewSHA256[:]), journal.Intents[4].NewSHA256) + require.NotEmpty(t, journal.Results) + for _, result := range journal.Results { + require.NotEmpty(t, result.PostPUTETag) + require.Equal(t, hex.EncodeToString(records[result.Ordinal-1].NewSHA256[:]), result.ObservedSHA256) + } + + store.mu.Lock() + store.putGates = nil + store.putErrors = nil + store.mu.Unlock() + options.CheckpointEvery = 8 + options.MaxInFlightBytes = maxObjectOperationBytes + getsBefore, _, _ := store.getStats() + store.mu.Lock() + putsBefore := store.puts + store.mu.Unlock() + _, err = runWriteModeWithOptions(context.Background(), manifestPath, checkpointPath, backup, "apply", options, store) + require.ErrorContains(t, err, "write journal batch needs") + getsAfter, _, _ := store.getStats() + require.Equal(t, getsBefore, getsAfter) + store.mu.Lock() + require.Equal(t, putsBefore, store.puts) + store.mu.Unlock() + + options.MaxInFlightBytes = defaultParallelOptions().MaxInFlightBytes + resumed, err := runWriteModeWithOptions(context.Background(), manifestPath, checkpointPath, backup, "apply", options, store) + require.NoError(t, err) + require.Equal(t, manifest.Changed, resumed.CheckpointFrontier) + require.Positive(t, resumed.AlreadyTarget) + for _, record := range records { + require.True(t, store.targetVisible(record), "height %d", record.Height) + } + completed, err := loadCheckpoint(checkpointPath, manifest.RunID, manifestHash, "apply") + require.NoError(t, err) + require.Greater(t, completed.PUTAttempts, uint64(manifest.Changed)) + require.Equal(t, uint64(manifest.Changed), completed.ConfirmedPUTs) +} + +func TestApplyRecoveryRejectsThirdContentBeforeRetryingIssuedBatch(t *testing.T) { + manifestPath, manifest, records, objects := makeSealedPlanRecords(t, 8) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + backup := writeBackupProof(t, manifestPath, manifestHash) + gate := make(chan struct{}) + boom := errors.New("interrupted PUT") + store := &fakeObjectStore{ + objects: objects, putGates: map[string]<-chan struct{}{records[0].Key: gate}, + putErrors: map[string]error{records[0].Key: boom}, + } + checkpointPath := runtimeArtifactPath(manifestPath, "apply-third-recovery.json") + options := defaultParallelOptions() + options.Concurrency, options.Window, options.CheckpointEvery = 8, 8, 8 + done := make(chan error, 1) + go func() { + _, runErr := runWriteModeWithOptions(context.Background(), manifestPath, checkpointPath, backup, "apply", options, store) + done <- runErr + }() + require.Eventually(t, func() bool { return store.targetVisible(records[3]) }, 5*time.Second, 10*time.Millisecond) + close(gate) + require.ErrorIs(t, <-done, boom) + + store.mu.Lock() + store.putGates = nil + store.putErrors = nil + store.objects[records[0].Key] = storedObject{Body: []byte("third"), ETag: "third"} + putsBeforeResume := store.puts + store.mu.Unlock() + _, err = runWriteModeWithOptions(context.Background(), manifestPath, checkpointPath, backup, "apply", options, store) + require.ErrorContains(t, err, "third-party content") + store.mu.Lock() + require.Equal(t, putsBeforeResume, store.puts) + store.mu.Unlock() + cp, err := loadCheckpoint(checkpointPath, manifest.RunID, manifestHash, "apply") + require.NoError(t, err) + require.Zero(t, cp.Frontier) + _, found, err := loadWriteJournal(writeJournalPath(checkpointPath), manifest.RunID, manifestHash, "apply") + require.NoError(t, err) + require.True(t, found) +} + +func TestWriteRecoveryRejectsSourceAfterPersistedPUTOutcome(t *testing.T) { + for _, outcome := range []string{"confirmed", "uncertain"} { + t.Run(outcome, func(t *testing.T) { + manifestPath, manifest, record, oldObject := makeSealedPlan(t) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + backup := writeBackupProof(t, manifestPath, manifestHash) + checkpointPath := runtimeArtifactPath(manifestPath, "apply-"+outcome+"-source.json") + journal := newIssuedWriteJournal(manifest, manifestHash, applyMode, []preparedWrite{{ + record: record, needsPUT: true, ifMatch: record.OldETag, + }}) + journal.Intents[0].PUTAttempts = 1 + if outcome == "confirmed" { + journal.Intents[0].ConfirmedPUTs = 1 + } else { + journal.Intents[0].UncertainPUTs = 1 + } + require.NoError(t, saveWriteJournal(writeJournalPath(checkpointPath), journal)) + + store := &fakeObjectStore{objects: map[string]storedObject{record.Key: oldObject}} + _, err = runWriteMode(context.Background(), manifestPath, checkpointPath, backup, applyMode, store) + require.ErrorContains(t, err, "persisted PUT outcome") + require.Zero(t, store.puts) + }) + } +} + +func TestWriteModeRejectsConcurrentWorkDirectoryLockBeforeS3(t *testing.T) { + manifestPath, _, record, object := makeSealedPlan(t) + workDir := t.TempDir() + locks, err := acquireStagingLocks(filepath.Join(workDir, "statediff-rewriter-write")) + require.NoError(t, err) + defer func() { require.NoError(t, releaseStagingLocks(locks)) }() + store := &fakeObjectStore{objects: map[string]storedObject{record.Key: object}} + _, err = runWriteMode( + context.Background(), manifestPath, filepath.Join(workDir, "rollback.json"), "", rollbackMode, store, + ) + require.ErrorContains(t, err, "acquire staging lock") + total, _, _ := store.getStats() + require.Zero(t, total) + require.Zero(t, store.puts) +} + +func TestWriteModeRejectsObservedJournalBehindCheckpoint(t *testing.T) { + manifestPath, manifest, records, objects := makeSealedPlanRecords(t, 3) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + checkpointPath := filepath.Join(t.TempDir(), "rollback.json") + require.NoError(t, saveCheckpoint(checkpointPath, checkpoint{ + RunID: manifest.RunID, ManifestHash: manifestHash, Mode: "rollback", + Frontier: 3, Height: records[2].Height, AlreadyTarget: 3, + })) + journal := newIssuedWriteJournal(manifest, manifestHash, "rollback", []preparedWrite{{ + record: records[0], needsPUT: true, ifMatch: "etag-new", + }}) + journal.State = writeJournalObserved + journal.Intents[0].PUTAttempts = 1 + journal.Intents[0].ConfirmedPUTs = 1 + journal.Results = []writeObservedResult{{ + Ordinal: records[0].Ordinal, ObservedSHA256: hex.EncodeToString(records[0].OldSHA256[:]), + PostPUTETag: "etag-old", Outcome: "confirmed", + }} + require.NoError(t, saveWriteJournal(writeJournalPath(checkpointPath), journal)) + + _, err = runWriteModeWithOptions( + context.Background(), manifestPath, checkpointPath, "", "rollback", defaultParallelOptions(), + &fakeObjectStore{objects: objects}, + ) + require.ErrorContains(t, err, "behind checkpoint") +} + +func TestConcurrentRollbackRecoversIssuedBatchSymmetrically(t *testing.T) { + manifestPath, manifest, records, objects := makeSealedPlanRecords(t, 16) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + backup := writeBackupProof(t, manifestPath, manifestHash) + store := &fakeObjectStore{objects: objects} + _, err = runWriteMode( + context.Background(), manifestPath, runtimeArtifactPath(manifestPath, "apply-before-rollback.json"), backup, "apply", store, + ) + require.NoError(t, err) + + gate := make(chan struct{}) + boom := errors.New("interrupted rollback PUT") + store.mu.Lock() + store.putGates = map[string]<-chan struct{}{records[4].Key: gate} + store.putErrors = map[string]error{records[4].Key: boom} + store.mu.Unlock() + checkpointPath := runtimeArtifactPath(manifestPath, "rollback-concurrent.json") + options := defaultParallelOptions() + options.Concurrency, options.Window, options.CheckpointEvery = 8, 16, 16 + done := make(chan error, 1) + go func() { + _, runErr := runWriteModeWithOptions(context.Background(), manifestPath, checkpointPath, "", "rollback", options, store) + done <- runErr + }() + require.Eventually(t, func() bool { return store.oldVisible(records[9]) }, 5*time.Second, 10*time.Millisecond) + close(gate) + require.ErrorIs(t, <-done, boom) + + cp, err := loadCheckpoint(checkpointPath, manifest.RunID, manifestHash, "rollback") + require.NoError(t, err) + require.Zero(t, cp.Frontier) + journal, found, err := loadWriteJournal(writeJournalPath(checkpointPath), manifest.RunID, manifestHash, "rollback") + require.NoError(t, err) + require.True(t, found) + require.Equal(t, writeJournalIssued, journal.State) + require.Equal(t, "rollback", journal.Intents[4].Operation) + require.NotEqual(t, records[4].OldETag, journal.Intents[4].IfMatch) + + store.mu.Lock() + store.putGates = nil + store.putErrors = nil + store.mu.Unlock() + report, err := runWriteModeWithOptions(context.Background(), manifestPath, checkpointPath, "", "rollback", options, store) + require.NoError(t, err) + require.Equal(t, manifest.Changed, report.CheckpointFrontier) + require.Positive(t, report.AlreadyTarget) + for _, record := range records { + require.True(t, store.oldVisible(record), "height %d", record.Height) + } +} + +func TestApplyConditionalConflictAcceptsReconciledTarget(t *testing.T) { + manifestPath, manifest, record, oldObject := makeSealedPlan(t) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + backup := writeBackupProof(t, manifestPath, manifestHash) + store := &fakeObjectStore{ + objects: map[string]storedObject{record.Key: oldObject}, + putErrors: map[string]error{record.Key: errObjectConflict}, + commitOnPutError: map[string]bool{record.Key: true}, + } + checkpointPath := runtimeArtifactPath(manifestPath, "apply-conflict-target.json") + report, err := runWriteMode(context.Background(), manifestPath, checkpointPath, backup, "apply", store) + require.NoError(t, err) + require.True(t, store.targetVisible(record)) + require.Zero(t, report.UncertainPUTs) + require.Equal(t, int64(1), report.AlreadyTarget) + require.Equal(t, int64(1), report.Conflicts) + require.Equal(t, uint64(1), report.CumulativeConflicts) + require.Equal(t, int64(1), report.CompletedThisRun) + totalGETs, _, _ := store.getStats() + require.Equal(t, 1, totalGETs) + _, statErr := os.Stat(checkpointPath) + require.NoError(t, statErr) + checkpoint, err := loadCheckpoint(checkpointPath, manifest.RunID, manifestHash, applyMode) + require.NoError(t, err) + require.Equal(t, uint64(1), checkpoint.AlreadyTarget) + require.Zero(t, checkpoint.UncertainPUTs) + require.Equal(t, uint64(1), checkpoint.Conflicts) + + terminalReport, err := runWriteMode(context.Background(), manifestPath, checkpointPath, backup, applyMode, store) + require.ErrorContains(t, err, "checkpoint is already complete") + require.Equal(t, int64(1), terminalReport.CheckpointFrontier) + require.Equal(t, uint64(1), terminalReport.CumulativeConflicts) + totalGETsAfterTerminalResume, _, _ := store.getStats() + require.Equal(t, totalGETs, totalGETsAfterTerminalResume) +} + +func TestApplyRecoversDurableConditionalConflictAudit(t *testing.T) { + manifestPath, manifest, record, oldObject := makeSealedPlan(t) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + backup := writeBackupProof(t, manifestPath, manifestHash) + checkpointPath := runtimeArtifactPath(manifestPath, "apply-conflict-recovery.json") + + journal := newIssuedWriteJournal(manifest, manifestHash, applyMode, []preparedWrite{{ + record: record, needsPUT: true, ifMatch: record.OldETag, + }}) + journal.State = writeJournalObserved + journal.Intents[0].PUTAttempts = 1 + journal.Intents[0].Conflicts = 1 + journal.Results = []writeObservedResult{{ + Ordinal: record.Ordinal, ObservedSHA256: hex.EncodeToString(record.NewSHA256[:]), + PostPUTETag: "etag-concurrent-writer", Outcome: writeOutcomeReconciled, + }} + require.NoError(t, saveWriteJournal(writeJournalPath(checkpointPath), journal)) + oldObject.Body = append([]byte(nil), record.NewBody...) + oldObject.ETag = "etag-concurrent-writer" + store := &fakeObjectStore{objects: map[string]storedObject{record.Key: oldObject}} + + report, err := runWriteMode(context.Background(), manifestPath, checkpointPath, backup, applyMode, store) + require.NoError(t, err) + require.Zero(t, store.puts) + require.Zero(t, report.Conflicts) + require.Equal(t, uint64(1), report.CumulativeConflicts) + require.Equal(t, int64(1), report.CheckpointFrontier) + + cp, err := loadCheckpoint(checkpointPath, manifest.RunID, manifestHash, applyMode) + require.NoError(t, err) + require.Equal(t, uint64(1), cp.Conflicts) + require.Equal(t, uint64(1), cp.AlreadyTarget) +} + +func TestApplyStopsOnUnresolvedSourceAndResumesSafely(t *testing.T) { + for name, putErr := range map[string]error{ + "conditional conflict": errObjectConflict, + "uncertain response": errObjectWriteUncertain, + } { + t.Run(name, func(t *testing.T) { + manifestPath, manifest, record, oldObject := makeSealedPlan(t) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + backup := writeBackupProof(t, manifestPath, manifestHash) + checkpointPath := runtimeArtifactPath(manifestPath, "apply-source-retry.json") + store := &fakeObjectStore{ + objects: map[string]storedObject{record.Key: oldObject}, + putErrors: map[string]error{record.Key: putErr}, + } + + report, err := runWriteMode(context.Background(), manifestPath, checkpointPath, backup, applyMode, store) + require.ErrorContains(t, err, "left source content") + require.Zero(t, report.CheckpointFrontier) + require.Zero(t, report.CompletedThisRun) + if errors.Is(putErr, errObjectConflict) { + require.Equal(t, int64(1), report.Conflicts) + } else { + require.Zero(t, report.Conflicts) + } + totalGETs, _, _ := store.getStats() + require.Equal(t, 1, totalGETs) + journal, found, err := loadWriteJournal( + writeJournalPath(checkpointPath), manifest.RunID, manifestHash, applyMode, + ) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, writeJournalIssued, journal.State) + require.Equal(t, uint64(1), journal.Intents[0].PUTAttempts) + require.Zero(t, journal.Intents[0].ConfirmedPUTs) + require.Zero(t, journal.Intents[0].UncertainPUTs) + if errors.Is(putErr, errObjectConflict) { + require.Equal(t, uint64(1), journal.Intents[0].Conflicts) + } else { + require.Zero(t, journal.Intents[0].Conflicts) + } + + store.mu.Lock() + store.putErrors = nil + store.mu.Unlock() + report, err = runWriteMode(context.Background(), manifestPath, checkpointPath, backup, applyMode, store) + require.NoError(t, err) + require.Equal(t, manifest.Changed, report.CheckpointFrontier) + require.True(t, store.targetVisible(record)) + if errors.Is(putErr, errObjectConflict) { + require.Equal(t, uint64(1), report.CumulativeConflicts) + } else { + require.Zero(t, report.CumulativeConflicts) + } + totalGETs, _, _ = store.getStats() + require.Equal(t, 2, totalGETs) + }) + } +} + +func TestApplyRestoresSealedMetadataWithoutPreGET(t *testing.T) { + manifestPath, _, record, oldObject := makeSealedPlan(t) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + backup := writeBackupProof(t, manifestPath, manifestHash) + oldObject.Headers.ContentType = "changed/type" + store := &fakeObjectStore{objects: map[string]storedObject{record.Key: oldObject}} + _, err = runWriteMode(context.Background(), manifestPath, runtimeArtifactPath(manifestPath, "metadata.json"), backup, "apply", store) + require.NoError(t, err) + require.True(t, store.targetVisible(record)) + totalGETs, _, _ := store.getStats() + require.Zero(t, totalGETs) +} + +func TestApplyRejectsConflictAndThirdContent(t *testing.T) { + manifestPath, _, record, oldObject := makeSealedPlan(t) + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + backup := writeBackupProof(t, manifestPath, manifestHash) + + oldObject.ETag = "different-etag" + store := &fakeObjectStore{objects: map[string]storedObject{record.Key: oldObject}} + _, err = runWriteMode(context.Background(), manifestPath, runtimeArtifactPath(manifestPath, "conflict.json"), backup, "apply", store) + require.ErrorContains(t, err, "source ETag changed") + + store.objects[record.Key] = storedObject{Body: []byte("third"), ETag: "third"} + _, err = runWriteMode(context.Background(), manifestPath, runtimeArtifactPath(manifestPath, "third.json"), backup, "apply", store) + require.ErrorContains(t, err, "third-party content") +} + +func TestCheckpointRejectsCorruptionAndBindingMismatch(t *testing.T) { + path := filepath.Join(t.TempDir(), "checkpoint.json") + require.NoError(t, os.WriteFile(path, []byte("{"), 0o600)) + _, err := loadCheckpoint(path, "run", "hash", "apply") + require.Error(t, err) + + require.NoError(t, saveCheckpoint(path, checkpoint{RunID: "other", ManifestHash: "hash", Mode: "apply"})) + _, err = loadCheckpoint(path, "run", "hash", "apply") + require.ErrorContains(t, err, "does not match") + + err = saveCheckpoint(path, checkpoint{ + RunID: "run", ManifestHash: "hash", Mode: "apply", Frontier: 1, Height: 2, + }) + require.ErrorContains(t, err, "audit counters are inconsistent") + err = saveCheckpoint(path, checkpoint{ + RunID: "run", ManifestHash: "hash", Mode: "apply", Frontier: 1, Height: 2, + PUTAttempts: 1, ConfirmedPUTs: 1, Conflicts: 2, + }) + require.ErrorContains(t, err, "audit counters are inconsistent") + loaded, err := loadCheckpoint(path, "other", "hash", "apply") + require.NoError(t, err) + require.Equal(t, "other", loaded.RunID) +} + +func TestCheckpointRejectsChecksumMutationUnknownFieldsAndTrailingData(t *testing.T) { + path := filepath.Join(t.TempDir(), "checkpoint.json") + want := checkpoint{ + RunID: "run", ManifestHash: "hash", Mode: "verify", + Frontier: 10, Height: 10, Changed: 4, + } + require.NoError(t, saveCheckpoint(path, want)) + loaded, err := loadCheckpoint(path, want.RunID, want.ManifestHash, want.Mode) + require.NoError(t, err) + require.Equal(t, want.Frontier, loaded.Frontier) + + body, err := os.ReadFile(path) + require.NoError(t, err) + var fields map[string]any + require.NoError(t, json.Unmarshal(body, &fields)) + fields["frontier"] = float64(11) + mutated, err := json.Marshal(fields) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, mutated, 0o600)) + _, err = loadCheckpoint(path, want.RunID, want.ManifestHash, want.Mode) + require.ErrorContains(t, err, "checksum mismatch") + + require.NoError(t, saveCheckpoint(path, want)) + body, err = os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &fields)) + fields["unexpected"] = true + withUnknown, err := json.Marshal(fields) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, withUnknown, 0o600)) + _, err = loadCheckpoint(path, want.RunID, want.ManifestHash, want.Mode) + require.ErrorContains(t, err, "unknown field") + + require.NoError(t, saveCheckpoint(path, want)) + file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) + require.NoError(t, err) + _, err = file.WriteString("{}\n") + require.NoError(t, err) + require.NoError(t, file.Close()) + _, err = loadCheckpoint(path, want.RunID, want.ManifestHash, want.Mode) + require.ErrorContains(t, err, "trailing JSON") +} + +func TestApplyPreflightsEntirePackBeforeFirstPUT(t *testing.T) { + manifestPath, manifest, _, objects := makeSealedPlanRecords(t, 3) + chunk := &manifest.Chunks[0] + indexPath := filepath.Join(filepath.Dir(manifestPath), chunk.Index) + index, err := os.OpenFile(indexPath, os.O_APPEND|os.O_WRONLY, 0) + require.NoError(t, err) + _, err = index.WriteString("{}\n") + require.NoError(t, err) + require.NoError(t, index.Close()) + chunk.IndexSHA256, _, err = hashFile(indexPath) + require.NoError(t, err) + _, err = atomicJSON(manifestPath, manifest) + require.NoError(t, err) + + _, manifestHash, err := loadPlanManifest(manifestPath) + require.NoError(t, err) + backup := writeBackupProof(t, manifestPath, manifestHash) + store := &fakeObjectStore{objects: objects} + _, err = runWriteMode( + context.Background(), manifestPath, runtimeArtifactPath(manifestPath, "apply-preflight.json"), backup, "apply", store, + ) + require.ErrorContains(t, err, "pack index") + require.Zero(t, store.puts) +} diff --git a/cmd/statediff-rewriter/pack.go b/cmd/statediff-rewriter/pack.go new file mode 100644 index 00000000..adebe5f8 --- /dev/null +++ b/cmd/statediff-rewriter/pack.go @@ -0,0 +1,534 @@ +package main + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + + "github.com/ethereum/go-ethereum/rlp" +) + +type indexRecord struct { + Ordinal uint64 `json:"ordinal"` + Height uint64 `json:"height"` + Offset int64 `json:"offset"` + Length uint64 `json:"length"` + Key string `json:"key"` + OldSHA256 string `json:"old_sha256"` + NewSHA256 string `json:"new_sha256"` + SlotsAdded uint64 `json:"slots_added"` + SlotsRemoved uint64 `json:"slots_removed"` + SlotsChanged uint64 `json:"slots_changed"` +} + +type packWriter struct { + ctx context.Context + dir string + maxSize int64 + chunk int + pack *os.File + index *os.File + packBuffer *bufio.Writer + indexBuffer *bufio.Writer + offset int64 + indexOffset int64 + records int64 + chunks []chunkManifest + finalized bool +} + +type packWriterState struct { + Chunk int `json:"chunk"` + PackOffset int64 `json:"pack_offset"` + IndexOffset int64 `json:"index_offset"` + Records int64 `json:"records"` + Chunks []chunkManifest `json:"chunks"` +} + +func newPackWriter(dir string, maxSize int64) (*packWriter, error) { + return newPackWriterContext(context.Background(), dir, maxSize) +} + +func newPackWriterContext(ctx context.Context, dir string, maxSize int64) (*packWriter, error) { + if ctx == nil { + return nil, fmt.Errorf("pack writer context is required") + } + if err := ctx.Err(); err != nil { + return nil, err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + return &packWriter{ctx: ctx, dir: dir, maxSize: maxSize}, nil +} + +func (w *packWriter) openChunk() error { + if w.pack != nil { + return nil + } + pack, err := os.OpenFile(filepath.Join(w.dir, fmt.Sprintf("chunk-%06d.pack.tmp", w.chunk)), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + index, err := os.OpenFile(filepath.Join(w.dir, fmt.Sprintf("chunk-%06d.idx.tmp", w.chunk)), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + _ = pack.Close() + return err + } + w.pack, w.index = pack, index + w.packBuffer, w.indexBuffer = bufio.NewWriterSize(pack, 1<<20), bufio.NewWriterSize(index, 1<<20) + w.offset, w.records = 0, 0 + w.indexOffset = 0 + return nil +} + +func (w *packWriter) Write(record packRecord) error { + if w.finalized { + return fmt.Errorf("pack writer is finalized") + } + body, err := rlp.EncodeToBytes(record) + if err != nil { + return err + } + framedSize := int64(8 + len(body)) + if w.pack != nil && w.records > 0 && w.offset+framedSize > w.maxSize { + if err := w.closeChunk(); err != nil { + return err + } + } + if err := w.openChunk(); err != nil { + return err + } + start := w.offset + var header [8]byte + binary.BigEndian.PutUint64(header[:], uint64(len(body))) + if _, err := w.packBuffer.Write(header[:]); err != nil { + return err + } + if _, err := w.packBuffer.Write(body); err != nil { + return err + } + entry := indexRecord{ + Ordinal: record.Ordinal, Height: record.Height, Offset: start, Length: uint64(framedSize), Key: record.Key, + OldSHA256: hex.EncodeToString(record.OldSHA256[:]), NewSHA256: hex.EncodeToString(record.NewSHA256[:]), + SlotsAdded: record.SlotsAdded, SlotsRemoved: record.SlotsRemoved, SlotsChanged: record.SlotsChanged, + } + indexBody, err := json.Marshal(entry) + if err != nil { + return err + } + if _, err := w.indexBuffer.Write(append(indexBody, '\n')); err != nil { + return err + } + w.offset += framedSize + w.indexOffset += int64(len(indexBody) + 1) + w.records++ + return nil +} + +func (w *packWriter) Sync() (packWriterState, error) { + if w.finalized { + return packWriterState{}, fmt.Errorf("pack writer is finalized") + } + if w.pack != nil { + if err := w.packBuffer.Flush(); err != nil { + return packWriterState{}, err + } + if err := w.indexBuffer.Flush(); err != nil { + return packWriterState{}, err + } + if err := w.pack.Sync(); err != nil { + return packWriterState{}, err + } + if err := w.index.Sync(); err != nil { + return packWriterState{}, err + } + } + return packWriterState{ + Chunk: w.chunk, PackOffset: w.offset, IndexOffset: w.indexOffset, Records: w.records, + Chunks: append([]chunkManifest(nil), w.chunks...), + }, nil +} + +func (w *packWriter) closeChunk() error { + if w.pack == nil { + return nil + } + if err := w.ctx.Err(); err != nil { + return err + } + if err := w.packBuffer.Flush(); err != nil { + return err + } + if err := w.indexBuffer.Flush(); err != nil { + return err + } + if err := w.pack.Sync(); err != nil { + return err + } + if err := w.index.Sync(); err != nil { + return err + } + if err := w.pack.Close(); err != nil { + return err + } + if err := w.index.Close(); err != nil { + return err + } + packTmp := filepath.Join(w.dir, fmt.Sprintf("chunk-%06d.pack.tmp", w.chunk)) + indexTmp := filepath.Join(w.dir, fmt.Sprintf("chunk-%06d.idx.tmp", w.chunk)) + packName := fmt.Sprintf("chunk-%06d.pack", w.chunk) + indexName := fmt.Sprintf("chunk-%06d.idx", w.chunk) + packPath := filepath.Join(w.dir, packName) + indexPath := filepath.Join(w.dir, indexName) + if err := commitFileNoReplace(packTmp, packPath); err != nil { + return err + } + if err := commitFileNoReplace(indexTmp, indexPath); err != nil { + return err + } + packHash, packSize, err := hashFileContext(w.ctx, packPath) + if err != nil { + return err + } + indexHash, _, err := hashFileContext(w.ctx, indexPath) + if err != nil { + return err + } + w.chunks = append(w.chunks, chunkManifest{ + Number: w.chunk, Pack: packName, PackSHA256: packHash, PackSize: packSize, + Index: indexName, IndexSHA256: indexHash, Records: w.records, + }) + w.chunk++ + w.pack, w.index = nil, nil + w.packBuffer, w.indexBuffer = nil, nil + w.offset, w.indexOffset, w.records = 0, 0, 0 + return syncDir(w.dir) +} + +func (w *packWriter) Close() ([]chunkManifest, error) { + if w.finalized { + return nil, fmt.Errorf("pack writer already finalized") + } + if err := w.closeChunk(); err != nil { + return nil, err + } + w.finalized = true + return append([]chunkManifest(nil), w.chunks...), nil +} + +func (w *packWriter) Abort() error { + if w.finalized { + return nil + } + w.finalized = true + var errs []error + if w.packBuffer != nil { + errs = append(errs, w.packBuffer.Flush()) + } + if w.indexBuffer != nil { + errs = append(errs, w.indexBuffer.Flush()) + } + if w.pack != nil { + errs = append(errs, w.pack.Close()) + } + if w.index != nil { + errs = append(errs, w.index.Close()) + } + return errors.Join(errs...) +} + +func resumePackWriter(dir string, maxSize int64, state packWriterState, manifest planManifest) (*packWriter, error) { + return resumePackWriterContext(context.Background(), dir, maxSize, state, manifest) +} + +func resumePackWriterContext(ctx context.Context, dir string, maxSize int64, state packWriterState, manifest planManifest) (*packWriter, error) { + if ctx == nil { + return nil, fmt.Errorf("resume pack writer context is required") + } + if err := ctx.Err(); err != nil { + return nil, err + } + if state.Chunk != len(state.Chunks) || state.Chunk < 0 || state.PackOffset < 0 || state.IndexOffset < 0 || state.Records < 0 { + return nil, fmt.Errorf("invalid pack writer checkpoint") + } + var completedRecords int64 + for number, chunk := range state.Chunks { + if chunk.Number != number || chunk.Records <= 0 { + return nil, fmt.Errorf("invalid completed chunk %d", number) + } + packPath := filepath.Join(dir, chunk.Pack) + indexPath := filepath.Join(dir, chunk.Index) + if err := requireRegularFile(packPath, fmt.Sprintf("completed pack chunk %d", number)); err != nil { + return nil, err + } + if err := requireRegularFile(indexPath, fmt.Sprintf("completed pack index %d", number)); err != nil { + return nil, err + } + packHash, packSize, err := hashFileContext(ctx, packPath) + if err != nil { + return nil, err + } + indexHash, _, err := hashFileContext(ctx, indexPath) + if err != nil { + return nil, err + } + if packHash != chunk.PackSHA256 || packSize != chunk.PackSize || indexHash != chunk.IndexSHA256 { + return nil, fmt.Errorf("completed pack chunk %d changed", number) + } + completedRecords += chunk.Records + } + if completedRecords+state.Records != manifest.Changed { + return nil, fmt.Errorf("pack checkpoint has %d records, manifest has %d", completedRecords+state.Records, manifest.Changed) + } + if state.Records > 0 { + type recovery struct { + partial string + final string + state noReplacePartialState + } + recoveries := make([]recovery, 0, 2) + for _, suffix := range []string{"pack", "idx"} { + tmpPath := filepath.Join(dir, fmt.Sprintf("chunk-%06d.%s.tmp", state.Chunk, suffix)) + finalPath := filepath.Join(dir, fmt.Sprintf("chunk-%06d.%s", state.Chunk, suffix)) + recoveryState, err := inspectNoReplacePartial(tmpPath, finalPath, "resumable pack artifact") + if err != nil { + return nil, err + } + if recoveryState == noReplaceMissing { + return nil, fmt.Errorf("resumable pack artifact is missing: %s", tmpPath) + } + recoveries = append(recoveries, recovery{partial: tmpPath, final: finalPath, state: recoveryState}) + } + for _, artifact := range recoveries { + if err := restoreNoReplacePartialState(artifact.partial, artifact.final, artifact.state); err != nil { + return nil, err + } + } + } + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + for _, entry := range entries { + number, suffix, ok := parseChunkArtifact(entry.Name()) + if !ok { + continue + } + if number < state.Chunk { + if strings.HasSuffix(suffix, ".tmp") { + return nil, fmt.Errorf("completed pack chunk %d has a stale partial artifact", number) + } + continue + } + keepCurrent := number == state.Chunk && state.Records > 0 && (suffix == ".pack.tmp" || suffix == ".idx.tmp") + if keepCurrent { + continue + } + if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil { + return nil, err + } + } + + writer := &packWriter{ + ctx: ctx, dir: dir, maxSize: maxSize, chunk: state.Chunk, offset: state.PackOffset, + indexOffset: state.IndexOffset, records: state.Records, + chunks: append([]chunkManifest(nil), state.Chunks...), + } + var packPath, indexPath string + if state.Records == 0 { + if state.PackOffset != 0 || state.IndexOffset != 0 { + return nil, fmt.Errorf("empty current pack chunk has non-zero offsets") + } + } else { + packPath = filepath.Join(dir, fmt.Sprintf("chunk-%06d.pack.tmp", state.Chunk)) + indexPath = filepath.Join(dir, fmt.Sprintf("chunk-%06d.idx.tmp", state.Chunk)) + if err := truncateCheckpointFile(packPath, state.PackOffset); err != nil { + return nil, err + } + if err := truncateCheckpointFile(indexPath, state.IndexOffset); err != nil { + return nil, err + } + } + validationManifest := manifest + validationManifest.Chunks = append([]chunkManifest(nil), state.Chunks...) + if state.Records > 0 { + validationManifest.Chunks = append(validationManifest.Chunks, chunkManifest{ + Number: state.Chunk, Pack: filepath.Base(packPath), Index: filepath.Base(indexPath), Records: state.Records, + }) + } + if err := validateResumablePackContext(ctx, dir, validationManifest); err != nil { + return nil, err + } + if state.Records == 0 { + return writer, nil + } + pack, err := os.OpenFile(packPath, os.O_WRONLY|os.O_APPEND|syscall.O_NOFOLLOW, 0o600) + if err != nil { + return nil, err + } + index, err := os.OpenFile(indexPath, os.O_WRONLY|os.O_APPEND|syscall.O_NOFOLLOW, 0o600) + if err != nil { + _ = pack.Close() + return nil, err + } + writer.pack, writer.index = pack, index + writer.packBuffer, writer.indexBuffer = bufio.NewWriterSize(pack, 1<<20), bufio.NewWriterSize(index, 1<<20) + return writer, nil +} + +func validateResumablePackContext(ctx context.Context, dir string, manifest planManifest) error { + if ctx == nil { + return fmt.Errorf("validate resumable pack context is required") + } + stream := newPackStream(dir, manifest) + var slotsAdded, slotsRemoved, slotsChanged uint64 + var oldBytes, newBytes, changedCanonical, changedConflict int64 + for { + if err := ctx.Err(); err != nil { + _ = stream.Close() + return err + } + record, ok, err := stream.Next() + if err != nil { + _ = stream.Close() + return fmt.Errorf("validate resumable pack: %w", err) + } + if !ok { + break + } + if slotsAdded+record.SlotsAdded < slotsAdded || slotsRemoved+record.SlotsRemoved < slotsRemoved || + slotsChanged+record.SlotsChanged < slotsChanged { + _ = stream.Close() + return fmt.Errorf("validate resumable pack: slot counters overflow") + } + slotsAdded += record.SlotsAdded + slotsRemoved += record.SlotsRemoved + slotsChanged += record.SlotsChanged + oldBytes += int64(len(record.OldBody)) + newBytes += int64(len(record.NewBody)) + if record.NoncanonicalOld { + changedCanonical++ + } + if record.ConflictingOld { + changedConflict++ + } + } + if err := stream.Close(); err != nil { + return err + } + if manifest.SlotsAdded != slotsAdded || manifest.SlotsRemoved != slotsRemoved || manifest.SlotsChanged != slotsChanged || + manifest.NewBytes != newBytes || manifest.ChangedCanonical != changedCanonical || manifest.ChangedConflict != changedConflict { + return fmt.Errorf("resumable pack statistics differ from checkpoint manifest") + } + if manifest.OldBytes < oldBytes { + return fmt.Errorf("resumable pack old bodies have %d bytes, checkpoint manifest has %d", oldBytes, manifest.OldBytes) + } + return nil +} + +func truncateCheckpointFile(path string, size int64) error { + if size < 0 { + return fmt.Errorf("checkpoint file %s has a negative truncate size", path) + } + file, err := os.OpenFile(path, os.O_WRONLY|syscall.O_NOFOLLOW, 0o600) + if err != nil { + return err + } + defer file.Close() + stat, err := file.Stat() + if err != nil { + return err + } + if !stat.Mode().IsRegular() || stat.Size() < size { + return fmt.Errorf("checkpoint file %s is shorter than %d", path, size) + } + identity, ok := stat.Sys().(*syscall.Stat_t) + if !ok || identity.Nlink != 1 { + return fmt.Errorf("checkpoint file %s must have exactly one link", path) + } + return file.Truncate(size) +} + +func parseChunkArtifact(name string) (int, string, bool) { + if !strings.HasPrefix(name, "chunk-") || len(name) < len("chunk-000000.pack") { + return 0, "", false + } + number, err := strconv.Atoi(name[len("chunk-") : len("chunk-")+6]) + if err != nil { + return 0, "", false + } + suffix := name[len("chunk-")+6:] + switch suffix { + case ".pack", ".idx", ".pack.tmp", ".idx.tmp": + return number, suffix, true + default: + return 0, "", false + } +} + +func hashFile(path string) (string, int64, error) { + return hashFileContext(context.Background(), path) +} + +func hashFileContext(ctx context.Context, path string) (string, int64, error) { + if ctx == nil { + return "", 0, fmt.Errorf("hash file context is required") + } + file, err := os.Open(path) + if err != nil { + return "", 0, err + } + defer file.Close() + hasher := sha256.New() + buffer := make([]byte, 1<<20) + var total int64 + for { + if err := ctx.Err(); err != nil { + return "", 0, err + } + n, readErr := file.Read(buffer) + if n > 0 { + if _, err := hasher.Write(buffer[:n]); err != nil { + return "", 0, err + } + total += int64(n) + } + if errors.Is(readErr, io.EOF) { + break + } + if readErr != nil { + return "", 0, readErr + } + } + return hex.EncodeToString(hasher.Sum(nil)), total, nil +} + +func iteratePack(dir string, manifest planManifest, fn func(packRecord) error) error { + stream := newPackStream(dir, manifest) + defer stream.Close() + for { + record, ok, err := stream.Next() + if err != nil { + return err + } + if !ok { + return nil + } + if err := fn(record); err != nil { + return err + } + } +} diff --git a/cmd/statediff-rewriter/pack_resume_test.go b/cmd/statediff-rewriter/pack_resume_test.go new file mode 100644 index 00000000..11b170b8 --- /dev/null +++ b/cmd/statediff-rewriter/pack_resume_test.go @@ -0,0 +1,246 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func manifestForResumableRecords(records []packRecord) planManifest { + manifest := planManifest{ + FirstHeight: int64(records[0].Height), FinalHeight: int64(records[len(records)-1].Height), + Changed: int64(len(records)), + } + for _, record := range records { + manifest.SlotsAdded += record.SlotsAdded + manifest.SlotsRemoved += record.SlotsRemoved + manifest.SlotsChanged += record.SlotsChanged + manifest.OldBytes += int64(len(record.OldBody)) + manifest.NewBytes += int64(len(record.NewBody)) + if record.NoncanonicalOld { + manifest.ChangedCanonical++ + } + if record.ConflictingOld { + manifest.ChangedConflict++ + } + } + return manifest +} + +func checkpointStatisticRecords(t *testing.T) []packRecord { + t.Helper() + _, _, records, _ := makeSealedPlanRecords(t, 2) + records[0].SlotsAdded, records[0].SlotsRemoved, records[0].SlotsChanged = 2, 3, 5 + records[0].NoncanonicalOld = true + records[1].SlotsAdded, records[1].SlotsRemoved, records[1].SlotsChanged = 7, 11, 13 + records[1].ConflictingOld = true + return records +} + +func TestTruncateCheckpointFileRejectsSymlink(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "outside") + require.NoError(t, os.WriteFile(target, []byte("keep"), 0o600)) + link := filepath.Join(base, "chunk.pack.tmp") + require.NoError(t, os.Symlink(target, link)) + + require.Error(t, truncateCheckpointFile(link, 0)) + body, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, []byte("keep"), body) +} + +func TestPackWriterResumeTruncatesUncheckpointedRecords(t *testing.T) { + _, _, records, _ := makeSealedPlanRecords(t, 10) + dir := t.TempDir() + writer, err := newPackWriter(dir, 1<<30) + require.NoError(t, err) + for _, record := range records[:5] { + require.NoError(t, writer.Write(record)) + } + state, err := writer.Sync() + require.NoError(t, err) + for _, record := range records[5:8] { + require.NoError(t, writer.Write(record)) + } + require.NoError(t, writer.Abort()) + + checkpointManifest := manifestForResumableRecords(records[:5]) + checkpointManifest.FinalHeight = 11 + resumed, err := resumePackWriter(dir, 1<<30, state, checkpointManifest) + require.NoError(t, err) + for _, record := range records[5:] { + require.NoError(t, resumed.Write(record)) + } + chunks, err := resumed.Close() + require.NoError(t, err) + + finalManifest := planManifest{FirstHeight: 2, FinalHeight: 11, Changed: 10, Chunks: chunks} + var heights []uint64 + require.NoError(t, iteratePack(dir, finalManifest, func(record packRecord) error { + heights = append(heights, record.Height) + return nil + })) + require.Equal(t, []uint64{2, 3, 4, 5, 6, 7, 8, 9, 10, 11}, heights) +} + +func TestPackWriterResumeRestoresChunkFinalizedAfterCheckpoint(t *testing.T) { + _, _, records, _ := makeSealedPlanRecords(t, 5) + dir := t.TempDir() + writer, err := newPackWriter(dir, 1<<30) + require.NoError(t, err) + for _, record := range records { + require.NoError(t, writer.Write(record)) + } + state, err := writer.Sync() + require.NoError(t, err) + _, err = writer.Close() + require.NoError(t, err) + + manifest := manifestForResumableRecords(records) + resumed, err := resumePackWriter(dir, 1<<30, state, manifest) + require.NoError(t, err) + chunks, err := resumed.Close() + require.NoError(t, err) + manifest.Chunks = chunks + var count int + require.NoError(t, iteratePack(dir, manifest, func(packRecord) error { + count++ + return nil + })) + require.Equal(t, 5, count) +} + +func TestPackWriterResumeRecoversNoReplaceLinkWindow(t *testing.T) { + _, _, records, _ := makeSealedPlanRecords(t, 2) + dir := t.TempDir() + writer, err := newPackWriter(dir, 1<<30) + require.NoError(t, err) + for _, record := range records { + require.NoError(t, writer.Write(record)) + } + state, err := writer.Sync() + require.NoError(t, err) + require.NoError(t, writer.Abort()) + for _, suffix := range []string{"pack", "idx"} { + partial := filepath.Join(dir, "chunk-000000."+suffix+".tmp") + final := filepath.Join(dir, "chunk-000000."+suffix) + require.NoError(t, os.Link(partial, final)) + } + resumed, err := resumePackWriter(dir, 1<<30, state, manifestForResumableRecords(records)) + require.NoError(t, err) + for _, suffix := range []string{"pack", "idx"} { + _, err := os.Lstat(filepath.Join(dir, "chunk-000000."+suffix)) + require.ErrorIs(t, err, os.ErrNotExist) + } + require.NoError(t, resumed.Abort()) +} + +func TestPackWriterResumeRejectsPairConflictBeforeMutation(t *testing.T) { + _, _, records, _ := makeSealedPlanRecords(t, 2) + dir := t.TempDir() + writer, err := newPackWriter(dir, 1<<30) + require.NoError(t, err) + for _, record := range records { + require.NoError(t, writer.Write(record)) + } + state, err := writer.Sync() + require.NoError(t, err) + require.NoError(t, writer.Abort()) + packPartial := filepath.Join(dir, "chunk-000000.pack.tmp") + packFinal := filepath.Join(dir, "chunk-000000.pack") + require.NoError(t, os.Link(packPartial, packFinal)) + indexFinal := filepath.Join(dir, "chunk-000000.idx") + require.NoError(t, os.WriteFile(indexFinal, []byte("conflict"), 0o600)) + + _, err = resumePackWriter(dir, 1<<30, state, manifestForResumableRecords(records)) + require.ErrorContains(t, err, "conflicting inodes") + packPartialInfo, err := os.Stat(packPartial) + require.NoError(t, err) + packFinalInfo, err := os.Stat(packFinal) + require.NoError(t, err) + require.True(t, os.SameFile(packPartialInfo, packFinalInfo)) +} + +func TestResumePackWriterHonorsCanceledContextBeforeRecovery(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := resumePackWriterContext(ctx, t.TempDir(), 1<<30, packWriterState{}, planManifest{}) + require.ErrorIs(t, err, context.Canceled) +} + +func TestPackWriterResumeValidatesCheckpointStatistics(t *testing.T) { + tests := []struct { + name string + mutate func(*planManifest) + }{ + {name: "slots added", mutate: func(manifest *planManifest) { manifest.SlotsAdded++ }}, + {name: "slots removed", mutate: func(manifest *planManifest) { manifest.SlotsRemoved++ }}, + {name: "slots changed", mutate: func(manifest *planManifest) { manifest.SlotsChanged++ }}, + {name: "new bytes", mutate: func(manifest *planManifest) { manifest.NewBytes++ }}, + {name: "noncanonical", mutate: func(manifest *planManifest) { manifest.ChangedCanonical++ }}, + {name: "conflict", mutate: func(manifest *planManifest) { manifest.ChangedConflict++ }}, + {name: "old bytes below changed bodies", mutate: func(manifest *planManifest) { manifest.OldBytes-- }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + records := checkpointStatisticRecords(t) + dir := t.TempDir() + writer, err := newPackWriter(dir, 1<<30) + require.NoError(t, err) + for _, record := range records { + require.NoError(t, writer.Write(record)) + } + state, err := writer.Sync() + require.NoError(t, err) + require.NoError(t, writer.Abort()) + manifest := manifestForResumableRecords(records) + test.mutate(&manifest) + + _, err = resumePackWriter(dir, 1<<30, state, manifest) + require.Error(t, err) + }) + } +} + +func TestPackWriterResumeAllowsOldBytesFromUnchangedObjects(t *testing.T) { + records := checkpointStatisticRecords(t) + dir := t.TempDir() + writer, err := newPackWriter(dir, 1<<30) + require.NoError(t, err) + for _, record := range records { + require.NoError(t, writer.Write(record)) + } + state, err := writer.Sync() + require.NoError(t, err) + require.NoError(t, writer.Abort()) + manifest := manifestForResumableRecords(records) + manifest.OldBytes += 123 + + resumed, err := resumePackWriter(dir, 1<<30, state, manifest) + require.NoError(t, err) + require.NoError(t, resumed.Abort()) +} + +func TestPackWriterResumeValidatesCompletedChunkStatistics(t *testing.T) { + records := checkpointStatisticRecords(t) + dir := t.TempDir() + writer, err := newPackWriter(dir, 1<<30) + require.NoError(t, err) + for _, record := range records { + require.NoError(t, writer.Write(record)) + } + require.NoError(t, writer.closeChunk()) + state, err := writer.Sync() + require.NoError(t, err) + require.Zero(t, state.Records) + require.NoError(t, writer.Abort()) + manifest := manifestForResumableRecords(records) + manifest.NewBytes++ + + _, err = resumePackWriter(dir, 1<<30, state, manifest) + require.ErrorContains(t, err, "statistics differ") +} diff --git a/cmd/statediff-rewriter/pipeline.go b/cmd/statediff-rewriter/pipeline.go new file mode 100644 index 00000000..b3301b45 --- /dev/null +++ b/cmd/statediff-rewriter/pipeline.go @@ -0,0 +1,299 @@ +package main + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "golang.org/x/sync/semaphore" +) + +const ( + defaultObjectConcurrency = 96 + defaultObjectWindow = 384 + defaultMaxInFlightBytes = int64(8 << 30) + defaultCheckpointEvery = uint64(4096) + defaultCheckpointInterval = time.Second + maximumObjectConcurrency = 4096 + maximumOrderedPipelineWindow = 1 << 20 + minimumOrderedPipelineWorkers = 1 + maxObjectOperationBytes = maxObjectSize*2 + (1 << 20) +) + +type parallelOptions struct { + Concurrency int + Window int + MaxInFlightBytes int64 + CheckpointEvery uint64 + CheckpointInterval time.Duration +} + +func defaultParallelOptions() parallelOptions { + return parallelOptions{ + Concurrency: defaultObjectConcurrency, Window: defaultObjectWindow, + MaxInFlightBytes: defaultMaxInFlightBytes, + CheckpointEvery: defaultCheckpointEvery, CheckpointInterval: defaultCheckpointInterval, + } +} + +func (options parallelOptions) validate(withCheckpoint bool) error { + if options.Concurrency < minimumOrderedPipelineWorkers || options.Concurrency > maximumObjectConcurrency { + return fmt.Errorf("concurrency must be between %d and %d", minimumOrderedPipelineWorkers, maximumObjectConcurrency) + } + if options.Window < options.Concurrency || options.Window > maximumOrderedPipelineWindow { + return fmt.Errorf("window must be between concurrency (%d) and %d", options.Concurrency, maximumOrderedPipelineWindow) + } + if options.MaxInFlightBytes < maxObjectOperationBytes { + return fmt.Errorf("max-inflight-bytes must be at least %d", maxObjectOperationBytes) + } + if withCheckpoint && (options.CheckpointEvery == 0 || options.CheckpointInterval <= 0) { + return fmt.Errorf("checkpoint interval and record count must be positive") + } + return nil +} + +type byteLimiter struct { + limit int64 + bytes *semaphore.Weighted +} + +func newByteLimiter(limit int64) (*byteLimiter, error) { + if limit < maxObjectOperationBytes { + return nil, fmt.Errorf("byte limit must be at least %d", maxObjectOperationBytes) + } + return &byteLimiter{limit: limit, bytes: semaphore.NewWeighted(limit)}, nil +} + +func (limiter *byteLimiter) Acquire(ctx context.Context, amount int64) error { + if amount <= 0 || amount > limiter.limit { + return fmt.Errorf("in-flight byte reservation %d is outside 1-%d", amount, limiter.limit) + } + return limiter.bytes.Acquire(ctx, amount) +} + +func (limiter *byteLimiter) Release(amount int64) { + limiter.bytes.Release(amount) +} + +func estimatedPackRecordBytes(record packRecord) int64 { + // The RLP frame and decoded record can coexist while iteratePack invokes its + // callback. Count the two object bodies twice and leave fixed room for the + // record, hashes, decoded metadata, and allocator overhead. + return int64(2*(len(record.OldBody)+len(record.NewBody))+ + len(record.Key)+len(record.OldETag)+len(record.Headers.Metadata)+ + len(record.Headers.CacheControl)+len(record.Headers.ContentDisposition)+ + len(record.Headers.ContentEncoding)+len(record.Headers.ContentLanguage)+ + len(record.Headers.ContentType)+len(record.Headers.WebsiteRedirect)) + (1 << 20) +} + +type orderedTask[T any] struct { + sequence uint64 + value T +} + +type orderedResult[T any] struct { + sequence uint64 + value T + err error +} + +type producerSummary struct { + count uint64 + err error +} + +// runOrderedPipeline executes work concurrently while committing successful results in +// contiguous sequence order. The producer must emit every sequence exactly once starting +// at firstSequence. The window bounds emitted-but-not-committed work, including reordering. +func runOrderedPipeline[Input, Output any]( + ctx context.Context, + firstSequence uint64, + workers int, + window int, + produce func(context.Context, func(uint64, Input) error) error, + work func(context.Context, Input) (Output, error), + commit func(uint64, Output) error, +) error { + if firstSequence == 0 { + return fmt.Errorf("first sequence must be positive") + } + if workers < 1 || workers > maximumObjectConcurrency { + return fmt.Errorf("workers must be between 1 and %d", maximumObjectConcurrency) + } + if window < workers || window > maximumOrderedPipelineWindow { + return fmt.Errorf("window must be between workers (%d) and %d", workers, maximumOrderedPipelineWindow) + } + + pipelineCtx, cancel := context.WithCancel(ctx) + defer cancel() + jobs := make(chan orderedTask[Input], workers) + results := make(chan orderedResult[Output], workers) + tokens := make(chan struct{}, window) + producerDone := make(chan producerSummary, 1) + + go func() { + expected := firstSequence + var count uint64 + emit := func(sequence uint64, value Input) error { + if sequence != expected { + return fmt.Errorf("producer emitted sequence %d, want %d", sequence, expected) + } + select { + case tokens <- struct{}{}: + case <-pipelineCtx.Done(): + return pipelineCtx.Err() + } + select { + case jobs <- orderedTask[Input]{sequence: sequence, value: value}: + expected++ + count++ + return nil + case <-pipelineCtx.Done(): + <-tokens + return pipelineCtx.Err() + } + } + err := produce(pipelineCtx, emit) + producerDone <- producerSummary{count: count, err: err} + close(jobs) + if err != nil { + cancel() + } + }() + + var workerGroup sync.WaitGroup + workerGroup.Add(workers) + for range workers { + go func() { + defer workerGroup.Done() + for task := range jobs { + value, err := work(pipelineCtx, task.value) + result := orderedResult[Output]{sequence: task.sequence, value: value, err: err} + if err != nil { + // Deliver the causal error before cancellation makes peers exit. + results <- result + cancel() + return + } + select { + case results <- result: + case <-pipelineCtx.Done(): + return + } + } + }() + } + go func() { + workerGroup.Wait() + close(results) + }() + + next := firstSequence + pending := make(map[uint64]Output, workers) + var causalErr error + for result := range results { + if result.err != nil { + if causalErr == nil || errors.Is(causalErr, context.Canceled) { + causalErr = fmt.Errorf("sequence %d: %w", result.sequence, result.err) + } + continue + } + if causalErr != nil { + continue + } + if result.sequence < next { + causalErr = fmt.Errorf("worker returned committed sequence %d before %d", result.sequence, next) + cancel() + continue + } + if _, exists := pending[result.sequence]; exists { + causalErr = fmt.Errorf("worker returned duplicate sequence %d", result.sequence) + cancel() + continue + } + pending[result.sequence] = result.value + for { + value, exists := pending[next] + if !exists { + break + } + if err := commit(next, value); err != nil { + causalErr = fmt.Errorf("commit sequence %d: %w", next, err) + cancel() + break + } + delete(pending, next) + <-tokens + next++ + } + } + + summary := <-producerDone + if summary.err != nil && !errors.Is(summary.err, context.Canceled) && + (causalErr == nil || errors.Is(causalErr, context.Canceled)) { + causalErr = summary.err + } + if causalErr != nil { + return causalErr + } + if err := ctx.Err(); err != nil { + return err + } + if next != firstSequence+summary.count { + return fmt.Errorf("pipeline committed through %d after producing %d records from %d", next-1, summary.count, firstSequence) + } + if len(pending) != 0 || len(tokens) != 0 { + return fmt.Errorf("pipeline finished with %d pending results and %d window tokens", len(pending), len(tokens)) + } + return nil +} + +type checkpointBatcher struct { + path string + checkpoint checkpoint + every uint64 + interval time.Duration + advances uint64 + dirty bool + lastPersist time.Time + now func() time.Time + save func(string, checkpoint) error +} + +func newCheckpointBatcher(path string, cp checkpoint, every uint64, interval time.Duration) (*checkpointBatcher, error) { + if every == 0 || interval <= 0 { + return nil, fmt.Errorf("checkpoint interval and record count must be positive") + } + return &checkpointBatcher{ + path: path, checkpoint: cp, every: every, interval: interval, + lastPersist: time.Now(), now: time.Now, save: saveCheckpoint, + }, nil +} + +func (batcher *checkpointBatcher) Advance(frontier, height uint64) error { + if frontier <= batcher.checkpoint.Frontier { + return fmt.Errorf("checkpoint frontier %d does not advance beyond %d", frontier, batcher.checkpoint.Frontier) + } + batcher.checkpoint.Frontier, batcher.checkpoint.Height = frontier, height + batcher.advances++ + batcher.dirty = true + if batcher.advances >= batcher.every || batcher.now().Sub(batcher.lastPersist) >= batcher.interval { + return batcher.Flush() + } + return nil +} + +func (batcher *checkpointBatcher) Flush() error { + if !batcher.dirty { + return nil + } + if err := batcher.save(batcher.path, batcher.checkpoint); err != nil { + return err + } + batcher.advances = 0 + batcher.dirty = false + batcher.lastPersist = batcher.now() + return nil +} diff --git a/cmd/statediff-rewriter/pipeline_test.go b/cmd/statediff-rewriter/pipeline_test.go new file mode 100644 index 00000000..d90d099b --- /dev/null +++ b/cmd/statediff-rewriter/pipeline_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestOrderedPipelineCommitsOutOfOrderWorkInSequence(t *testing.T) { + var committed []int + err := runOrderedPipeline(context.Background(), 1, 4, 8, + func(_ context.Context, emit func(uint64, int) error) error { + for sequence := uint64(1); sequence <= 20; sequence++ { + if err := emit(sequence, int(sequence)); err != nil { + return err + } + } + return nil + }, + func(_ context.Context, value int) (int, error) { + time.Sleep(time.Duration(20-value%5) * time.Millisecond) + return value * 2, nil + }, + func(sequence uint64, value int) error { + require.Equal(t, int(sequence)*2, value) + committed = append(committed, int(sequence)) + return nil + }, + ) + require.NoError(t, err) + require.Equal(t, []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}, committed) +} + +func TestOrderedPipelineStopsFrontierAtFailureGap(t *testing.T) { + boom := errors.New("boom") + var committed []uint64 + err := runOrderedPipeline(context.Background(), 1, 4, 8, + func(_ context.Context, emit func(uint64, int) error) error { + for sequence := uint64(1); sequence <= 20; sequence++ { + if err := emit(sequence, int(sequence)); err != nil { + return err + } + } + return nil + }, + func(_ context.Context, value int) (int, error) { + if value == 5 { + return 0, boom + } + return value, nil + }, + func(sequence uint64, _ int) error { + committed = append(committed, sequence) + return nil + }, + ) + require.ErrorIs(t, err, boom) + for i, sequence := range committed { + require.Equal(t, uint64(i+1), sequence) + require.Less(t, sequence, uint64(5)) + } +} + +func TestOrderedPipelineBoundsUncommittedWindow(t *testing.T) { + const window = 7 + var active, maximum atomic.Int64 + gate := make(chan struct{}) + started := make(chan struct{}) + var once sync.Once + done := make(chan error, 1) + go func() { + done <- runOrderedPipeline(context.Background(), 1, 4, window, + func(_ context.Context, emit func(uint64, int) error) error { + for sequence := uint64(1); sequence <= 50; sequence++ { + if err := emit(sequence, int(sequence)); err != nil { + return err + } + } + return nil + }, + func(_ context.Context, value int) (int, error) { + current := active.Add(1) + for { + previous := maximum.Load() + if current <= previous || maximum.CompareAndSwap(previous, current) { + break + } + } + once.Do(func() { close(started) }) + <-gate + active.Add(-1) + return value, nil + }, + func(uint64, int) error { return nil }, + ) + }() + <-started + time.Sleep(25 * time.Millisecond) + require.LessOrEqual(t, maximum.Load(), int64(4)) + close(gate) + require.NoError(t, <-done) +} + +func TestOrderedPipelineRejectsProducerGap(t *testing.T) { + err := runOrderedPipeline(context.Background(), 10, 1, 1, + func(_ context.Context, emit func(uint64, int) error) error { + return emit(11, 1) + }, + func(_ context.Context, value int) (int, error) { return value, nil }, + func(uint64, int) error { return nil }, + ) + require.ErrorContains(t, err, "emitted sequence 11, want 10") +} + +func TestCheckpointBatcherPersistsByCountTimeAndFinalFlush(t *testing.T) { + now := time.Unix(100, 0) + var saved []checkpoint + batcher, err := newCheckpointBatcher("checkpoint", checkpoint{RunID: "run"}, 3, time.Second) + require.NoError(t, err) + batcher.now = func() time.Time { return now } + batcher.lastPersist = now + batcher.save = func(_ string, cp checkpoint) error { + saved = append(saved, cp) + return nil + } + + require.NoError(t, batcher.Advance(1, 10)) + require.NoError(t, batcher.Advance(2, 11)) + require.Empty(t, saved) + require.NoError(t, batcher.Advance(3, 12)) + require.Equal(t, []checkpoint{{RunID: "run", Frontier: 3, Height: 12}}, saved) + + now = now.Add(time.Second) + require.NoError(t, batcher.Advance(4, 13)) + require.Len(t, saved, 2) + require.Equal(t, uint64(4), saved[1].Frontier) + + require.NoError(t, batcher.Advance(5, 14)) + require.NoError(t, batcher.Flush()) + require.Len(t, saved, 3) + require.Equal(t, uint64(5), saved[2].Frontier) + require.NoError(t, batcher.Flush()) + require.Len(t, saved, 3) +} diff --git a/cmd/statediff-rewriter/plan.go b/cmd/statediff-rewriter/plan.go new file mode 100644 index 00000000..66ee5416 --- /dev/null +++ b/cmd/statediff-rewriter/plan.go @@ -0,0 +1,964 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime/debug" + "strings" + "syscall" + "time" + + "github.com/cosmos/iavl" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rlp" + "github.com/evmos/ethermint/debank/statediff" + dtypes "github.com/evmos/ethermint/debank/types" + + storetypes "cosmossdk.io/store/types" + + "github.com/cosmos/cosmos-sdk/version" +) + +type planOptions struct { + DumpStaging string + Direct bool + IAVLCacheSize int + IAVLConcurrency int + ArchiveHome string + Output string + Bucket string + Prefix string + Region string + MinFree uint64 + SnapshotID string + ImageDigest string + Pilot bool + PilotFirstHeight int64 + PilotFinalHeight int64 + Parallel parallelOptions +} + +const ( + planBufferedWriteReserve = uint64(3 << 20) + planInitialWriteReserve = uint64(4 << 20) + directTraversalChunkSize = int64(100_000) +) + +type planRange struct { + FirstHeight int64 + FinalHeight int64 + DumpFirstVersion int64 + ManifestSchema string + DumpSchema string +} + +type archiveRootCursor struct { + reader commitInfoReader + nextHeight int64 + previous common.Hash + started bool +} + +func newArchiveRootCursor(reader commitInfoReader, firstHeight int64) (*archiveRootCursor, error) { + if firstHeight < 2 { + return nil, fmt.Errorf("first block height must be at least 2") + } + return &archiveRootCursor{reader: reader, nextHeight: firstHeight}, nil +} + +func (cursor *archiveRootCursor) Next(height int64) (common.Hash, common.Hash, error) { + if height != cursor.nextHeight { + return common.Hash{}, common.Hash{}, fmt.Errorf("root cursor got height %d, want %d", height, cursor.nextHeight) + } + rootInfo, err := cursor.reader.commitInfo(height - 1) + if err != nil { + return common.Hash{}, common.Hash{}, err + } + root := common.BytesToHash(rootInfo.Hash()) + parent := cursor.previous + if !cursor.started && height > 2 { + parentInfo, err := cursor.reader.commitInfo(height - 2) + if err != nil { + return common.Hash{}, common.Hash{}, err + } + parent = common.BytesToHash(parentInfo.Hash()) + } + cursor.previous = root + cursor.nextHeight++ + cursor.started = true + return root, parent, nil +} + +type planTask struct { + height uint64 + root common.Hash + parent common.Hash + key string + canonical []dtypes.AccountStorageDiff + prefixSHA common.Hash + bytes int64 +} + +type planOutcome struct { + height uint64 + root common.Hash + prefixSHA common.Hash + skipped bool + changed bool + oldBytes int64 + record packRecord + bytes int64 +} + +type planPrefixRecord struct { + Previous common.Hash + Height uint64 + Root common.Hash + Parent common.Hash + CanonicalSHA common.Hash +} + +func resolvePlanRange(options planOptions, latestVersion int64) (planRange, error) { + if latestVersion < 2 { + return planRange{}, fmt.Errorf("archive latest version %d is below block 2", latestVersion) + } + if !options.Pilot { + if options.PilotFirstHeight != 0 || options.PilotFinalHeight != 0 { + return planRange{}, fmt.Errorf("pilot heights require explicit pilot mode") + } + return planRange{ + FirstHeight: 2, FinalHeight: latestVersion, DumpFirstVersion: 1, + ManifestSchema: manifestSchema, DumpSchema: dumpManifestSchema, + }, nil + } + if options.PilotFirstHeight < 2 || options.PilotFinalHeight < options.PilotFirstHeight || options.PilotFinalHeight > latestVersion { + return planRange{}, fmt.Errorf("invalid pilot range %d-%d for archive latest %d", options.PilotFirstHeight, options.PilotFinalHeight, latestVersion) + } + return planRange{ + FirstHeight: options.PilotFirstHeight, FinalHeight: options.PilotFinalHeight, + DumpFirstVersion: options.PilotFirstHeight, ManifestSchema: pilotManifestSchema, DumpSchema: pilotDumpManifestSchema, + }, nil +} + +func iterateDirectStateChangesContext( + ctx context.Context, + source stateChangeSource, + first, last int64, + callback func(int64, *iavl.ChangeSet) error, +) error { + if ctx == nil || source == nil || callback == nil { + return fmt.Errorf("direct traversal context, source, and callback are required") + } + if first < 2 || last < first { + return fmt.Errorf("invalid direct traversal range %d-%d", first, last) + } + for chunkFirst := first; ; { + chunkLast := chunkFirst + directTraversalChunkSize - 1 + if chunkLast < chunkFirst || chunkLast > last { + chunkLast = last + } + expected := chunkFirst + err := traverseVerifiedStateChanges(source, chunkFirst, chunkLast, func(version int64, changeSet *iavl.ChangeSet) error { + if err := ctx.Err(); err != nil { + return err + } + if version != expected { + return fmt.Errorf("direct traversal emitted version %d, want %d", version, expected) + } + if changeSet == nil { + return fmt.Errorf("direct traversal emitted nil changeset at version %d", version) + } + expected++ + return callback(version, changeSet) + }) + if err != nil { + return fmt.Errorf("direct traversal versions %d-%d: %w", chunkFirst, chunkLast, err) + } + if expected != chunkLast+1 { + return fmt.Errorf("direct traversal ended at version %d, want %d", expected-1, chunkLast) + } + if chunkLast == last { + return nil + } + chunkFirst = chunkLast + 1 + } +} + +func runPlan(ctx context.Context, options planOptions, objects objectStore) (planManifest, string, error) { + if err := options.Parallel.validate(true); err != nil { + return planManifest{}, "", err + } + if options.MinFree == 0 { + return planManifest{}, "", fmt.Errorf("min-free-bytes must be greater than zero") + } + if options.Parallel.MaxInFlightBytes < maxObjectOperationBytes+maxObjectSize { + return planManifest{}, "", fmt.Errorf("plan max-inflight-bytes must be at least %d", maxObjectOperationBytes+maxObjectSize) + } + if options.Direct { + if options.DumpStaging != "" { + return planManifest{}, "", fmt.Errorf("direct and dump sources are mutually exclusive") + } + if options.IAVLCacheSize < 0 { + return planManifest{}, "", fmt.Errorf("iavl-cache-size must not be negative") + } + if options.IAVLConcurrency < 1 || options.IAVLConcurrency > maximumDirectIAVLConcurrency { + return planManifest{}, "", fmt.Errorf("iavl-concurrency must be between 1 and %d", maximumDirectIAVLConcurrency) + } + if !filepath.IsAbs(options.ArchiveHome) { + return planManifest{}, "", fmt.Errorf("direct archive home must be absolute") + } + } else if options.DumpStaging == "" { + return planManifest{}, "", fmt.Errorf("exactly one plan source is required: --direct or --dump") + } + output, sealedPlan, err := resolvePlanOutput(options.Output) + if err != nil { + return planManifest{}, "", err + } + options.Output = output + lockPaths := []string{options.Output} + if !options.Direct { + dumpPath, err := canonicalDumpArgument(options.DumpStaging) + if err != nil { + return planManifest{}, "", err + } + options.DumpStaging = dumpPath + if strings.HasSuffix(options.DumpStaging, ".staging") { + lockPaths = append(lockPaths, options.DumpStaging) + } + } + locks, err := acquireStagingLocks(lockPaths...) + if err != nil { + return planManifest{}, "", err + } + defer func() { _ = releaseStagingLocks(locks) }() + if checkedOutput, checkedSealed, err := resolvePlanOutput(options.Output); err != nil { + return planManifest{}, "", err + } else if checkedOutput != options.Output || checkedSealed != sealedPlan { + return planManifest{}, "", fmt.Errorf("plan output identity changed while acquiring its staging lock") + } + archive, err := openArchive(options.ArchiveHome) + if err != nil { + return planManifest{}, "", err + } + defer archive.Close() + identity, err := archive.identity() + if err != nil { + return planManifest{}, "", err + } + scope, err := resolvePlanRange(options, identity.LatestVersion) + if err != nil { + return planManifest{}, "", err + } + cronosCommit, ethermintCommit, iavlCommit := buildCommits() + if err := requireRuntimeBuild(cronosCommit, ethermintCommit, iavlCommit, options.ImageDigest, version.BuildTags); err != nil { + return planManifest{}, "", err + } + var sealedDump, dumpHash string + var dumpInfo dumpManifest + sourceMode := planSourceDirectV1 + if !options.Direct { + sourceMode = planSourceDumpV1 + sealedDump, dumpInfo, dumpHash, err = prepareDumpContext(ctx, options.DumpStaging, dumpContext{ + Schema: scope.DumpSchema, FirstVersion: scope.DumpFirstVersion, LastVersion: scope.FinalHeight, + SnapshotID: options.SnapshotID, ArchiveIdentity: identity, CronosCommit: cronosCommit, + EthermintCommit: ethermintCommit, IAVLCommit: iavlCommit, + ImageDigest: options.ImageDigest, BuildTags: version.BuildTags, + }) + if err != nil { + return planManifest{}, "", err + } + } + expectedManifest := planManifest{ + Schema: scope.ManifestSchema, Sealed: true, + Bucket: options.Bucket, Prefix: strings.TrimSuffix(options.Prefix, "/"), Region: options.Region, + FirstHeight: scope.FirstHeight, FinalHeight: scope.FinalHeight, + CronosCommit: cronosCommit, EthermintCommit: ethermintCommit, IAVLCommit: iavlCommit, + SourceMode: sourceMode, DumpPath: sealedDump, DumpManifestHash: dumpHash, ArchiveIdentity: identity, + SnapshotID: options.SnapshotID, ImageDigest: options.ImageDigest, BuildTags: version.BuildTags, + } + if existing, manifestPath, found, err := reuseSealedPlanContext(ctx, sealedPlan, expectedManifest); err != nil { + return planManifest{}, "", err + } else if found { + return existing, manifestPath, nil + } + checkpointPath := filepath.Join(options.Output, "plan.checkpoint.json") + rootIndexPath := filepath.Join(options.Output, "roots.by-height.tmp") + var manifest planManifest + var writer *packWriter + var rootIndex *os.File + var resumeRootFile *os.File + var resumeRootReader *bufio.Reader + var resumeFrontier uint64 + var prefixDigest common.Hash + var freshOutput bool + outputInfo, outputErr := os.Lstat(options.Output) + switch { + case errors.Is(outputErr, os.ErrNotExist): + initialMinimum, addErr := checkedAddUint64(options.MinFree, planInitialWriteReserve, "initial plan free-space requirement") + if addErr != nil { + return planManifest{}, "", addErr + } + if err := ensureFreeSpace(filepath.Dir(options.Output), initialMinimum); err != nil { + return planManifest{}, "", fmt.Errorf("initialize plan staging: %w", err) + } + if objects == nil { + objects, err = newS3ObjectStore(ctx, options.Region, options.Parallel.Concurrency) + if err != nil { + return planManifest{}, "", err + } + } + if err := os.Mkdir(options.Output, 0o755); err != nil { + return planManifest{}, "", err + } + freshOutput = true + defer func() { + if freshOutput { + _ = os.RemoveAll(options.Output) + } + }() + now := time.Now().UTC() + manifest = expectedManifest + manifest.RunID = fmt.Sprintf("bugb-%d", now.UnixNano()) + manifest.CreatedAt = now.Format(time.RFC3339Nano) + writer, err = newPackWriterContext(ctx, options.Output, chunkSize) + if err != nil { + return planManifest{}, "", err + } + rootIndex, err = os.OpenFile(rootIndexPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + _ = writer.Abort() + return planManifest{}, "", err + } + case outputErr == nil: + if !outputInfo.IsDir() || outputInfo.Mode()&os.ModeSymlink != 0 { + return planManifest{}, "", fmt.Errorf("plan staging must be a non-symlink directory: %s", options.Output) + } + if err := requireRegularFile(checkpointPath, "plan checkpoint"); err != nil { + if errors.Is(err, os.ErrNotExist) { + return planManifest{}, "", fmt.Errorf("plan staging directory exists without a resumable checkpoint: %s", options.Output) + } + return planManifest{}, "", err + } + checkpoint, found, loadErr := loadPlanCheckpoint(checkpointPath, expectedManifest) + if loadErr != nil { + return planManifest{}, "", loadErr + } + if !found { + return planManifest{}, "", fmt.Errorf("plan staging directory exists without a resumable checkpoint: %s", options.Output) + } + manifest = checkpoint.Manifest + resumeFrontier = checkpoint.Frontier + prefixBytes, decodeErr := hex.DecodeString(checkpoint.PrefixSHA) + if decodeErr != nil || len(prefixBytes) != common.HashLength { + return planManifest{}, "", fmt.Errorf("decode plan checkpoint prefix hash") + } + copy(prefixDigest[:], prefixBytes) + rootIndexPath, err = restorePlanRootIndex(options.Output, checkpoint.RootBytes) + if err != nil { + return planManifest{}, "", err + } + resumeRootFile, err = os.Open(rootIndexPath) + if err != nil { + return planManifest{}, "", err + } + resumeRootReader = bufio.NewReaderSize(resumeRootFile, 1<<20) + writer, err = resumePackWriterContext(ctx, options.Output, chunkSize, checkpoint.Pack, manifest) + if err != nil { + _ = resumeRootFile.Close() + return planManifest{}, "", err + } + rootIndex, err = os.OpenFile(rootIndexPath, os.O_WRONLY|os.O_APPEND|syscall.O_NOFOLLOW, 0o600) + if err != nil { + _ = writer.Abort() + _ = resumeRootFile.Close() + return planManifest{}, "", err + } + default: + return planManifest{}, "", outputErr + } + writerOpen := true + defer func() { + if writerOpen { + _ = writer.Abort() + } + }() + rootOpen := true + defer func() { + if rootOpen { + _ = rootIndex.Close() + } + if resumeRootFile != nil { + _ = resumeRootFile.Close() + } + }() + rootBuffer := bufio.NewWriterSize(rootIndex, 1<<20) + rootCursor, err := newArchiveRootCursor(archive, scope.FirstHeight) + if err != nil { + _ = rootIndex.Close() + return planManifest{}, "", err + } + limiter, err := newByteLimiter(options.Parallel.MaxInFlightBytes) + if err != nil { + _ = rootIndex.Close() + return planManifest{}, "", err + } + saver, err := newPlanCheckpointSaver( + checkpointPath, rootBuffer, rootIndex, writer, options.MinFree, + options.Parallel.CheckpointEvery, options.Parallel.CheckpointInterval, + ) + if err != nil { + return planManifest{}, "", err + } + saver.prefixSHA = prefixDigest + if freshOutput { + if err := saver.Initialize(manifest); err != nil { + return planManifest{}, "", fmt.Errorf("persist initial plan checkpoint: %w", err) + } + freshOutput = false + } + if objects == nil { + objects, err = newS3ObjectStore(ctx, manifest.Region, options.Parallel.Concurrency) + if err != nil { + return planManifest{}, "", err + } + } + ordinal := uint64(manifest.Changed) + firstSequence := uint64(scope.FirstHeight) + if resumeFrontier != 0 { + firstSequence = resumeFrontier + 1 + } + rootCheckpointMatched := resumeFrontier == 0 + iteratePlanSource := func(iterCtx context.Context, callback func(int64, []dtypes.AccountStorageDiff) error) error { + if options.Direct { + return iterateArchiveDirectStorageDiffsContext( + iterCtx, archive, options.IAVLCacheSize, options.IAVLConcurrency, + scope.FirstHeight, scope.FinalHeight, callback, + ) + } + return iterateSealedDumpContext(iterCtx, sealedDump, dumpInfo, func(height int64, changeSet *iavl.ChangeSet) error { + if height == 1 && scope.FirstHeight == 2 { + return callback(height, nil) + } + canonical, err := statediff.CanonicalStorageDiff(changeSet) + if err != nil { + return fmt.Errorf("height %d canonical storage: %w", height, err) + } + return callback(height, canonical) + }) + } + err = runOrderedPipeline(ctx, firstSequence, options.Parallel.Concurrency, options.Parallel.Window, + func(pipelineCtx context.Context, emit func(uint64, planTask) error) error { + rollingDigest := common.Hash{} + scanErr := iteratePlanSource(pipelineCtx, func(height int64, canonical []dtypes.AccountStorageDiff) error { + if height == 1 && scope.FirstHeight == 2 { + return nil + } + if height < scope.FirstHeight || height > scope.FinalHeight { + return fmt.Errorf("source height %d is outside plan range %d-%d", height, scope.FirstHeight, scope.FinalHeight) + } + root, parent, err := rootCursor.Next(height) + if err != nil { + return err + } + if root == parent && len(canonical) != 0 { + return fmt.Errorf("height %d has storage changes behind an equal-root short circuit", height) + } + rollingDigest, err = extendPlanPrefixDigest(rollingDigest, uint64(height), root, parent, canonical) + if err != nil { + return fmt.Errorf("hash plan source prefix at height %d: %w", height, err) + } + if uint64(height) <= resumeFrontier { + record, err := readRootRecord(resumeRootReader) + if err != nil { + return fmt.Errorf("read resumable root height %d: %w", height, err) + } + if record.Height != uint64(height) || record.Root != root { + return fmt.Errorf("resumable root index differs at height %d", height) + } + if uint64(height) == resumeFrontier { + if rollingDigest != prefixDigest { + return fmt.Errorf("plan checkpoint prefix differs from current source at height %d", resumeFrontier) + } + rootCheckpointMatched = true + } + return nil + } + if uint64(height) > resumeFrontier && !rootCheckpointMatched { + return fmt.Errorf("plan checkpoint height %d was not found", resumeFrontier) + } + weight, err := canonicalObjectOperationBytes(canonical, root == parent) + if err != nil { + return fmt.Errorf("height %d canonical storage reservation: %w", height, err) + } + if err := limiter.Acquire(pipelineCtx, weight); err != nil { + return err + } + task := planTask{ + height: uint64(height), root: root, parent: parent, + key: fmt.Sprintf("%s/%s/stateDiff", manifest.Prefix, strings.ToLower(root.Hex())), + canonical: canonical, prefixSHA: rollingDigest, bytes: weight, + } + if err := emit(uint64(height), task); err != nil { + limiter.Release(weight) + return err + } + return nil + }) + if scanErr != nil { + return scanErr + } + if !rootCheckpointMatched { + return fmt.Errorf("plan checkpoint height %d was not found", resumeFrontier) + } + if resumeRootReader != nil { + if _, err := resumeRootReader.Peek(1); !errors.Is(err, io.EOF) { + if err == nil { + return fmt.Errorf("resumable root index has records after checkpoint height %d", resumeFrontier) + } + return err + } + } + return nil + }, + func(workerCtx context.Context, task planTask) (planOutcome, error) { + outcome, err := processPlanTask(workerCtx, manifest.Bucket, task, objects) + if err != nil { + limiter.Release(task.bytes) + return planOutcome{}, err + } + outcome.bytes = task.bytes + return outcome, nil + }, + func(_ uint64, outcome planOutcome) error { + defer limiter.Release(outcome.bytes) + writeReserve := planBufferedWriteReserve + uint64(rootRecordSize) + if outcome.changed { + recordBytes := estimatedPackRecordBytes(outcome.record) + if recordBytes <= 0 || uint64(recordBytes) > ^uint64(0)-writeReserve { + return fmt.Errorf("plan record write reservation overflows uint64") + } + writeReserve += uint64(recordBytes) + } + if options.MinFree > ^uint64(0)-writeReserve { + return fmt.Errorf("plan free-space requirement overflows uint64") + } + if err := ensureFreeSpace(options.Output, options.MinFree+writeReserve); err != nil { + return err + } + if err := writeRootRecord(rootBuffer, outcome.root, outcome.height); err != nil { + return err + } + if outcome.changed { + ordinal++ + outcome.record.Ordinal = ordinal + if err := writer.Write(outcome.record); err != nil { + return err + } + } + accumulatePlanOutcome(&manifest, outcome) + return saver.Advance(outcome.height, manifest, outcome.prefixSHA) + }, + ) + flushErr := saver.Flush() + if err != nil || flushErr != nil { + return planManifest{}, "", errors.Join(err, flushErr) + } + expectedProcessed := scope.FinalHeight - scope.FirstHeight + 1 + if manifest.Processed != expectedProcessed { + _ = rootIndex.Close() + return planManifest{}, "", fmt.Errorf("processed %d heights, want %d", manifest.Processed, expectedProcessed) + } + if err := rootBuffer.Flush(); err != nil { + _ = rootIndex.Close() + return planManifest{}, "", err + } + if err := rootIndex.Sync(); err != nil { + _ = rootIndex.Close() + return planManifest{}, "", err + } + if err := rootIndex.Close(); err != nil { + return planManifest{}, "", err + } + rootOpen = false + if resumeRootFile != nil { + if err := resumeRootFile.Close(); err != nil { + return planManifest{}, "", err + } + resumeRootFile = nil + } + heightRootName := "roots.by-height" + heightRootPath := filepath.Join(options.Output, heightRootName) + if err := commitFileNoReplace(rootIndexPath, heightRootPath); err != nil { + return planManifest{}, "", err + } + manifest.HeightRootIndex = heightRootName + manifest.HeightRootIndexSHA256, _, err = hashFileContext(ctx, heightRootPath) + if err != nil { + return planManifest{}, "", err + } + rootStat, err := os.Stat(heightRootPath) + if err != nil { + return planManifest{}, "", err + } + if rootStat.Size() < 0 || uint64(rootStat.Size()) > ^uint64(0)/2 { + return planManifest{}, "", fmt.Errorf("root index external sort reservation overflows uint64") + } + sortReserver, err := newFreeSpaceReserver(options.Output, options.MinFree) + if err != nil { + return planManifest{}, "", err + } + releaseSort, err := sortReserver.reserve(uint64(rootStat.Size()) * 2) + if err != nil { + return planManifest{}, "", fmt.Errorf("root index external sort: %w", err) + } + manifest.RootIndex, manifest.RootIndexSHA256, err = checkDuplicateRootsContext(ctx, heightRootPath, options.Output) + releaseSort() + if err != nil { + return planManifest{}, "", err + } + heightMultiset, heightCount, err := rootMultisetSHA256Context(ctx, heightRootPath) + if err != nil { + return planManifest{}, "", err + } + sortedMultiset, sortedCount, err := rootMultisetSHA256Context(ctx, filepath.Join(options.Output, manifest.RootIndex)) + if err != nil { + return planManifest{}, "", err + } + if heightCount != manifest.Processed || sortedCount != manifest.Processed || heightMultiset != sortedMultiset { + return planManifest{}, "", fmt.Errorf("root indexes do not contain the same multiset") + } + manifest.RootMultisetSHA256 = heightMultiset + manifest.Chunks, err = writer.Close() + if err != nil { + return planManifest{}, "", err + } + writerOpen = false + finalIdentity, err := archive.identity() + if err != nil { + return planManifest{}, "", err + } + if finalIdentity != identity { + return planManifest{}, "", fmt.Errorf("archive identity changed during plan") + } + if !options.Direct { + if err := validateSealedDumpManifest(sealedDump, dumpInfo, dumpHash); err != nil { + return planManifest{}, "", err + } + if err := validateSealedDumpArtifactsContext(ctx, sealedDump, dumpInfo); err != nil { + return planManifest{}, "", fmt.Errorf("sealed dump changed during plan: %w", err) + } + } + if err := ctx.Err(); err != nil { + return planManifest{}, "", err + } + if _, err := atomicJSONWithMinFree(filepath.Join(options.Output, "manifest.v1.json"), manifest, options.MinFree); err != nil { + return planManifest{}, "", err + } + if _, err := os.Lstat(sealedPlan); err == nil { + return planManifest{}, "", fmt.Errorf("sealed plan already exists: %s", sealedPlan) + } else if !errors.Is(err, os.ErrNotExist) { + return planManifest{}, "", err + } + if err := syncDir(options.Output); err != nil { + return planManifest{}, "", err + } + if err := ctx.Err(); err != nil { + return planManifest{}, "", err + } + if err := renameNoReplace(options.Output, sealedPlan); err != nil { + return planManifest{}, "", err + } + if err := syncDir(filepath.Dir(sealedPlan)); err != nil { + return planManifest{}, "", err + } + return manifest, filepath.Join(sealedPlan, "manifest.v1.json"), nil +} + +func reuseSealedPlanContext(ctx context.Context, sealedPath string, expected planManifest) (planManifest, string, bool, error) { + if ctx == nil { + return planManifest{}, "", false, fmt.Errorf("reuse sealed plan context is required") + } + if _, err := os.Lstat(sealedPath); errors.Is(err, os.ErrNotExist) { + return planManifest{}, "", false, nil + } else if err != nil { + return planManifest{}, "", false, err + } + manifestPath := filepath.Join(sealedPath, "manifest.v1.json") + manifest, _, err := loadPlanManifestContext(ctx, manifestPath) + if err != nil { + return planManifest{}, "", false, fmt.Errorf("validate existing sealed plan: %w", err) + } + if !samePlanIdentity(manifest, expected) { + return planManifest{}, "", false, fmt.Errorf("existing sealed plan identity differs from this run") + } + return manifest, manifestPath, true, nil +} + +func resolvePlanOutput(path string) (string, string, error) { + path = filepath.Clean(path) + if !strings.HasSuffix(path, ".staging") { + return "", "", fmt.Errorf("plan output must end in .staging") + } + absolute, err := filepath.Abs(path) + if err != nil { + return "", "", err + } + parent, err := filepath.EvalSymlinks(filepath.Dir(absolute)) + if err != nil { + return "", "", fmt.Errorf("resolve plan output parent: %w", err) + } + staging := filepath.Join(parent, filepath.Base(absolute)) + sealed := strings.TrimSuffix(staging, ".staging") + ".sealed" + sealedInfo, sealedErr := os.Lstat(sealed) + if sealedErr != nil && !errors.Is(sealedErr, os.ErrNotExist) { + return "", "", sealedErr + } + stagingInfo, stagingErr := os.Lstat(staging) + if stagingErr != nil && !errors.Is(stagingErr, os.ErrNotExist) { + return "", "", stagingErr + } + if sealedErr == nil && stagingErr == nil { + return "", "", fmt.Errorf("both staging and sealed plans exist: %s and %s", staging, sealed) + } + if sealedErr == nil && (!sealedInfo.IsDir() || sealedInfo.Mode()&os.ModeSymlink != 0) { + return "", "", fmt.Errorf("sealed plan must be a non-symlink directory: %s", sealed) + } + if stagingErr == nil { + if !stagingInfo.IsDir() || stagingInfo.Mode()&os.ModeSymlink != 0 { + return "", "", fmt.Errorf("plan staging must be a non-symlink directory: %s", staging) + } + checkpointPath := filepath.Join(staging, "plan.checkpoint.json") + if err := requireRegularFile(checkpointPath, "plan checkpoint"); err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", "", fmt.Errorf("plan staging directory exists without a resumable checkpoint: %s", staging) + } + return "", "", err + } + } + return staging, sealed, nil +} + +func canonicalDumpArgument(path string) (string, error) { + path = filepath.Clean(path) + if !strings.HasSuffix(path, ".staging") && !strings.HasSuffix(path, ".sealed") { + return "", fmt.Errorf("dump path must end in .staging or .sealed") + } + absolute, err := filepath.Abs(path) + if err != nil { + return "", err + } + parent, err := filepath.EvalSymlinks(filepath.Dir(absolute)) + if err != nil { + return "", fmt.Errorf("resolve dump parent: %w", err) + } + return filepath.Join(parent, filepath.Base(absolute)), nil +} + +func processPlanTask(ctx context.Context, bucket string, task planTask, objects objectStore) (planOutcome, error) { + outcome := planOutcome{height: task.height, root: task.root, prefixSHA: task.prefixSHA} + if task.root == task.parent { + if len(task.canonical) != 0 { + return planOutcome{}, fmt.Errorf("height %d has storage changes behind an equal-root short circuit", task.height) + } + outcome.skipped = true + return outcome, nil + } + object, err := objects.Get(ctx, bucket, task.key) + if err != nil { + return planOutcome{}, fmt.Errorf("get height %d key %s: %w", task.height, task.key, err) + } + record, changed, err := makePackRecord(task.height, task.key, object, task.root, task.parent, task.canonical) + if err != nil { + return planOutcome{}, fmt.Errorf("analyze height %d: %w", task.height, err) + } + outcome.changed = changed + outcome.oldBytes = int64(len(object.Body)) + outcome.record = record + return outcome, nil +} + +func extendPlanPrefixDigest( + previous common.Hash, + height uint64, + root, parent common.Hash, + canonical []dtypes.AccountStorageDiff, +) (common.Hash, error) { + canonicalBody, err := rlp.EncodeToBytes(canonical) + if err != nil { + return common.Hash{}, err + } + record := planPrefixRecord{ + Previous: previous, Height: height, Root: root, Parent: parent, CanonicalSHA: sha256Hash(canonicalBody), + } + body, err := rlp.EncodeToBytes(record) + if err != nil { + return common.Hash{}, err + } + return sha256Hash(body), nil +} + +func accumulatePlanOutcome(manifest *planManifest, outcome planOutcome) { + manifest.Processed++ + if outcome.skipped { + manifest.SkippedEqualRoot++ + return + } + manifest.OldBytes += outcome.oldBytes + if !outcome.changed { + manifest.Unchanged++ + return + } + manifest.Changed++ + manifest.SlotsAdded += outcome.record.SlotsAdded + manifest.SlotsRemoved += outcome.record.SlotsRemoved + manifest.SlotsChanged += outcome.record.SlotsChanged + manifest.NewBytes += int64(len(outcome.record.NewBody)) + if outcome.record.NoncanonicalOld { + manifest.ChangedCanonical++ + } + if outcome.record.ConflictingOld { + manifest.ChangedConflict++ + } +} + +func makePackRecord(height uint64, key string, object storedObject, root, parent common.Hash, canonical []dtypes.AccountStorageDiff) (packRecord, bool, error) { + var old dtypes.BlockStorageDiff + if err := rlp.DecodeBytes(object.Body, &old); err != nil { + return packRecord{}, false, fmt.Errorf("decode state diff: %w", err) + } + if old.Hash != root || old.ParentHash != parent { + return packRecord{}, false, fmt.Errorf("root mismatch: object %s/%s archive %s/%s", old.Hash, old.ParentHash, root, parent) + } + newBody, err := replaceStorageDiffRLP(object.Body, canonical) + if err != nil { + return packRecord{}, false, err + } + if err := verifyReencoded(old, canonical, newBody); err != nil { + return packRecord{}, false, err + } + if err := verifyRawRetainedFields(object.Body, newBody); err != nil { + return packRecord{}, false, err + } + if bytes.Equal(object.Body, newBody) { + return packRecord{}, false, nil + } + retained, err := retainedDigest(old) + if err != nil { + return packRecord{}, false, err + } + return packRecord{ + Schema: packSchema, Height: height, Key: key, OldETag: object.ETag, + OldBody: object.Body, NewBody: newBody, OldSHA256: sha256Hash(object.Body), NewSHA256: sha256Hash(newBody), + Headers: object.Headers, RetainedSHA256: retained, + }, true, nil +} + +type commitInfoReader interface { + commitInfo(int64) (*storetypes.CommitInfo, error) +} + +func archiveRoots(archive commitInfoReader, height int64) (common.Hash, common.Hash, error) { + rootInfo, err := archive.commitInfo(height - 1) + if err != nil { + return common.Hash{}, common.Hash{}, err + } + root := common.BytesToHash(rootInfo.Hash()) + if height == 2 { + return root, common.Hash{}, nil + } + parentInfo, err := archive.commitInfo(height - 2) + if err != nil { + return common.Hash{}, common.Hash{}, err + } + return root, common.BytesToHash(parentInfo.Hash()), nil +} + +func verifyReencoded(old dtypes.BlockStorageDiff, canonical []dtypes.AccountStorageDiff, body []byte) error { + var decoded dtypes.BlockStorageDiff + if err := rlp.DecodeBytes(body, &decoded); err != nil { + return err + } + decodedStorage, err := rlp.EncodeToBytes(decoded.StorageDiff) + if err != nil { + return err + } + canonicalStorage, err := rlp.EncodeToBytes(canonical) + if err != nil { + return err + } + if !bytes.Equal(decodedStorage, canonicalStorage) { + return fmt.Errorf("replacement storage is not canonical") + } + oldRetained, err := retainedDigest(old) + if err != nil { + return err + } + newRetained, err := retainedDigest(decoded) + if err != nil { + return err + } + if oldRetained != newRetained { + return fmt.Errorf("replacement changed retained fields") + } + return nil +} + +func ensureFreeSpace(path string, minimum uint64) error { + if minimum == 0 { + return fmt.Errorf("min-free-bytes must be greater than zero") + } + available, _, err := filesystemSpace(path) + if err != nil { + return err + } + if available < minimum { + return fmt.Errorf("filesystem free space %d is below min-free-bytes %d", available, minimum) + } + return nil +} + +func buildCommits() (string, string, string) { + cronosCommit := version.Commit + ethermintCommit, iavlCommit := unknownBuildIdentity, unknownBuildIdentity + if info, ok := debug.ReadBuildInfo(); ok { + for _, setting := range info.Settings { + if setting.Key == "vcs.revision" && cronosCommit == "" { + cronosCommit = setting.Value + } + } + for _, dependency := range info.Deps { + switch dependency.Path { + case "github.com/evmos/ethermint": + ethermintCommit = moduleVersionCommit(dependency) + case "github.com/cosmos/iavl": + iavlCommit = moduleVersionCommit(dependency) + } + } + } + if cronosCommit == "" { + cronosCommit = unknownBuildIdentity + } + return cronosCommit, ethermintCommit, iavlCommit +} + +func moduleVersionCommit(dependency *debug.Module) string { + if dependency == nil { + return unknownBuildIdentity + } + version := dependency.Version + if dependency.Replace != nil { + version = dependency.Replace.Version + } + parts := strings.Split(version, "-") + if len(parts) < 3 || parts[len(parts)-1] == "" { + return unknownBuildIdentity + } + return parts[len(parts)-1] +} diff --git a/cmd/statediff-rewriter/plan_checkpoint.go b/cmd/statediff-rewriter/plan_checkpoint.go new file mode 100644 index 00000000..9f95d84f --- /dev/null +++ b/cmd/statediff-rewriter/plan_checkpoint.go @@ -0,0 +1,269 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "os" + "path/filepath" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +const planCheckpointSchema = "statediff-rewriter-plan-checkpoint/v1" + +type planCheckpoint struct { + Schema string `json:"schema"` + Checksum string `json:"checksum"` + Frontier uint64 `json:"frontier"` + PrefixSHA string `json:"prefix_sha256"` + RootBytes int64 `json:"root_bytes"` + Manifest planManifest `json:"manifest"` + Pack packWriterState `json:"pack"` +} + +func planCheckpointChecksum(checkpoint planCheckpoint) (string, error) { + checkpoint.Checksum = "" + body, err := json.Marshal(checkpoint) + if err != nil { + return "", err + } + return sha256Hex(body), nil +} + +func restorePlanRootIndex(dir string, size int64) (string, error) { + tmpPath := filepath.Join(dir, "roots.by-height.tmp") + finalPath := filepath.Join(dir, "roots.by-height") + found, err := restoreNoReplacePartial(tmpPath, finalPath, "resumable height root index") + if err != nil { + return "", err + } + if !found { + return "", fmt.Errorf("resumable height root index is missing") + } + if err := truncateCheckpointFile(tmpPath, size); err != nil { + return "", err + } + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + for _, entry := range entries { + name := entry.Name() + if name == "roots.sorted" || name == "manifest.v1.json" || strings.HasPrefix(name, "roots-sort-") { + if err := os.Remove(filepath.Join(dir, name)); err != nil { + return "", err + } + } + } + return tmpPath, nil +} + +func loadPlanCheckpoint(path string, expected planManifest) (planCheckpoint, bool, error) { + body, found, err := readMutableStateFile(path, "plan checkpoint") + if !found && err == nil { + return planCheckpoint{}, false, nil + } + if err != nil { + return planCheckpoint{}, false, err + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + var checkpoint planCheckpoint + if err := decoder.Decode(&checkpoint); err != nil { + return planCheckpoint{}, false, fmt.Errorf("decode plan checkpoint: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return planCheckpoint{}, false, fmt.Errorf("plan checkpoint has trailing JSON") + } + if checkpoint.Schema != planCheckpointSchema { + return planCheckpoint{}, false, fmt.Errorf("unsupported plan checkpoint schema %q", checkpoint.Schema) + } + wantChecksum, err := planCheckpointChecksum(checkpoint) + if err != nil { + return planCheckpoint{}, false, fmt.Errorf("hash plan checkpoint: %w", err) + } + if checkpoint.Checksum == "" || checkpoint.Checksum != wantChecksum { + return planCheckpoint{}, false, fmt.Errorf("plan checkpoint checksum mismatch") + } + if !samePlanIdentity(checkpoint.Manifest, expected) { + return planCheckpoint{}, false, fmt.Errorf("plan checkpoint identity differs from this run") + } + if checkpoint.Manifest.RunID == "" || checkpoint.Manifest.CreatedAt == "" || + !validCreatedAt(checkpoint.Manifest.CreatedAt) || !validSHA256Hex(checkpoint.PrefixSHA) || + checkpoint.Frontier > uint64(expected.FinalHeight) || + checkpoint.Frontier != 0 && checkpoint.Frontier < uint64(expected.FirstHeight) { + return planCheckpoint{}, false, fmt.Errorf("plan checkpoint frontier or run identity is invalid") + } + var wantProcessed int64 + if checkpoint.Frontier != 0 { + wantProcessed = int64(checkpoint.Frontier) - expected.FirstHeight + 1 + } + manifest := checkpoint.Manifest + if checkpoint.Frontier == 0 && checkpoint.PrefixSHA != strings.Repeat("0", 64) { + return planCheckpoint{}, false, fmt.Errorf("zero-frontier plan checkpoint has a non-zero prefix hash") + } + if manifest.Processed != wantProcessed || manifest.Processed < 0 || manifest.Changed < 0 || manifest.Unchanged < 0 || + manifest.SkippedEqualRoot < 0 || manifest.ChangedCanonical < 0 || manifest.ChangedConflict < 0 || + manifest.OldBytes < 0 || manifest.NewBytes < 0 || manifest.Changed > manifest.Processed || + manifest.Unchanged > manifest.Processed-manifest.Changed || + manifest.SkippedEqualRoot != manifest.Processed-manifest.Changed-manifest.Unchanged || + manifest.ChangedCanonical > manifest.Changed || manifest.ChangedConflict > manifest.Changed || + manifest.Processed > math.MaxInt64/rootRecordSize || checkpoint.RootBytes != manifest.Processed*rootRecordSize { + return planCheckpoint{}, false, fmt.Errorf("plan checkpoint counters are inconsistent") + } + if checkpoint.Pack.Chunk < 0 || checkpoint.Pack.Chunk != len(checkpoint.Pack.Chunks) || + checkpoint.Pack.PackOffset < 0 || checkpoint.Pack.IndexOffset < 0 || checkpoint.Pack.Records < 0 || + (checkpoint.Pack.Records == 0 && (checkpoint.Pack.PackOffset != 0 || checkpoint.Pack.IndexOffset != 0)) || + (checkpoint.Pack.Records > 0 && (checkpoint.Pack.PackOffset == 0 || checkpoint.Pack.IndexOffset == 0)) { + return planCheckpoint{}, false, fmt.Errorf("plan checkpoint pack state is invalid") + } + var packRecords int64 + for number, chunk := range checkpoint.Pack.Chunks { + if chunk.Number != number || chunk.Pack != fmt.Sprintf("chunk-%06d.pack", number) || + chunk.Index != fmt.Sprintf("chunk-%06d.idx", number) || chunk.Records < 1 || chunk.PackSize < 1 || + !validSHA256Hex(chunk.PackSHA256) || !validSHA256Hex(chunk.IndexSHA256) { + return planCheckpoint{}, false, fmt.Errorf("plan checkpoint completed chunk %d is invalid", number) + } + if packRecords > math.MaxInt64-chunk.Records { + return planCheckpoint{}, false, fmt.Errorf("plan checkpoint pack record count overflows") + } + packRecords += chunk.Records + } + if packRecords > math.MaxInt64-checkpoint.Pack.Records { + return planCheckpoint{}, false, fmt.Errorf("plan checkpoint pack record count overflows") + } + packRecords += checkpoint.Pack.Records + if packRecords != manifest.Changed { + return planCheckpoint{}, false, fmt.Errorf("plan checkpoint pack has %d records, manifest has %d", packRecords, manifest.Changed) + } + return checkpoint, true, nil +} + +func samePlanIdentity(left, right planManifest) bool { + return left.Schema == right.Schema && left.Sealed == right.Sealed && left.Bucket == right.Bucket && + left.Prefix == right.Prefix && left.Region == right.Region && left.FirstHeight == right.FirstHeight && + left.FinalHeight == right.FinalHeight && left.CronosCommit == right.CronosCommit && + left.EthermintCommit == right.EthermintCommit && left.IAVLCommit == right.IAVLCommit && + effectivePlanSourceMode(left) == effectivePlanSourceMode(right) && left.DumpPath == right.DumpPath && + left.DumpManifestHash == right.DumpManifestHash && left.ArchiveIdentity == right.ArchiveIdentity && + left.SnapshotID == right.SnapshotID && left.ImageDigest == right.ImageDigest && left.BuildTags == right.BuildTags +} + +type planCheckpointSaver struct { + path string + rootBuffer *bufio.Writer + rootFile *os.File + writer *packWriter + minFree uint64 + every uint64 + interval time.Duration + advances uint64 + dirty bool + frontier uint64 + prefixSHA common.Hash + manifest planManifest + lastPersist time.Time + now func() time.Time +} + +func newPlanCheckpointSaver( + path string, + rootBuffer *bufio.Writer, + rootFile *os.File, + writer *packWriter, + minFree uint64, + every uint64, + interval time.Duration, +) (*planCheckpointSaver, error) { + if minFree == 0 || every == 0 || interval <= 0 { + return nil, fmt.Errorf("plan min-free, checkpoint interval, and record count must be positive") + } + return &planCheckpointSaver{ + path: path, rootBuffer: rootBuffer, rootFile: rootFile, writer: writer, minFree: minFree, + every: every, interval: interval, lastPersist: time.Now(), now: time.Now, + }, nil +} + +func (saver *planCheckpointSaver) Initialize(manifest planManifest) error { + if saver.frontier != 0 || saver.dirty || saver.advances != 0 { + return fmt.Errorf("plan checkpoint saver is already initialized") + } + if manifest.Processed != 0 || manifest.Changed != 0 || manifest.Unchanged != 0 || manifest.SkippedEqualRoot != 0 { + return fmt.Errorf("initial plan checkpoint has non-zero counters") + } + saver.manifest = manifest + saver.dirty = true + return saver.Flush() +} + +func (saver *planCheckpointSaver) Advance(frontier uint64, manifest planManifest, prefixSHA common.Hash) error { + if frontier <= saver.frontier { + return fmt.Errorf("plan checkpoint frontier %d does not advance beyond %d", frontier, saver.frontier) + } + saver.frontier = frontier + saver.prefixSHA = prefixSHA + saver.manifest = manifest + saver.advances++ + saver.dirty = true + if saver.advances >= saver.every || saver.now().Sub(saver.lastPersist) >= saver.interval { + return saver.Flush() + } + return nil +} + +func (saver *planCheckpointSaver) Flush() error { + if !saver.dirty { + return nil + } + if err := saver.rootBuffer.Flush(); err != nil { + return err + } + if err := saver.rootFile.Sync(); err != nil { + return err + } + pack, err := saver.writer.Sync() + if err != nil { + return err + } + stat, err := saver.rootFile.Stat() + if err != nil { + return err + } + if saver.manifest.Processed < 0 || saver.manifest.Processed > math.MaxInt64/rootRecordSize { + return fmt.Errorf("plan checkpoint root index size overflows") + } + wantRootBytes := saver.manifest.Processed * rootRecordSize + if stat.Size() != wantRootBytes { + return fmt.Errorf("height root index has %d bytes, want %d", stat.Size(), wantRootBytes) + } + manifest := saver.manifest + manifest.Chunks = append([]chunkManifest(nil), pack.Chunks...) + checkpoint := planCheckpoint{ + Schema: planCheckpointSchema, Frontier: saver.frontier, PrefixSHA: hex.EncodeToString(saver.prefixSHA[:]), RootBytes: stat.Size(), + Manifest: manifest, Pack: pack, + } + checkpoint.Checksum, err = planCheckpointChecksum(checkpoint) + if err != nil { + return err + } + if err := validateMutableStatePath(saver.path, "plan checkpoint"); err != nil { + return err + } + _, err = atomicJSONWithMinFree(saver.path, checkpoint, saver.minFree) + if err != nil { + return err + } + saver.advances = 0 + saver.dirty = false + saver.lastPersist = saver.now() + return nil +} diff --git a/cmd/statediff-rewriter/plan_checkpoint_test.go b/cmd/statediff-rewriter/plan_checkpoint_test.go new file mode 100644 index 00000000..bda787e3 --- /dev/null +++ b/cmd/statediff-rewriter/plan_checkpoint_test.go @@ -0,0 +1,235 @@ +package main + +import ( + "bufio" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func testPlanCheckpointManifest() planManifest { + return planManifest{ + Schema: manifestSchema, Sealed: true, RunID: "run", CreatedAt: "2026-07-22T00:00:00Z", + Bucket: defaultBucket, Prefix: defaultPrefix, Region: defaultRegion, + FirstHeight: 2, FinalHeight: 10, + CronosCommit: testCronosCommit, EthermintCommit: testEthermintCommit, IAVLCommit: testIAVLCommit, + DumpPath: "/dump.sealed", DumpManifestHash: "dump-hash", + ArchiveIdentity: archiveIdentity{ + Home: "/archive", DatabaseIdentity: "db-test", LatestVersion: 10, FinalCommitHash: "hash", + }, + SnapshotID: "snap", ImageDigest: testImageDigest, BuildTags: testBuildTags, + } +} + +func TestPlanCheckpointFlushAndResumeTruncateFiles(t *testing.T) { + _, _, records, _ := makeSealedPlanRecords(t, 3) + dir := filepath.Join(t.TempDir(), "plan.staging") + require.NoError(t, os.Mkdir(dir, 0o755)) + writer, err := newPackWriter(dir, 1<<30) + require.NoError(t, err) + rootPath := filepath.Join(dir, "roots.by-height.tmp") + rootFile, err := os.OpenFile(rootPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + require.NoError(t, err) + rootBuffer := bufio.NewWriter(rootFile) + saver, err := newPlanCheckpointSaver(filepath.Join(dir, "plan.checkpoint.json"), rootBuffer, rootFile, writer, 1, 2, time.Hour) + require.NoError(t, err) + manifest := testPlanCheckpointManifest() + + for index := 0; index < 2; index++ { + height := uint64(index + 2) + require.NoError(t, writeRootRecord(rootBuffer, common.BytesToHash([]byte{1}), height)) + require.NoError(t, writer.Write(records[index])) + manifest.Processed++ + manifest.Changed++ + manifest.SlotsAdded += records[index].SlotsAdded + manifest.SlotsRemoved += records[index].SlotsRemoved + manifest.SlotsChanged += records[index].SlotsChanged + manifest.OldBytes += int64(len(records[index].OldBody)) + manifest.NewBytes += int64(len(records[index].NewBody)) + if records[index].NoncanonicalOld { + manifest.ChangedCanonical++ + } + if records[index].ConflictingOld { + manifest.ChangedConflict++ + } + require.NoError(t, saver.Advance(height, manifest, common.BytesToHash([]byte{byte(height)}))) + } + checkpoint, found, err := loadPlanCheckpoint(filepath.Join(dir, "plan.checkpoint.json"), testPlanCheckpointManifest()) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, uint64(3), checkpoint.Frontier) + require.Equal(t, int64(2), checkpoint.Manifest.Changed) + wantPrefix := common.BytesToHash([]byte{3}) + require.Equal(t, hex.EncodeToString(wantPrefix[:]), checkpoint.PrefixSHA) + require.ErrorContains(t, saver.Advance(3, manifest, wantPrefix), "does not advance") + + require.NoError(t, writeRootRecord(rootBuffer, common.BytesToHash([]byte{2}), 4)) + require.NoError(t, writer.Write(records[2])) + require.NoError(t, rootBuffer.Flush()) + require.NoError(t, rootFile.Close()) + require.NoError(t, writer.Abort()) + + restoredRoot, err := restorePlanRootIndex(dir, checkpoint.RootBytes) + require.NoError(t, err) + stat, err := os.Stat(restoredRoot) + require.NoError(t, err) + require.Equal(t, int64(2*rootRecordSize), stat.Size()) + resumed, err := resumePackWriter(dir, 1<<30, checkpoint.Pack, checkpoint.Manifest) + require.NoError(t, err) + state, err := resumed.Sync() + require.NoError(t, err) + require.Equal(t, int64(2), state.Records) + require.NoError(t, resumed.Abort()) +} + +func TestPlanCheckpointInitializePersistsZeroFrontier(t *testing.T) { + dir := filepath.Join(t.TempDir(), "plan.staging") + require.NoError(t, os.Mkdir(dir, 0o755)) + writer, err := newPackWriter(dir, 1<<30) + require.NoError(t, err) + rootFile, err := os.OpenFile(filepath.Join(dir, "roots.by-height.tmp"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + require.NoError(t, err) + rootBuffer := bufio.NewWriter(rootFile) + checkpointPath := filepath.Join(dir, "plan.checkpoint.json") + saver, err := newPlanCheckpointSaver(checkpointPath, rootBuffer, rootFile, writer, 1, 2, time.Hour) + require.NoError(t, err) + manifest := testPlanCheckpointManifest() + + require.NoError(t, saver.Initialize(manifest)) + checkpoint, found, err := loadPlanCheckpoint(checkpointPath, manifest) + require.NoError(t, err) + require.True(t, found) + require.Zero(t, checkpoint.Frontier) + require.Zero(t, checkpoint.RootBytes) + require.Zero(t, checkpoint.Manifest.Processed) + require.NoError(t, rootFile.Close()) + require.NoError(t, writer.Abort()) +} + +func TestRestorePlanRootIndexRejectsSymlinkWithoutTruncatingTarget(t *testing.T) { + base := t.TempDir() + dir := filepath.Join(base, "plan.staging") + require.NoError(t, os.Mkdir(dir, 0o755)) + target := filepath.Join(base, "outside") + require.NoError(t, os.WriteFile(target, []byte("keep"), 0o600)) + require.NoError(t, os.Symlink(target, filepath.Join(dir, "roots.by-height.tmp"))) + + _, err := restorePlanRootIndex(dir, 0) + require.ErrorContains(t, err, "regular non-symlink") + body, readErr := os.ReadFile(target) + require.NoError(t, readErr) + require.Equal(t, []byte("keep"), body) +} + +func TestRestorePlanRootIndexRecoversNoReplaceLinkWindow(t *testing.T) { + dir := filepath.Join(t.TempDir(), "plan.staging") + require.NoError(t, os.Mkdir(dir, 0o700)) + partial := filepath.Join(dir, "roots.by-height.tmp") + final := filepath.Join(dir, "roots.by-height") + require.NoError(t, os.WriteFile(partial, make([]byte, rootRecordSize), 0o600)) + require.NoError(t, os.Link(partial, final)) + restored, err := restorePlanRootIndex(dir, rootRecordSize) + require.NoError(t, err) + require.Equal(t, partial, restored) + _, err = os.Lstat(final) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestRestorePlanRootIndexRejectsConflictingPartialAndFinal(t *testing.T) { + dir := filepath.Join(t.TempDir(), "plan.staging") + require.NoError(t, os.Mkdir(dir, 0o700)) + partial := filepath.Join(dir, "roots.by-height.tmp") + final := filepath.Join(dir, "roots.by-height") + require.NoError(t, os.WriteFile(partial, []byte("partial"), 0o600)) + require.NoError(t, os.WriteFile(final, []byte("final"), 0o600)) + _, err := restorePlanRootIndex(dir, 0) + require.ErrorContains(t, err, "conflicting inodes") + partialBody, err := os.ReadFile(partial) + require.NoError(t, err) + finalBody, err := os.ReadFile(final) + require.NoError(t, err) + require.Equal(t, []byte("partial"), partialBody) + require.Equal(t, []byte("final"), finalBody) +} + +func TestLoadPlanCheckpointRejectsNumericMutation(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "plan.checkpoint.json") + manifest := testPlanCheckpointManifest() + manifest.Processed = 1 + manifest.Unchanged = 1 + checkpoint := planCheckpoint{ + Schema: planCheckpointSchema, Frontier: 2, PrefixSHA: strings.Repeat("0", 64), RootBytes: rootRecordSize, + Manifest: manifest, + } + checksum, err := planCheckpointChecksum(checkpoint) + require.NoError(t, err) + checkpoint.Checksum = checksum + body, err := atomicJSON(path, checkpoint) + require.NoError(t, err) + + mutated := strings.Replace(string(body), `"old_bytes": 0`, `"old_bytes": 1`, 1) + require.NotEqual(t, string(body), mutated) + require.NoError(t, os.WriteFile(path, []byte(mutated), 0o600)) + + _, found, err := loadPlanCheckpoint(path, testPlanCheckpointManifest()) + require.ErrorContains(t, err, "checksum mismatch") + require.False(t, found) +} + +func TestLoadPlanCheckpointRejectsChecksummedInvalidState(t *testing.T) { + validChunk := chunkManifest{ + Number: 0, Pack: "chunk-000000.pack", Index: "chunk-000000.idx", + PackSHA256: strings.Repeat("1", 64), IndexSHA256: strings.Repeat("2", 64), PackSize: 1, Records: 1, + } + tests := []struct { + name string + mutate func(*planCheckpoint) + want string + }{ + {name: "negative counter", mutate: func(checkpoint *planCheckpoint) { + checkpoint.Manifest.OldBytes = -1 + }, want: "counters are inconsistent"}, + {name: "chunk count mismatch", mutate: func(checkpoint *planCheckpoint) { + checkpoint.Pack.Chunk = 1 + }, want: "pack state is invalid"}, + {name: "current offset missing", mutate: func(checkpoint *planCheckpoint) { + checkpoint.Pack.PackOffset = 0 + }, want: "pack state is invalid"}, + {name: "invalid completed path", mutate: func(checkpoint *planCheckpoint) { + checkpoint.Pack = packWriterState{Chunk: 1, Chunks: []chunkManifest{validChunk}} + checkpoint.Pack.Chunks[0].Pack = "../chunk.pack" + }, want: "completed chunk 0 is invalid"}, + {name: "zero-record completed chunk", mutate: func(checkpoint *planCheckpoint) { + checkpoint.Pack = packWriterState{Chunk: 1, Chunks: []chunkManifest{validChunk}} + checkpoint.Pack.Chunks[0].Records = 0 + }, want: "completed chunk 0 is invalid"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + manifest := testPlanCheckpointManifest() + manifest.Processed = 1 + manifest.Changed = 1 + checkpoint := planCheckpoint{ + Schema: planCheckpointSchema, Frontier: 2, PrefixSHA: strings.Repeat("1", 64), RootBytes: rootRecordSize, + Manifest: manifest, Pack: packWriterState{PackOffset: 1, IndexOffset: 1, Records: 1}, + } + test.mutate(&checkpoint) + checksum, err := planCheckpointChecksum(checkpoint) + require.NoError(t, err) + checkpoint.Checksum = checksum + path := filepath.Join(t.TempDir(), "plan.checkpoint.json") + _, err = atomicJSON(path, checkpoint) + require.NoError(t, err) + _, found, err := loadPlanCheckpoint(path, testPlanCheckpointManifest()) + require.ErrorContains(t, err, test.want) + require.False(t, found) + }) + } +} diff --git a/cmd/statediff-rewriter/plan_test.go b/cmd/statediff-rewriter/plan_test.go new file mode 100644 index 00000000..6bb2d048 --- /dev/null +++ b/cmd/statediff-rewriter/plan_test.go @@ -0,0 +1,375 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rlp" + dtypes "github.com/evmos/ethermint/debank/types" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + storetypes "cosmossdk.io/store/types" +) + +func TestResolvePlanOutputBeforeDumpSeal(t *testing.T) { + parent := t.TempDir() + _, _, err := resolvePlanOutput(filepath.Join(parent, "plan.invalid")) + require.ErrorContains(t, err, "must end in .staging") + + sealed := filepath.Join(parent, "plan.sealed") + require.NoError(t, os.WriteFile(sealed, []byte("occupied"), 0o600)) + _, _, err = resolvePlanOutput(filepath.Join(parent, "plan.staging")) + require.ErrorContains(t, err, "sealed plan must be a non-symlink directory") + body, readErr := os.ReadFile(sealed) + require.NoError(t, readErr) + require.Equal(t, []byte("occupied"), body) + + require.NoError(t, os.Remove(sealed)) + require.NoError(t, os.Mkdir(filepath.Join(parent, "plan.staging"), 0o755)) + _, _, err = resolvePlanOutput(filepath.Join(parent, "plan.staging")) + require.ErrorContains(t, err, "without a resumable checkpoint") +} + +func TestReuseSealedPlanValidatesIdentity(t *testing.T) { + manifestPath, manifest, _, _ := makeSealedPlanRecords(t, 2) + sealedPath := filepath.Dir(manifestPath) + loaded, gotPath, found, err := reuseSealedPlanContext(context.Background(), sealedPath, manifest) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, manifestPath, gotPath) + require.Equal(t, manifest.RunID, loaded.RunID) + + different := manifest + different.Prefix = "different" + _, _, found, err = reuseSealedPlanContext(context.Background(), sealedPath, different) + require.ErrorContains(t, err, "identity differs") + require.False(t, found) +} + +func TestExtendPlanPrefixDigestBindsEveryInput(t *testing.T) { + previous := common.BytesToHash([]byte{1}) + root := common.BytesToHash([]byte{2}) + parent := common.BytesToHash([]byte{3}) + canonical := []dtypes.AccountStorageDiff{testStorage(1, 1, 2)} + base, err := extendPlanPrefixDigest(previous, 10, root, parent, canonical) + require.NoError(t, err) + variants := []struct { + previous common.Hash + height uint64 + root common.Hash + parent common.Hash + canonical []dtypes.AccountStorageDiff + }{ + {common.BytesToHash([]byte{9}), 10, root, parent, canonical}, + {previous, 11, root, parent, canonical}, + {previous, 10, common.BytesToHash([]byte{9}), parent, canonical}, + {previous, 10, root, common.BytesToHash([]byte{9}), canonical}, + {previous, 10, root, parent, []dtypes.AccountStorageDiff{testStorage(1, 1, 3)}}, + } + for _, variant := range variants { + got, err := extendPlanPrefixDigest(variant.previous, variant.height, variant.root, variant.parent, variant.canonical) + require.NoError(t, err) + require.NotEqual(t, base, got) + } +} + +type fakeCommitInfoReader struct { + infos map[int64]*storetypes.CommitInfo + calls []int64 +} + +func (r *fakeCommitInfoReader) commitInfo(version int64) (*storetypes.CommitInfo, error) { + r.calls = append(r.calls, version) + info, ok := r.infos[version] + if !ok { + return nil, fmt.Errorf("unexpected commit info version %d", version) + } + return info, nil +} + +func TestResolvePlanRange(t *testing.T) { + full, err := resolvePlanRange(planOptions{}, 200) + require.NoError(t, err) + require.Equal(t, planRange{ + FirstHeight: 2, FinalHeight: 200, DumpFirstVersion: 1, + ManifestSchema: manifestSchema, DumpSchema: dumpManifestSchema, + }, full) + + pilot, err := resolvePlanRange(planOptions{Pilot: true, PilotFirstHeight: 100, PilotFinalHeight: 109}, 200) + require.NoError(t, err) + require.Equal(t, planRange{ + FirstHeight: 100, FinalHeight: 109, DumpFirstVersion: 100, + ManifestSchema: pilotManifestSchema, DumpSchema: pilotDumpManifestSchema, + }, pilot) + + for _, options := range []planOptions{ + {Pilot: true, PilotFirstHeight: 1, PilotFinalHeight: 10}, + {Pilot: true, PilotFirstHeight: 100, PilotFinalHeight: 99}, + {Pilot: true, PilotFirstHeight: 100, PilotFinalHeight: 201}, + {PilotFirstHeight: 100, PilotFinalHeight: 109}, + } { + _, err := resolvePlanRange(options, 200) + require.Error(t, err) + } +} + +func TestArchiveRootsUsesZeroParentAtHeightTwo(t *testing.T) { + info := &storetypes.CommitInfo{Version: 1} + reader := &fakeCommitInfoReader{infos: map[int64]*storetypes.CommitInfo{1: info}} + root, parent, err := archiveRoots(reader, 2) + require.NoError(t, err) + require.Equal(t, common.BytesToHash(info.Hash()), root) + require.Equal(t, common.Hash{}, parent) +} + +func TestArchiveRootCursorReadsEachSequentialVersionOnce(t *testing.T) { + infos := make(map[int64]*storetypes.CommitInfo) + for version := int64(1); version <= 3; version++ { + infos[version] = &storetypes.CommitInfo{Version: version} + } + reader := &fakeCommitInfoReader{infos: infos} + cursor, err := newArchiveRootCursor(reader, 2) + require.NoError(t, err) + var previous common.Hash + for height := int64(2); height <= 4; height++ { + root, parent, err := cursor.Next(height) + require.NoError(t, err) + require.Equal(t, previous, parent) + previous = root + } + require.Equal(t, []int64{1, 2, 3}, reader.calls) +} + +func TestArchiveRootCursorPilotReadsOnePredecessorThenSequentialVersions(t *testing.T) { + infos := map[int64]*storetypes.CommitInfo{ + 98: {Version: 98}, + 99: {Version: 99}, + 100: {Version: 100}, + } + reader := &fakeCommitInfoReader{infos: infos} + cursor, err := newArchiveRootCursor(reader, 100) + require.NoError(t, err) + root, parent, err := cursor.Next(100) + require.NoError(t, err) + require.Equal(t, common.BytesToHash(infos[99].Hash()), root) + require.Equal(t, common.BytesToHash(infos[98].Hash()), parent) + root, parent, err = cursor.Next(101) + require.NoError(t, err) + require.Equal(t, common.BytesToHash(infos[100].Hash()), root) + require.Equal(t, common.BytesToHash(infos[99].Hash()), parent) + require.Equal(t, []int64{99, 98, 100}, reader.calls) +} + +func planTestBlockStorageDiff(root, parent common.Hash, storage []dtypes.AccountStorageDiff) dtypes.BlockStorageDiff { + return dtypes.BlockStorageDiff{ + Hash: root, ParentHash: parent, + NewAccounts: []dtypes.NewAccount{{ + Address: common.BytesToHash([]byte{10}), Balance: uint256.NewInt(11), Nonce: 12, + CodeHash: common.BytesToHash([]byte{13}), + }}, + DeletedAccounts: []common.Hash{common.BytesToHash([]byte{14})}, + StorageDiff: storage, + NewCodes: []dtypes.NewCode{{ + CodeHash: common.BytesToHash([]byte{15}), Code: []byte{0x60, 0x00}, + }}, + } +} + +func requireChangedPackRecord( + t *testing.T, + old dtypes.BlockStorageDiff, + object storedObject, + record packRecord, + canonical []dtypes.AccountStorageDiff, +) { + t.Helper() + require.Equal(t, packSchema, record.Schema) + require.Equal(t, uint64(2), record.Height) + require.Equal(t, "key", record.Key) + require.Equal(t, object.ETag, record.OldETag) + require.Equal(t, object.Body, record.OldBody) + require.Equal(t, object.Headers, record.Headers) + expectedNewBody, err := replaceStorageDiffRLP(object.Body, canonical) + require.NoError(t, err) + require.Equal(t, expectedNewBody, record.NewBody) + require.False(t, bytes.Equal(record.OldBody, record.NewBody)) + require.Equal(t, sha256Hash(record.OldBody), record.OldSHA256) + require.Equal(t, sha256Hash(record.NewBody), record.NewSHA256) + retained, err := retainedDigest(old) + require.NoError(t, err) + require.Equal(t, retained, record.RetainedSHA256) + require.Zero(t, record.SlotsAdded) + require.Zero(t, record.SlotsRemoved) + require.Zero(t, record.SlotsChanged) + require.False(t, record.NoncanonicalOld) + require.False(t, record.ConflictingOld) + + oldFields, err := blockStorageDiffRawFields(record.OldBody) + require.NoError(t, err) + newFields, err := blockStorageDiffRawFields(record.NewBody) + require.NoError(t, err) + for index := range oldFields { + if index == storageDiffFieldIndex { + require.False(t, bytes.Equal(oldFields[index], newFields[index])) + continue + } + require.True(t, bytes.Equal(oldFields[index], newFields[index]), "raw RLP field %d changed", index) + } + + var decoded dtypes.BlockStorageDiff + require.NoError(t, rlp.DecodeBytes(record.NewBody, &decoded)) + decodedStorage, err := rlp.EncodeToBytes(decoded.StorageDiff) + require.NoError(t, err) + canonicalStorage, err := rlp.EncodeToBytes(canonical) + require.NoError(t, err) + require.Equal(t, canonicalStorage, decodedStorage) +} + +func TestMakePackRecordReplacesStorageWithoutSemanticComparison(t *testing.T) { + root := common.BytesToHash([]byte{2}) + parent := common.BytesToHash([]byte{1}) + canonical := []dtypes.AccountStorageDiff{testStorage(1, 1, 2), testStorage(2, 2, 3)} + tests := []struct { + name string + old []dtypes.AccountStorageDiff + }{ + {name: "missing", old: []dtypes.AccountStorageDiff{testStorage(1, 1, 2)}}, + {name: "extra", old: []dtypes.AccountStorageDiff{testStorage(1, 1, 2), testStorage(2, 2, 3), testStorage(3, 3, 4)}}, + {name: "wrong", old: []dtypes.AccountStorageDiff{testStorage(1, 1, 99), testStorage(2, 2, 3)}}, + {name: "duplicate", old: []dtypes.AccountStorageDiff{testStorage(1, 1, 2), testStorage(1, 1, 99), testStorage(2, 2, 3)}}, + {name: "same slots different order", old: []dtypes.AccountStorageDiff{testStorage(2, 2, 3), testStorage(1, 1, 2)}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + old := planTestBlockStorageDiff(root, parent, test.old) + body, err := rlp.EncodeToBytes(old) + require.NoError(t, err) + object := storedObject{ + Body: body, ETag: "etag", + Headers: objectHeaders{ContentType: "application/octet-stream", Metadata: []byte(`{"source":"test"}`)}, + } + record, changed, err := makePackRecord(2, "key", object, root, parent, canonical) + require.NoError(t, err) + require.True(t, changed) + requireChangedPackRecord(t, old, object, record, canonical) + }) + } +} + +func TestMakePackRecordNoopRequiresIdenticalBody(t *testing.T) { + root := common.BytesToHash([]byte{2}) + parent := common.BytesToHash([]byte{1}) + canonical := []dtypes.AccountStorageDiff{testStorage(1, 1, 2), testStorage(2, 2, 3)} + old := planTestBlockStorageDiff(root, parent, canonical) + body, err := rlp.EncodeToBytes(old) + require.NoError(t, err) + record, changed, err := makePackRecord(2, "key", storedObject{Body: body}, root, parent, canonical) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, packRecord{}, record) +} + +func TestMakePackRecordTreatsNilAndEmptyStorageAsSameRLP(t *testing.T) { + root := common.BytesToHash([]byte{2}) + parent := common.BytesToHash([]byte{1}) + tests := []struct { + name string + old []dtypes.AccountStorageDiff + canonical []dtypes.AccountStorageDiff + }{ + {name: "nil old empty canonical", old: nil, canonical: []dtypes.AccountStorageDiff{}}, + {name: "empty old nil canonical", old: []dtypes.AccountStorageDiff{}, canonical: nil}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + old := planTestBlockStorageDiff(root, parent, test.old) + body, err := rlp.EncodeToBytes(old) + require.NoError(t, err) + record, changed, err := makePackRecord(2, "key", storedObject{Body: body}, root, parent, test.canonical) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, packRecord{}, record) + }) + } +} + +func TestMakePackRecordRejectsInvalidObject(t *testing.T) { + root := common.BytesToHash([]byte{2}) + parent := common.BytesToHash([]byte{1}) + canonical := []dtypes.AccountStorageDiff{testStorage(1, 1, 2)} + + _, _, err := makePackRecord(2, "key", storedObject{Body: []byte{0xff}}, root, parent, canonical) + require.ErrorContains(t, err, "decode state diff") + + diff := dtypes.BlockStorageDiff{Hash: common.BytesToHash([]byte{3}), ParentHash: parent} + body, encodeErr := rlp.EncodeToBytes(diff) + require.NoError(t, encodeErr) + _, _, err = makePackRecord(2, "key", storedObject{Body: body}, root, parent, canonical) + require.ErrorContains(t, err, "root mismatch") + + valid := dtypes.BlockStorageDiff{Hash: root, ParentHash: parent} + body, encodeErr = rlp.EncodeToBytes(valid) + require.NoError(t, encodeErr) + body = append(body, 0x80) + _, _, err = makePackRecord(2, "key", storedObject{Body: body}, root, parent, canonical) + require.Error(t, err) +} + +func TestPlanObjectAnalysisReachesTargetRateAtTwentyMillisecondLatency(t *testing.T) { + _, _, records, objects := makeSealedPlanRecords(t, 1000) + tasks := make([]planTask, 0, len(records)) + for _, record := range records { + var target dtypes.BlockStorageDiff + require.NoError(t, rlp.DecodeBytes(record.NewBody, &target)) + tasks = append(tasks, planTask{ + height: record.Height, root: target.Hash, parent: target.ParentHash, + key: record.Key, canonical: target.StorageDiff, bytes: maxObjectOperationBytes, + }) + } + store := &fakeObjectStore{objects: objects, getDelay: 20 * time.Millisecond} + options := defaultParallelOptions() + options.Concurrency = 64 + options.Window = 256 + limiter, err := newByteLimiter(options.MaxInFlightBytes) + require.NoError(t, err) + started := time.Now() + err = runOrderedPipeline(context.Background(), 2, options.Concurrency, options.Window, + func(ctx context.Context, emit func(uint64, planTask) error) error { + for _, task := range tasks { + if err := limiter.Acquire(ctx, task.bytes); err != nil { + return err + } + if err := emit(task.height, task); err != nil { + limiter.Release(task.bytes) + return err + } + } + return nil + }, + func(ctx context.Context, task planTask) (planOutcome, error) { + outcome, err := processPlanTask(ctx, defaultBucket, task, store) + if err != nil { + limiter.Release(task.bytes) + return planOutcome{}, err + } + outcome.bytes = task.bytes + return outcome, nil + }, + func(_ uint64, outcome planOutcome) error { + limiter.Release(outcome.bytes) + require.True(t, outcome.changed) + return nil + }, + ) + require.NoError(t, err) + rate := float64(len(tasks)) / time.Since(started).Seconds() + t.Logf("plan analysis rate %.2f objects/s", rate) + require.GreaterOrEqual(t, rate, float64(1000)) +} diff --git a/cmd/statediff-rewriter/preflight.go b/cmd/statediff-rewriter/preflight.go new file mode 100644 index 00000000..d62f58e9 --- /dev/null +++ b/cmd/statediff-rewriter/preflight.go @@ -0,0 +1,181 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rlp" + dtypes "github.com/evmos/ethermint/debank/types" +) + +func validatePlanForWriteContext(ctx context.Context, dir string, manifest planManifest) error { + if ctx == nil { + return fmt.Errorf("plan preflight context is required") + } + if err := validateSortedRootsContext(ctx, filepath.Join(dir, manifest.RootIndex), manifest); err != nil { + return err + } + roots, err := newHeightRootStream(filepath.Join(dir, manifest.HeightRootIndex), uint64(manifest.FirstHeight)) + if err != nil { + return err + } + defer roots.Close() + + stream := newPackStream(dir, manifest) + defer stream.Close() + var currentHeight uint64 + var currentRoot, parentRoot common.Hash + var slotsAdded, slotsRemoved, slotsChanged uint64 + var oldBytes, newBytes, changedCanonical, changedConflict int64 + for { + if err := ctx.Err(); err != nil { + return err + } + record, ok, err := stream.Next() + if err != nil { + return fmt.Errorf("preflight pack: %w", err) + } + if !ok { + break + } + for currentHeight < record.Height { + root, err := roots.Next() + if err != nil { + return fmt.Errorf("preflight roots for height %d: %w", record.Height, err) + } + currentHeight = root.Height + parentRoot, currentRoot = currentRoot, root.Root + } + if currentHeight != record.Height { + return fmt.Errorf("pack height %d precedes root height %d", record.Height, currentHeight) + } + if currentRoot == parentRoot { + return fmt.Errorf("pack height %d uses an equal-root object key", record.Height) + } + wantKey := fmt.Sprintf("%s/%s/stateDiff", manifest.Prefix, strings.ToLower(currentRoot.Hex())) + if record.Key != wantKey { + return fmt.Errorf("pack height %d key %s does not match root key %s", record.Height, record.Key, wantKey) + } + if record.OldETag == "" || record.OldSHA256 == record.NewSHA256 { + return fmt.Errorf("pack record %d has an empty old ETag or equal old/new body", record.Ordinal) + } + if err := validatePackRecordBodies(record, currentRoot, parentRoot); err != nil { + return fmt.Errorf("preflight record %d: %w", record.Ordinal, err) + } + if slotsAdded+record.SlotsAdded < slotsAdded || slotsRemoved+record.SlotsRemoved < slotsRemoved || + slotsChanged+record.SlotsChanged < slotsChanged { + return fmt.Errorf("preflight pack slot counters overflow") + } + slotsAdded += record.SlotsAdded + slotsRemoved += record.SlotsRemoved + slotsChanged += record.SlotsChanged + oldBytes += int64(len(record.OldBody)) + newBytes += int64(len(record.NewBody)) + if record.NoncanonicalOld { + changedCanonical++ + } + if record.ConflictingOld { + changedConflict++ + } + } + for currentHeight < uint64(manifest.FinalHeight) { + if err := ctx.Err(); err != nil { + return err + } + root, err := roots.Next() + if err != nil { + return fmt.Errorf("finish preflight roots: %w", err) + } + currentHeight = root.Height + } + if err := roots.Finish(uint64(manifest.FinalHeight)); err != nil { + return err + } + if manifest.SlotsAdded != slotsAdded || manifest.SlotsRemoved != slotsRemoved || manifest.SlotsChanged != slotsChanged || + manifest.NewBytes != newBytes || manifest.ChangedCanonical != changedCanonical || manifest.ChangedConflict != changedConflict { + return fmt.Errorf("preflight pack statistics differ from manifest") + } + if manifest.OldBytes < oldBytes { + return fmt.Errorf("preflight changed old bodies have %d bytes, manifest scanned %d", oldBytes, manifest.OldBytes) + } + return nil +} + +func validatePackRecordBodies(record packRecord, root, parent common.Hash) error { + if err := verifyRecordTarget(record, record.OldBody, record.OldSHA256); err != nil { + return fmt.Errorf("old body: %w", err) + } + if err := verifyRecordTarget(record, record.NewBody, record.NewSHA256); err != nil { + return fmt.Errorf("new body: %w", err) + } + if err := verifyRawRetainedFields(record.OldBody, record.NewBody); err != nil { + return err + } + var oldDiff, newDiff dtypes.BlockStorageDiff + if err := rlp.DecodeBytes(record.OldBody, &oldDiff); err != nil { + return fmt.Errorf("decode old body: %w", err) + } + if err := rlp.DecodeBytes(record.NewBody, &newDiff); err != nil { + return fmt.Errorf("decode new body: %w", err) + } + for label, diff := range map[string]dtypes.BlockStorageDiff{"old": oldDiff, "new": newDiff} { + if diff.Hash != root || diff.ParentHash != parent { + return fmt.Errorf("%s body root mismatch", label) + } + } + if record.SlotsAdded != 0 || record.SlotsRemoved != 0 || record.SlotsChanged != 0 || + record.NoncanonicalOld || record.ConflictingOld { + return fmt.Errorf("pack record contains disabled storage comparison statistics") + } + return nil +} + +func validateSortedRootsContext(ctx context.Context, path string, manifest planManifest) error { + if ctx == nil { + return fmt.Errorf("validate sorted roots context is required") + } + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + reader := bufio.NewReaderSize(file, 1<<20) + var previous rootRecord + var count int64 + for { + if err := ctx.Err(); err != nil { + return err + } + record, err := readRootRecord(reader) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return fmt.Errorf("read sorted root index: %w", err) + } + if record.Height < uint64(manifest.FirstHeight) || record.Height > uint64(manifest.FinalHeight) { + return fmt.Errorf("sorted root height %d is outside manifest", record.Height) + } + if count > 0 { + if !rootLess(previous, record) { + return fmt.Errorf("sorted root index is not strictly ordered") + } + if record.Root == previous.Root && record.Height != previous.Height+1 { + return fmt.Errorf("state root %s is reused by non-adjacent heights %d and %d", record.Root, previous.Height, record.Height) + } + } + previous = record + count++ + } + if count != manifest.Processed { + return fmt.Errorf("sorted root index has %d records, want %d", count, manifest.Processed) + } + return nil +} diff --git a/cmd/statediff-rewriter/provenance.go b/cmd/statediff-rewriter/provenance.go new file mode 100644 index 00000000..dd3d965b --- /dev/null +++ b/cmd/statediff-rewriter/provenance.go @@ -0,0 +1,126 @@ +package main + +import ( + "encoding/hex" + "fmt" + "os" + "strings" + "time" + + "github.com/cosmos/cosmos-sdk/version" +) + +type buildIdentity struct { + CronosCommit string + EthermintCommit string + IAVLCommit string + BuildTags string + ImageDigest string +} + +const ( + runtimeImageDigestEnvironment = "STATEDIFF_REWRITER_IMAGE_DIGEST" + unknownBuildIdentity = "unknown" +) + +var currentBuildIdentity = func() buildIdentity { + cronosCommit, ethermintCommit, iavlCommit := buildCommits() + return buildIdentity{ + CronosCommit: cronosCommit, EthermintCommit: ethermintCommit, + IAVLCommit: iavlCommit, BuildTags: version.BuildTags, + ImageDigest: os.Getenv(runtimeImageDigestEnvironment), + } +} + +func knownBuildCommit(commit string) bool { + if len(commit) < 12 || len(commit) > 64 || len(commit)%2 != 0 { + return false + } + body, err := hex.DecodeString(commit) + if err != nil { + return false + } + for _, value := range body { + if value != 0 { + return true + } + } + return false +} + +func validSHA256Digest(digest string) bool { + if len(digest) != len("sha256:")+64 || !strings.HasPrefix(digest, "sha256:") { + return false + } + _, err := hex.DecodeString(strings.TrimPrefix(digest, "sha256:")) + return err == nil +} + +func validSHA256Hex(digest string) bool { + if len(digest) != 64 { + return false + } + _, err := hex.DecodeString(digest) + return err == nil +} + +func hasRequiredBuildTags(tags string) bool { + seen := make(map[string]bool) + for _, tag := range strings.FieldsFunc(tags, func(r rune) bool { return r == ',' || r == ' ' }) { + seen[tag] = true + } + return seen["rocksdb"] && seen["grocksdb_clean_link"] +} + +func validCreatedAt(value string) bool { + if value == "" { + return false + } + _, err := time.Parse(time.RFC3339Nano, value) + return err == nil +} + +func validateBuildIdentity( + cronosCommit, ethermintCommit, iavlCommit, imageDigest, buildTags string, +) error { + if !knownBuildCommit(cronosCommit) || !knownBuildCommit(ethermintCommit) || !knownBuildCommit(iavlCommit) { + return fmt.Errorf("build commits are incomplete or malformed") + } + if !validSHA256Digest(imageDigest) { + return fmt.Errorf("image digest must be sha256 followed by 64 hexadecimal characters") + } + if !hasRequiredBuildTags(buildTags) { + return fmt.Errorf("build tags must contain rocksdb and grocksdb_clean_link") + } + return nil +} + +func requireRuntimeBuildIdentity(manifest planManifest) error { + return requireRuntimeBuild( + manifest.CronosCommit, manifest.EthermintCommit, manifest.IAVLCommit, manifest.ImageDigest, manifest.BuildTags, + ) +} + +func requireRuntimeBuild(cronosCommit, ethermintCommit, iavlCommit, imageDigest, buildTags string) error { + current := currentBuildIdentity() + if !validSHA256Digest(current.ImageDigest) { + return fmt.Errorf("%s must contain the running image sha256 digest", runtimeImageDigestEnvironment) + } + if err := validateBuildIdentity( + current.CronosCommit, current.EthermintCommit, current.IAVLCommit, current.ImageDigest, current.BuildTags, + ); err != nil { + return fmt.Errorf("runtime build identity: %w", err) + } + if err := validateBuildIdentity(cronosCommit, ethermintCommit, iavlCommit, imageDigest, buildTags); err != nil { + return fmt.Errorf("requested build identity: %w", err) + } + if current.CronosCommit != cronosCommit || current.EthermintCommit != ethermintCommit || + current.IAVLCommit != iavlCommit || current.BuildTags != buildTags || current.ImageDigest != imageDigest { + return fmt.Errorf( + "runtime build identity differs from requested identity: got %s/%s/%s tags=%q image=%q, want %s/%s/%s tags=%q image=%q", + current.CronosCommit, current.EthermintCommit, current.IAVLCommit, current.BuildTags, current.ImageDigest, + cronosCommit, ethermintCommit, iavlCommit, buildTags, imageDigest, + ) + } + return nil +} diff --git a/cmd/statediff-rewriter/provenance_test.go b/cmd/statediff-rewriter/provenance_test.go new file mode 100644 index 00000000..0de93368 --- /dev/null +++ b/cmd/statediff-rewriter/provenance_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + testCronosCommit = "1111111111111111111111111111111111111111" + testEthermintCommit = "222222222222" + testIAVLCommit = "333333333333" + testBuildTags = "rocksdb,grocksdb_clean_link" +) + +var testImageDigest = "sha256:" + strings.Repeat("4", 64) + +func useTestRuntimeBuildIdentity(t *testing.T) { + t.Helper() + previous := currentBuildIdentity + currentBuildIdentity = func() buildIdentity { + return buildIdentity{ + CronosCommit: testCronosCommit, EthermintCommit: testEthermintCommit, + IAVLCommit: testIAVLCommit, BuildTags: testBuildTags, ImageDigest: testImageDigest, + } + } + t.Cleanup(func() { currentBuildIdentity = previous }) +} + +func TestValidateBuildIdentity(t *testing.T) { + require.NoError(t, validateBuildIdentity( + testCronosCommit, testEthermintCommit, testIAVLCommit, testImageDigest, testBuildTags, + )) + require.Error(t, validateBuildIdentity("unknown", testEthermintCommit, testIAVLCommit, testImageDigest, testBuildTags)) + require.Error(t, validateBuildIdentity(strings.Repeat("0", 40), testEthermintCommit, testIAVLCommit, testImageDigest, testBuildTags)) + require.Error(t, validateBuildIdentity(testCronosCommit, testEthermintCommit, testIAVLCommit, "sha256:test", testBuildTags)) + require.Error(t, validateBuildIdentity(testCronosCommit, testEthermintCommit, testIAVLCommit, testImageDigest, "rocksdb")) +} + +func TestRequireRuntimeBuildIdentityRejectsEveryMismatch(t *testing.T) { + manifest := planManifest{ + CronosCommit: testCronosCommit, EthermintCommit: testEthermintCommit, IAVLCommit: testIAVLCommit, + BuildTags: testBuildTags, ImageDigest: testImageDigest, + } + base := buildIdentity{ + CronosCommit: testCronosCommit, EthermintCommit: testEthermintCommit, IAVLCommit: testIAVLCommit, + BuildTags: testBuildTags, ImageDigest: testImageDigest, + } + tests := []struct { + name string + mutate func(*buildIdentity) + }{ + {name: "Cronos", mutate: func(identity *buildIdentity) { identity.CronosCommit = strings.Repeat("a", 40) }}, + {name: "Ethermint", mutate: func(identity *buildIdentity) { identity.EthermintCommit = strings.Repeat("a", 12) }}, + {name: "IAVL", mutate: func(identity *buildIdentity) { identity.IAVLCommit = strings.Repeat("a", 12) }}, + {name: "build tags", mutate: func(identity *buildIdentity) { identity.BuildTags = "rocksdb" }}, + {name: "image", mutate: func(identity *buildIdentity) { identity.ImageDigest = "sha256:" + strings.Repeat("a", 64) }}, + {name: "missing runtime image", mutate: func(identity *buildIdentity) { identity.ImageDigest = "" }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + identity := base + test.mutate(&identity) + previous := currentBuildIdentity + currentBuildIdentity = func() buildIdentity { return identity } + defer func() { currentBuildIdentity = previous }() + require.Error(t, requireRuntimeBuildIdentity(manifest)) + }) + } +} + +func TestRuntimeIdentityMismatchPrecedesObjectAccess(t *testing.T) { + manifestPath, _, record, object := makeSealedPlan(t) + allowTestReadonlyFilesystems(t) + previous := currentBuildIdentity + currentBuildIdentity = func() buildIdentity { + return buildIdentity{ + CronosCommit: testCronosCommit, EthermintCommit: testEthermintCommit, + IAVLCommit: testIAVLCommit, BuildTags: testBuildTags, + ImageDigest: "sha256:" + strings.Repeat("a", 64), + } + } + t.Cleanup(func() { currentBuildIdentity = previous }) + store := &fakeObjectStore{objects: map[string]storedObject{record.Key: object}} + _, err := runWriteMode( + context.Background(), manifestPath, filepath.Join(t.TempDir(), "rollback.json"), "", "rollback", store, + ) + require.ErrorContains(t, err, "runtime build identity differs") + require.Empty(t, store.gets) + require.Zero(t, store.puts) +} diff --git a/cmd/statediff-rewriter/rename_noreplace_darwin.go b/cmd/statediff-rewriter/rename_noreplace_darwin.go new file mode 100644 index 00000000..a28d6b4b --- /dev/null +++ b/cmd/statediff-rewriter/rename_noreplace_darwin.go @@ -0,0 +1,9 @@ +//go:build darwin + +package main + +import "golang.org/x/sys/unix" + +func renameNoReplace(source, target string) error { + return unix.RenameatxNp(unix.AT_FDCWD, source, unix.AT_FDCWD, target, unix.RENAME_EXCL) +} diff --git a/cmd/statediff-rewriter/rename_noreplace_linux.go b/cmd/statediff-rewriter/rename_noreplace_linux.go new file mode 100644 index 00000000..f13235e2 --- /dev/null +++ b/cmd/statediff-rewriter/rename_noreplace_linux.go @@ -0,0 +1,9 @@ +//go:build linux + +package main + +import "golang.org/x/sys/unix" + +func renameNoReplace(source, target string) error { + return unix.Renameat2(unix.AT_FDCWD, source, unix.AT_FDCWD, target, unix.RENAME_NOREPLACE) +} diff --git a/cmd/statediff-rewriter/rename_noreplace_other.go b/cmd/statediff-rewriter/rename_noreplace_other.go new file mode 100644 index 00000000..2e55c952 --- /dev/null +++ b/cmd/statediff-rewriter/rename_noreplace_other.go @@ -0,0 +1,9 @@ +//go:build !darwin && !linux + +package main + +import "fmt" + +func renameNoReplace(_, _ string) error { + return fmt.Errorf("atomic no-replace rename is unsupported on this platform") +} diff --git a/cmd/statediff-rewriter/rlp_replace.go b/cmd/statediff-rewriter/rlp_replace.go new file mode 100644 index 00000000..6cc7c648 --- /dev/null +++ b/cmd/statediff-rewriter/rlp_replace.go @@ -0,0 +1,58 @@ +package main + +import ( + "bytes" + "fmt" + + "github.com/ethereum/go-ethereum/rlp" + dtypes "github.com/evmos/ethermint/debank/types" +) + +const ( + blockStorageDiffFieldCount = 6 + storageDiffFieldIndex = 4 +) + +func blockStorageDiffRawFields(body []byte) ([]rlp.RawValue, error) { + var fields []rlp.RawValue + if err := rlp.DecodeBytes(body, &fields); err != nil { + return nil, err + } + if len(fields) != blockStorageDiffFieldCount { + return nil, fmt.Errorf("block storage diff has %d RLP fields, want %d", len(fields), blockStorageDiffFieldCount) + } + return fields, nil +} + +func replaceStorageDiffRLP(body []byte, storage []dtypes.AccountStorageDiff) ([]byte, error) { + fields, err := blockStorageDiffRawFields(body) + if err != nil { + return nil, err + } + storageBody, err := rlp.EncodeToBytes(storage) + if err != nil { + return nil, err + } + fields[storageDiffFieldIndex] = rlp.RawValue(storageBody) + return rlp.EncodeToBytes(fields) +} + +func verifyRawRetainedFields(oldBody, newBody []byte) error { + oldFields, err := blockStorageDiffRawFields(oldBody) + if err != nil { + return fmt.Errorf("decode old raw fields: %w", err) + } + newFields, err := blockStorageDiffRawFields(newBody) + if err != nil { + return fmt.Errorf("decode new raw fields: %w", err) + } + for index := range oldFields { + if index == storageDiffFieldIndex { + continue + } + if !bytes.Equal(oldFields[index], newFields[index]) { + return fmt.Errorf("retained RLP field %d changed", index) + } + } + return nil +} diff --git a/cmd/statediff-rewriter/rlp_replace_test.go b/cmd/statediff-rewriter/rlp_replace_test.go new file mode 100644 index 00000000..5051971f --- /dev/null +++ b/cmd/statediff-rewriter/rlp_replace_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "bytes" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rlp" + dtypes "github.com/evmos/ethermint/debank/types" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" +) + +func TestReplaceStorageDiffRLPPreservesEveryOtherRawField(t *testing.T) { + old := dtypes.BlockStorageDiff{ + Hash: common.HexToHash("0x01"), ParentHash: common.HexToHash("0x02"), + NewAccounts: []dtypes.NewAccount{{ + Address: common.HexToHash("0x03"), Balance: uint256.NewInt(4), Nonce: 5, + CodeHash: common.HexToHash("0x06"), + }}, + DeletedAccounts: []common.Hash{common.HexToHash("0x07")}, + StorageDiff: []dtypes.AccountStorageDiff{{ + Address: common.HexToHash("0x08"), + Values: []dtypes.IndexValuePair{{Index: common.HexToHash("0x09"), Value: uint256.NewInt(10)}}, + }}, + NewCodes: []dtypes.NewCode{{CodeHash: common.HexToHash("0x0b"), Code: []byte{0x60, 0x00}}}, + } + oldBody, err := rlp.EncodeToBytes(old) + require.NoError(t, err) + canonical := []dtypes.AccountStorageDiff{{ + Address: common.HexToHash("0x0c"), + Values: []dtypes.IndexValuePair{{Index: common.HexToHash("0x0d"), Value: uint256.NewInt(14)}}, + }} + newBody, err := replaceStorageDiffRLP(oldBody, canonical) + require.NoError(t, err) + require.NoError(t, verifyRawRetainedFields(oldBody, newBody)) + + oldFields, err := blockStorageDiffRawFields(oldBody) + require.NoError(t, err) + newFields, err := blockStorageDiffRawFields(newBody) + require.NoError(t, err) + for index := range oldFields { + if index == storageDiffFieldIndex { + require.False(t, bytes.Equal(oldFields[index], newFields[index])) + continue + } + require.Equal(t, []byte(oldFields[index]), []byte(newFields[index])) + } + var decoded dtypes.BlockStorageDiff + require.NoError(t, rlp.DecodeBytes(newBody, &decoded)) + require.Equal(t, canonical, decoded.StorageDiff) +} + +func TestVerifyRawRetainedFieldsRejectsRetainedMutation(t *testing.T) { + old := dtypes.BlockStorageDiff{Hash: common.HexToHash("0x01"), ParentHash: common.HexToHash("0x02")} + oldBody, err := rlp.EncodeToBytes(old) + require.NoError(t, err) + changed := old + changed.ParentHash = common.HexToHash("0x03") + changedBody, err := rlp.EncodeToBytes(changed) + require.NoError(t, err) + require.ErrorContains(t, verifyRawRetainedFields(oldBody, changedBody), "field 1 changed") +} diff --git a/cmd/statediff-rewriter/roots.go b/cmd/statediff-rewriter/roots.go new file mode 100644 index 00000000..cb74de1c --- /dev/null +++ b/cmd/statediff-rewriter/roots.go @@ -0,0 +1,290 @@ +package main + +import ( + "bufio" + "bytes" + "container/heap" + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + + "github.com/ethereum/go-ethereum/common" +) + +const ( + rootRecordSize = 40 + rootSortChunkRecords = 1 << 20 +) + +type rootRecord struct { + Root common.Hash + Height uint64 +} + +func writeRootRecord(writer io.Writer, root common.Hash, height uint64) error { + body := encodeRootRecord(rootRecord{Root: root, Height: height}) + return writeBytes(writer, body[:]) +} + +func encodeRootRecord(record rootRecord) [rootRecordSize]byte { + var body [rootRecordSize]byte + copy(body[:32], record.Root[:]) + binary.BigEndian.PutUint64(body[32:], record.Height) + return body +} + +func readRootRecord(reader io.Reader) (rootRecord, error) { + var body [rootRecordSize]byte + if _, err := io.ReadFull(reader, body[:]); err != nil { + return rootRecord{}, err + } + return rootRecord{Root: common.BytesToHash(body[:32]), Height: binary.BigEndian.Uint64(body[32:])}, nil +} + +func rootLess(left, right rootRecord) bool { + if comparison := bytes.Compare(left.Root[:], right.Root[:]); comparison != 0 { + return comparison < 0 + } + return left.Height < right.Height +} + +// rootMultisetSHA256 commits to the count and order-independent XOR and sum of +// the SHA-256 digest of every root record. Duplicate records affect the sum and +// count, so the result commits to a multiset rather than a set. +func rootMultisetSHA256(path string) (string, int64, error) { + return rootMultisetSHA256Context(context.Background(), path) +} + +func rootMultisetSHA256Context(ctx context.Context, path string) (string, int64, error) { + if ctx == nil { + return "", 0, fmt.Errorf("root multiset context is required") + } + file, err := os.Open(path) + if err != nil { + return "", 0, err + } + defer file.Close() + reader := bufio.NewReaderSize(file, 1<<20) + var xor, sum [sha256.Size]byte + var count uint64 + for { + if err := ctx.Err(); err != nil { + return "", 0, err + } + record, err := readRootRecord(reader) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", 0, fmt.Errorf("read root multiset: %w", err) + } + body := encodeRootRecord(record) + digest := sha256.Sum256(body[:]) + carry := uint16(0) + for i := len(digest) - 1; i >= 0; i-- { + xor[i] ^= digest[i] + total := uint16(sum[i]) + uint16(digest[i]) + carry + sum[i] = byte(total) + carry = total >> 8 + } + count++ + } + var commitment [8 + 2*sha256.Size]byte + binary.BigEndian.PutUint64(commitment[:8], count) + copy(commitment[8:8+sha256.Size], xor[:]) + copy(commitment[8+sha256.Size:], sum[:]) + result := sha256.Sum256(commitment[:]) + return hex.EncodeToString(result[:]), int64(count), nil +} + +func checkDuplicateRoots(rawPath, dir string) (string, string, error) { + return checkDuplicateRootsContext(context.Background(), rawPath, dir) +} + +func checkDuplicateRootsContext(ctx context.Context, rawPath, dir string) (string, string, error) { + if ctx == nil { + return "", "", fmt.Errorf("root sort context is required") + } + raw, err := os.Open(rawPath) + if err != nil { + return "", "", err + } + var chunks []string + for chunkNumber := 0; ; chunkNumber++ { + records := make([]rootRecord, 0, rootSortChunkRecords) + for len(records) < cap(records) { + if err := ctx.Err(); err != nil { + _ = raw.Close() + return "", "", err + } + record, err := readRootRecord(raw) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + _ = raw.Close() + return "", "", fmt.Errorf("read root index: %w", err) + } + records = append(records, record) + } + if len(records) == 0 { + break + } + sort.Slice(records, func(i, j int) bool { return rootLess(records[i], records[j]) }) + if err := ctx.Err(); err != nil { + _ = raw.Close() + return "", "", err + } + chunkPath := filepath.Join(dir, fmt.Sprintf("roots-sort-%06d.tmp", chunkNumber)) + chunk, err := os.OpenFile(chunkPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + _ = raw.Close() + return "", "", err + } + chunkWriter := bufio.NewWriterSize(chunk, 1<<20) + for _, record := range records { + if err := ctx.Err(); err != nil { + _ = chunk.Close() + _ = raw.Close() + return "", "", err + } + if err := writeRootRecord(chunkWriter, record.Root, record.Height); err != nil { + _ = chunk.Close() + _ = raw.Close() + return "", "", err + } + } + if err := chunkWriter.Flush(); err != nil { + _ = chunk.Close() + _ = raw.Close() + return "", "", err + } + if err := chunk.Sync(); err != nil { + _ = chunk.Close() + _ = raw.Close() + return "", "", err + } + if err := chunk.Close(); err != nil { + _ = raw.Close() + return "", "", err + } + chunks = append(chunks, chunkPath) + if len(records) < rootSortChunkRecords { + break + } + } + if err := raw.Close(); err != nil { + return "", "", err + } + sortedName := "roots.sorted" + sortedPath := filepath.Join(dir, sortedName) + if err := mergeRootChunksContext(ctx, chunks, sortedPath); err != nil { + return "", "", err + } + for _, chunk := range chunks { + if err := os.Remove(chunk); err != nil { + return "", "", err + } + } + hash, _, err := hashFileContext(ctx, sortedPath) + return sortedName, hash, err +} + +type rootHeapItem struct { + record rootRecord + reader int +} + +type rootHeap []rootHeapItem + +func (h rootHeap) Len() int { return len(h) } +func (h rootHeap) Less(i, j int) bool { return rootLess(h[i].record, h[j].record) } +func (h rootHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } +func (h *rootHeap) Push(value any) { *h = append(*h, value.(rootHeapItem)) } +func (h *rootHeap) Pop() any { + old := *h + item := old[len(old)-1] + *h = old[:len(old)-1] + return item +} + +func mergeRootChunksContext(ctx context.Context, chunks []string, outputPath string) error { + if ctx == nil { + return fmt.Errorf("root merge context is required") + } + output, err := os.OpenFile(outputPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + outputWriter := bufio.NewWriterSize(output, 1<<20) + readers := make([]*bufio.Reader, len(chunks)) + files := make([]*os.File, len(chunks)) + items := &rootHeap{} + for i, path := range chunks { + if err := ctx.Err(); err != nil { + _ = output.Close() + return err + } + file, err := os.Open(path) + if err != nil { + _ = output.Close() + return err + } + files[i] = file + readers[i] = bufio.NewReader(file) + record, err := readRootRecord(readers[i]) + if err == nil { + heap.Push(items, rootHeapItem{record: record, reader: i}) + } else if !errors.Is(err, io.EOF) { + _ = output.Close() + return err + } + } + heap.Init(items) + var previous rootRecord + havePrevious := false + for items.Len() > 0 { + if err := ctx.Err(); err != nil { + _ = output.Close() + return err + } + item := heap.Pop(items).(rootHeapItem) + if havePrevious && item.record.Root == previous.Root && item.record.Height != previous.Height+1 { + _ = output.Close() + return fmt.Errorf("state root %s is reused by non-adjacent heights %d and %d", item.record.Root, previous.Height, item.record.Height) + } + if err := writeRootRecord(outputWriter, item.record.Root, item.record.Height); err != nil { + _ = output.Close() + return err + } + previous, havePrevious = item.record, true + next, err := readRootRecord(readers[item.reader]) + if err == nil { + heap.Push(items, rootHeapItem{record: next, reader: item.reader}) + } else if !errors.Is(err, io.EOF) { + _ = output.Close() + return err + } + } + for _, file := range files { + if file != nil { + _ = file.Close() + } + } + if err := outputWriter.Flush(); err != nil { + _ = output.Close() + return err + } + if err := output.Sync(); err != nil { + _ = output.Close() + return err + } + return output.Close() +} diff --git a/cmd/statediff-rewriter/roots_test.go b/cmd/statediff-rewriter/roots_test.go new file mode 100644 index 00000000..c653ba07 --- /dev/null +++ b/cmd/statediff-rewriter/roots_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func writeRootIndex(t *testing.T, path string, records ...rootRecord) { + t.Helper() + file, err := os.Create(path) + require.NoError(t, err) + for _, record := range records { + require.NoError(t, writeRootRecord(file, record.Root, record.Height)) + } + require.NoError(t, file.Close()) +} + +func TestCheckDuplicateRoots(t *testing.T) { + t.Run("adjacent equal roots", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "roots.raw") + root := common.BytesToHash([]byte{1}) + writeRootIndex(t, path, rootRecord{Root: root, Height: 3}, rootRecord{Root: root, Height: 2}) + name, hash, err := checkDuplicateRoots(path, dir) + require.NoError(t, err) + require.Equal(t, "roots.sorted", name) + require.NotEmpty(t, hash) + }) + + t.Run("non-adjacent reused root", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "roots.raw") + root := common.BytesToHash([]byte{1}) + writeRootIndex(t, path, rootRecord{Root: root, Height: 2}, rootRecord{Root: common.BytesToHash([]byte{2}), Height: 3}, rootRecord{Root: root, Height: 4}) + _, _, err := checkDuplicateRoots(path, dir) + require.ErrorContains(t, err, "non-adjacent heights") + }) +} + +func TestRootMultisetCommitmentIgnoresOrderButPreservesMultiplicity(t *testing.T) { + dir := t.TempDir() + first := filepath.Join(dir, "first") + second := filepath.Join(dir, "second") + third := filepath.Join(dir, "third") + a := rootRecord{Root: common.BytesToHash([]byte{1}), Height: 2} + b := rootRecord{Root: common.BytesToHash([]byte{2}), Height: 3} + writeRootIndex(t, first, a, b, a) + writeRootIndex(t, second, b, a, a) + writeRootIndex(t, third, b, a, b) + + firstHash, firstCount, err := rootMultisetSHA256(first) + require.NoError(t, err) + secondHash, secondCount, err := rootMultisetSHA256(second) + require.NoError(t, err) + thirdHash, thirdCount, err := rootMultisetSHA256(third) + require.NoError(t, err) + require.Equal(t, firstCount, secondCount) + require.Equal(t, firstHash, secondHash) + require.Equal(t, firstCount, thirdCount) + require.NotEqual(t, firstHash, thirdHash) +} diff --git a/cmd/statediff-rewriter/s3.go b/cmd/statediff-rewriter/s3.go new file mode 100644 index 00000000..e8ad85e5 --- /dev/null +++ b/cmd/statediff-rewriter/s3.go @@ -0,0 +1,194 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "hash/crc32" + "io" + "net/http" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/retry" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + smithyhttp "github.com/aws/smithy-go/transport/http" +) + +var ( + errObjectConflict = errors.New("object precondition conflict") + errObjectWriteUncertain = errors.New("object write result is uncertain") + crc32cTable = crc32.MakeTable(crc32.Castagnoli) +) + +type storedObject struct { + Body []byte + ETag string + Headers objectHeaders +} + +type putResult struct { + ETag string +} + +type objectStore interface { + Get(context.Context, string, string) (storedObject, error) + Put(context.Context, string, string, []byte, string, objectHeaders) (putResult, error) +} + +type s3ObjectStore struct { + readClient *s3.Client + writeClient *s3.Client +} + +func newS3ObjectStore(ctx context.Context, region string, concurrency int) (*s3ObjectStore, error) { + if concurrency < 1 || concurrency > maximumObjectConcurrency { + return nil, fmt.Errorf("S3 concurrency must be between 1 and %d", maximumObjectConcurrency) + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = concurrency * 2 + transport.MaxIdleConnsPerHost = concurrency + transport.MaxConnsPerHost = concurrency + httpClient := &http.Client{Transport: transport} + awsConfig, err := config.LoadDefaultConfig(ctx, config.WithRegion(region), config.WithHTTPClient(httpClient)) + if err != nil { + return nil, err + } + readClient := s3.NewFromConfig(awsConfig) + writeClient := s3.NewFromConfig(awsConfig, func(options *s3.Options) { + // A conditional PUT must not be retried invisibly: the first attempt can commit + // while its response is lost, and a retry with the old ETag then returns 412. + options.Retryer = retry.NewStandard(func(standard *retry.StandardOptions) { + standard.MaxAttempts = 1 + }) + }) + return &s3ObjectStore{readClient: readClient, writeClient: writeClient}, nil +} + +func (s *s3ObjectStore) Get(ctx context.Context, bucket, key string) (storedObject, error) { + output, err := s.readClient.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)}) + if err != nil { + return storedObject{}, err + } + defer output.Body.Close() + body, err := io.ReadAll(io.LimitReader(output.Body, maxObjectSize+1)) + if err != nil { + return storedObject{}, err + } + if int64(len(body)) > maxObjectSize { + return storedObject{}, fmt.Errorf("object %s exceeds %d bytes", key, maxObjectSize) + } + metadata, err := canonicalMetadata(output.Metadata) + if err != nil { + return storedObject{}, err + } + if err := validateS3ExpressObject(output); err != nil { + return storedObject{}, fmt.Errorf("object %s has unsupported attributes: %w", key, err) + } + if aws.ToString(output.ETag) == "" { + return storedObject{}, fmt.Errorf("object %s has no ETag", key) + } + return storedObject{ + Body: body, ETag: aws.ToString(output.ETag), + Headers: objectHeaders{ + Metadata: metadata, CacheControl: aws.ToString(output.CacheControl), + ContentDisposition: aws.ToString(output.ContentDisposition), ContentEncoding: aws.ToString(output.ContentEncoding), + ContentLanguage: aws.ToString(output.ContentLanguage), ContentType: aws.ToString(output.ContentType), + WebsiteRedirect: aws.ToString(output.WebsiteRedirectLocation), + StorageClass: string(output.StorageClass), ServerSideEncryption: string(output.ServerSideEncryption), + }, + }, nil +} + +func validateS3ExpressObject(output *s3.GetObjectOutput) error { + if output.StorageClass != s3types.StorageClassExpressOnezone { + return fmt.Errorf("storage class is %q, want %q", output.StorageClass, s3types.StorageClassExpressOnezone) + } + if output.ServerSideEncryption != s3types.ServerSideEncryptionAes256 { + return fmt.Errorf("server-side encryption is %q, want %q", output.ServerSideEncryption, s3types.ServerSideEncryptionAes256) + } + if output.BucketKeyEnabled != nil || output.DeleteMarker != nil || aws.ToInt32(output.MissingMeta) != 0 || output.ObjectLockLegalHoldStatus != "" || + output.ObjectLockMode != "" || output.ObjectLockRetainUntilDate != nil || output.ReplicationStatus != "" || + output.RequestCharged != "" || output.Restore != nil || output.SSECustomerAlgorithm != nil || + output.SSECustomerKeyMD5 != nil || output.SSEKMSKeyId != nil || aws.ToInt32(output.TagCount) != 0 || + output.VersionId != nil || output.WebsiteRedirectLocation != nil || output.ExpiresString != nil { + return fmt.Errorf("directory-bucket-unsupported metadata is present") + } + if output.Expiration != nil && aws.ToString(output.Expiration) != "NotImplemented" { + return fmt.Errorf("object expiration is %q", aws.ToString(output.Expiration)) + } + return nil +} + +func (s *s3ObjectStore) Put(ctx context.Context, bucket, key string, body []byte, ifMatch string, headers objectHeaders) (putResult, error) { + metadata, err := decodeMetadata(headers.Metadata) + if err != nil { + return putResult{}, err + } + checksum := crc32cBase64(body) + input := &s3.PutObjectInput{ + Bucket: aws.String(bucket), Key: aws.String(key), Body: bytes.NewReader(body), IfMatch: aws.String(ifMatch), + ChecksumAlgorithm: s3types.ChecksumAlgorithmCrc32c, ChecksumCRC32C: aws.String(checksum), Metadata: metadata, + StorageClass: s3types.StorageClass(headers.StorageClass), + ServerSideEncryption: s3types.ServerSideEncryption(headers.ServerSideEncryption), + } + setOptionalString := func(value string, target **string) { + if value != "" { + *target = aws.String(value) + } + } + setOptionalString(headers.CacheControl, &input.CacheControl) + setOptionalString(headers.ContentDisposition, &input.ContentDisposition) + setOptionalString(headers.ContentEncoding, &input.ContentEncoding) + setOptionalString(headers.ContentLanguage, &input.ContentLanguage) + setOptionalString(headers.ContentType, &input.ContentType) + setOptionalString(headers.WebsiteRedirect, &input.WebsiteRedirectLocation) + output, err := s.writeClient.PutObject(ctx, input) + if err == nil { + result, validateErr := validatePutObjectResult(output, checksum) + if validateErr != nil { + return putResult{}, fmt.Errorf("%w: %w", errObjectWriteUncertain, validateErr) + } + return result, nil + } + return putResult{}, classifyPutObjectError(err) +} + +func classifyPutObjectError(err error) error { + var responseError *smithyhttp.ResponseError + if errors.As(err, &responseError) { + status := responseError.HTTPStatusCode() + switch { + case status == http.StatusConflict || status == http.StatusPreconditionFailed: + return fmt.Errorf("%w: HTTP %d: %w", errObjectConflict, status, err) + case status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status >= http.StatusInternalServerError: + return fmt.Errorf("%w: HTTP %d: %w", errObjectWriteUncertain, status, err) + default: + return err + } + } + return fmt.Errorf("%w: %w", errObjectWriteUncertain, err) +} + +func crc32cBase64(body []byte) string { + var encoded [4]byte + binary.BigEndian.PutUint32(encoded[:], crc32.Checksum(body, crc32cTable)) + return base64.StdEncoding.EncodeToString(encoded[:]) +} + +func validatePutObjectResult(output *s3.PutObjectOutput, expectedChecksum string) (putResult, error) { + if output == nil { + return putResult{}, fmt.Errorf("PUT response is empty") + } + if aws.ToString(output.ChecksumCRC32C) != expectedChecksum { + return putResult{}, fmt.Errorf("PUT response CRC32C is %q, want %q", aws.ToString(output.ChecksumCRC32C), expectedChecksum) + } + if aws.ToString(output.ETag) == "" { + return putResult{}, fmt.Errorf("PUT response has no ETag") + } + return putResult{ETag: aws.ToString(output.ETag)}, nil +} diff --git a/cmd/statediff-rewriter/s3_integration_test.go b/cmd/statediff-rewriter/s3_integration_test.go new file mode 100644 index 00000000..5ec98c3b --- /dev/null +++ b/cmd/statediff-rewriter/s3_integration_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "os" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/stretchr/testify/require" +) + +func benchmarkEnvironmentInt(t *testing.T, name string, fallback int) int { + t.Helper() + value := os.Getenv(name) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + require.NoError(t, err) + require.Positive(t, parsed) + return parsed +} + +// TestProductionS3ReadThroughput is opt-in because it performs real, read-only +// requests against the sealed manifest's production bucket. It never writes S3. +func TestProductionS3ReadThroughput(t *testing.T) { + if os.Getenv("STATEDIFF_S3_BENCHMARK") != "1" { + t.Skip("set STATEDIFF_S3_BENCHMARK=1 to run the production read-only gate") + } + objectCount := benchmarkEnvironmentInt(t, "S3_BENCHMARK_OBJECTS", 100000) + concurrency := benchmarkEnvironmentInt(t, "S3_BENCHMARK_CONCURRENCY", defaultObjectConcurrency) + target := benchmarkEnvironmentInt(t, "S3_BENCHMARK_TARGET", 1000) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + store, err := newS3ObjectStore(ctx, defaultRegion, concurrency) + require.NoError(t, err) + + keys := make([]string, 0, objectCount) + paginator := s3.NewListObjectsV2Paginator(store.readClient, &s3.ListObjectsV2Input{ + Bucket: aws.String(defaultBucket), Prefix: aws.String(defaultPrefix + "/"), + }) + for len(keys) < objectCount && paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + require.NoError(t, err) + for _, object := range page.Contents { + if object.Key != nil && strings.HasSuffix(*object.Key, "/stateDiff") { + keys = append(keys, *object.Key) + if len(keys) == objectCount { + break + } + } + } + } + require.Len(t, keys, objectCount) + + started := time.Now() + var totalBytes atomic.Int64 + err = runOrderedPipeline(ctx, 1, concurrency, concurrency*4, + func(_ context.Context, emit func(uint64, string) error) error { + for index, key := range keys { + if err := emit(uint64(index+1), key); err != nil { + return err + } + } + return nil + }, + func(workerCtx context.Context, key string) (int, error) { + object, err := store.Get(workerCtx, defaultBucket, key) + if err != nil { + return 0, err + } + return len(object.Body), nil + }, + func(_ uint64, size int) error { + totalBytes.Add(int64(size)) + return nil + }, + ) + require.NoError(t, err) + duration := time.Since(started) + rate := float64(objectCount) / duration.Seconds() + t.Logf("objects=%d concurrency=%d bytes=%d duration=%s rate=%.2f objects/s", objectCount, concurrency, totalBytes.Load(), duration, rate) + require.GreaterOrEqual(t, rate, float64(target)) +} diff --git a/cmd/statediff-rewriter/s3_test.go b/cmd/statediff-rewriter/s3_test.go new file mode 100644 index 00000000..5ed0f9d3 --- /dev/null +++ b/cmd/statediff-rewriter/s3_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "errors" + "net/http" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + smithyhttp "github.com/aws/smithy-go/transport/http" + "github.com/stretchr/testify/require" +) + +func validS3ExpressGetOutput() *s3.GetObjectOutput { + return &s3.GetObjectOutput{ + StorageClass: s3types.StorageClassExpressOnezone, + ServerSideEncryption: s3types.ServerSideEncryptionAes256, + Expiration: aws.String("NotImplemented"), + } +} + +func TestValidateS3ExpressObject(t *testing.T) { + require.NoError(t, validateS3ExpressObject(validS3ExpressGetOutput())) + + invalidClass := validS3ExpressGetOutput() + invalidClass.StorageClass = s3types.StorageClassStandard + require.ErrorContains(t, validateS3ExpressObject(invalidClass), "storage class") + + withTags := validS3ExpressGetOutput() + withTags.TagCount = aws.Int32(1) + require.ErrorContains(t, validateS3ExpressObject(withTags), "unsupported metadata") + + withKMS := validS3ExpressGetOutput() + withKMS.SSEKMSKeyId = aws.String("key") + require.ErrorContains(t, validateS3ExpressObject(withKMS), "unsupported metadata") + + withExpiration := validS3ExpressGetOutput() + withExpiration.Expiration = aws.String("expiry-date=tomorrow") + require.ErrorContains(t, validateS3ExpressObject(withExpiration), "object expiration") +} + +func TestCRC32CBase64(t *testing.T) { + require.Equal(t, "4waSgw==", crc32cBase64([]byte("123456789"))) +} + +func TestValidatePutObjectResult(t *testing.T) { + checksum := crc32cBase64([]byte("body")) + result, err := validatePutObjectResult(&s3.PutObjectOutput{ + ChecksumCRC32C: aws.String(checksum), + ETag: aws.String("etag-new"), + }, checksum) + require.NoError(t, err) + require.Equal(t, "etag-new", result.ETag) + + _, err = validatePutObjectResult(nil, checksum) + require.ErrorContains(t, err, "empty") + _, err = validatePutObjectResult(&s3.PutObjectOutput{ETag: aws.String("etag-new")}, checksum) + require.ErrorContains(t, err, "CRC32C") + _, err = validatePutObjectResult(&s3.PutObjectOutput{ + ChecksumCRC32C: aws.String("different"), ETag: aws.String("etag-new"), + }, checksum) + require.ErrorContains(t, err, "CRC32C") + _, err = validatePutObjectResult(&s3.PutObjectOutput{ChecksumCRC32C: aws.String(checksum)}, checksum) + require.ErrorContains(t, err, "ETag") +} + +func TestClassifyPutObjectError(t *testing.T) { + responseError := func(status int) error { + return &smithyhttp.ResponseError{ + Response: &smithyhttp.Response{Response: &http.Response{StatusCode: status}}, + Err: errors.New("request failed"), + } + } + for _, status := range []int{http.StatusConflict, http.StatusPreconditionFailed} { + require.ErrorIs(t, classifyPutObjectError(responseError(status)), errObjectConflict) + } + for _, status := range []int{http.StatusRequestTimeout, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusServiceUnavailable} { + require.ErrorIs(t, classifyPutObjectError(responseError(status)), errObjectWriteUncertain) + } + definite := responseError(http.StatusForbidden) + require.Same(t, definite, classifyPutObjectError(definite)) + require.ErrorIs(t, classifyPutObjectError(errors.New("connection reset")), errObjectWriteUncertain) +} diff --git a/cmd/statediff-rewriter/stream.go b/cmd/statediff-rewriter/stream.go new file mode 100644 index 00000000..f50e4573 --- /dev/null +++ b/cmd/statediff-rewriter/stream.go @@ -0,0 +1,240 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/ethereum/go-ethereum/rlp" +) + +type packStream struct { + dir string + manifest planManifest + chunkIndex int + pack *os.File + index *os.File + packReader *bufio.Reader + indexReader *bufio.Reader + chunkRecords int64 + offset int64 + expectedOrdinal uint64 + previousHeight uint64 + peeked *packRecord + done bool +} + +func newPackStream(dir string, manifest planManifest) *packStream { + return &packStream{dir: dir, manifest: manifest, expectedOrdinal: 1} +} + +func (stream *packStream) openChunk() error { + if stream.pack != nil || stream.chunkIndex >= len(stream.manifest.Chunks) { + return nil + } + chunk := stream.manifest.Chunks[stream.chunkIndex] + if chunk.Number != stream.chunkIndex || chunk.Records <= 0 { + return fmt.Errorf("invalid pack chunk %d metadata", stream.chunkIndex) + } + pack, err := os.Open(filepath.Join(stream.dir, chunk.Pack)) + if err != nil { + return err + } + index, err := os.Open(filepath.Join(stream.dir, chunk.Index)) + if err != nil { + _ = pack.Close() + return err + } + stream.pack, stream.index = pack, index + stream.packReader, stream.indexReader = bufio.NewReader(pack), bufio.NewReader(index) + stream.chunkRecords, stream.offset = 0, 0 + return nil +} + +func (stream *packStream) closeChunk() error { + if stream.pack == nil { + return nil + } + chunk := stream.manifest.Chunks[stream.chunkIndex] + if stream.chunkRecords != chunk.Records { + return fmt.Errorf("pack chunk %s has %d records, want %d", chunk.Pack, stream.chunkRecords, chunk.Records) + } + if _, err := stream.packReader.Peek(1); !errors.Is(err, io.EOF) { + if err == nil { + return fmt.Errorf("pack chunk %s has trailing data", chunk.Pack) + } + return err + } + if _, err := stream.indexReader.Peek(1); !errors.Is(err, io.EOF) { + if err == nil { + return fmt.Errorf("pack index %s has trailing data", chunk.Index) + } + return err + } + packErr := stream.pack.Close() + indexErr := stream.index.Close() + stream.pack, stream.index = nil, nil + stream.packReader, stream.indexReader = nil, nil + stream.chunkIndex++ + return errors.Join(packErr, indexErr) +} + +func (stream *packStream) read() (packRecord, bool, error) { + for { + if stream.done { + return packRecord{}, false, nil + } + if err := stream.openChunk(); err != nil { + return packRecord{}, false, err + } + if stream.pack == nil { + stream.done = true + if int64(stream.expectedOrdinal-1) != stream.manifest.Changed { + return packRecord{}, false, fmt.Errorf("pack has %d records, manifest has %d", stream.expectedOrdinal-1, stream.manifest.Changed) + } + return packRecord{}, false, nil + } + chunk := stream.manifest.Chunks[stream.chunkIndex] + if stream.chunkRecords == chunk.Records { + if err := stream.closeChunk(); err != nil { + return packRecord{}, false, err + } + continue + } + + start := stream.offset + var header [8]byte + if _, err := io.ReadFull(stream.packReader, header[:]); err != nil { + return packRecord{}, false, fmt.Errorf("read pack header %s: %w", chunk.Pack, err) + } + length := binary.BigEndian.Uint64(header[:]) + if length == 0 || length > uint64(maxObjectOperationBytes) { + return packRecord{}, false, fmt.Errorf("invalid pack record length %d", length) + } + body := make([]byte, length) + if _, err := io.ReadFull(stream.packReader, body); err != nil { + return packRecord{}, false, fmt.Errorf("read pack record %s: %w", chunk.Pack, err) + } + line, err := stream.indexReader.ReadBytes('\n') + if err != nil { + return packRecord{}, false, fmt.Errorf("read pack index %s: %w", chunk.Index, err) + } + var entry indexRecord + decoder := json.NewDecoder(bytes.NewReader(line)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&entry); err != nil { + return packRecord{}, false, fmt.Errorf("decode pack index %s: %w", chunk.Index, err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return packRecord{}, false, fmt.Errorf("pack index %s has trailing JSON", chunk.Index) + } + + var record packRecord + if err := rlp.DecodeBytes(body, &record); err != nil { + return packRecord{}, false, fmt.Errorf("decode pack record %s: %w", chunk.Pack, err) + } + framedLength := length + 8 + if record.Schema != packSchema || record.Ordinal != stream.expectedOrdinal { + return packRecord{}, false, fmt.Errorf("unexpected pack record schema/ordinal %d/%d", record.Schema, record.Ordinal) + } + if record.Height < uint64(stream.manifest.FirstHeight) || record.Height > uint64(stream.manifest.FinalHeight) || + (stream.previousHeight != 0 && record.Height <= stream.previousHeight) { + return packRecord{}, false, fmt.Errorf("pack record height %d is outside or out of order for range %d-%d", record.Height, stream.manifest.FirstHeight, stream.manifest.FinalHeight) + } + if entry.Ordinal != record.Ordinal || entry.Height != record.Height || entry.Offset != start || entry.Length != framedLength || + entry.Key != record.Key || entry.OldSHA256 != hex.EncodeToString(record.OldSHA256[:]) || + entry.NewSHA256 != hex.EncodeToString(record.NewSHA256[:]) || entry.SlotsAdded != record.SlotsAdded || + entry.SlotsRemoved != record.SlotsRemoved || entry.SlotsChanged != record.SlotsChanged { + return packRecord{}, false, fmt.Errorf("pack index %s disagrees with ordinal %d", chunk.Index, record.Ordinal) + } + if sha256Hash(record.OldBody) != record.OldSHA256 || sha256Hash(record.NewBody) != record.NewSHA256 { + return packRecord{}, false, fmt.Errorf("pack record %d body hash mismatch", record.Ordinal) + } + + stream.offset += int64(framedLength) + stream.chunkRecords++ + stream.expectedOrdinal++ + stream.previousHeight = record.Height + return record, true, nil + } +} + +func (stream *packStream) Peek() (packRecord, bool, error) { + if stream.peeked != nil { + return *stream.peeked, true, nil + } + record, ok, err := stream.read() + if err != nil || !ok { + return packRecord{}, ok, err + } + stream.peeked = &record + return record, true, nil +} + +func (stream *packStream) Next() (packRecord, bool, error) { + if stream.peeked != nil { + record := *stream.peeked + stream.peeked = nil + return record, true, nil + } + return stream.read() +} + +func (stream *packStream) Close() error { + if stream.pack == nil { + return nil + } + packErr := stream.pack.Close() + indexErr := stream.index.Close() + stream.pack, stream.index = nil, nil + return errors.Join(packErr, indexErr) +} + +type heightRootStream struct { + file *os.File + reader *bufio.Reader + expected uint64 +} + +func newHeightRootStream(path string, firstHeight uint64) (*heightRootStream, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + return &heightRootStream{file: file, reader: bufio.NewReaderSize(file, 1<<20), expected: firstHeight}, nil +} + +func (stream *heightRootStream) Next() (rootRecord, error) { + record, err := readRootRecord(stream.reader) + if err != nil { + return rootRecord{}, err + } + if record.Height != stream.expected { + return rootRecord{}, fmt.Errorf("height root index has height %d, want %d", record.Height, stream.expected) + } + stream.expected++ + return record, nil +} + +func (stream *heightRootStream) Finish(finalHeight uint64) error { + if stream.expected != finalHeight+1 { + return fmt.Errorf("height root index ended before height %d", finalHeight) + } + if _, err := stream.reader.Peek(1); !errors.Is(err, io.EOF) { + if err == nil { + return fmt.Errorf("height root index has trailing records") + } + return err + } + return nil +} + +func (stream *heightRootStream) Close() error { return stream.file.Close() } diff --git a/cmd/statediff-rewriter/strict_json.go b/cmd/statediff-rewriter/strict_json.go new file mode 100644 index 00000000..ed607746 --- /dev/null +++ b/cmd/statediff-rewriter/strict_json.go @@ -0,0 +1,25 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" +) + +func decodeStrictJSON(body []byte, target any, label string) error { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return fmt.Errorf("decode %s: %w", label, err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return fmt.Errorf("%s has trailing JSON", label) + } + return fmt.Errorf("decode trailing %s: %w", label, err) + } + return nil +} diff --git a/cmd/statediff-rewriter/strict_json_test.go b/cmd/statediff-rewriter/strict_json_test.go new file mode 100644 index 00000000..e5f3f206 --- /dev/null +++ b/cmd/statediff-rewriter/strict_json_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDecodeStrictJSONRejectsUnknownAndTrailingFields(t *testing.T) { + type document struct { + Schema string `json:"schema"` + } + var decoded document + require.NoError(t, decodeStrictJSON([]byte(`{"schema":"v1"}`), &decoded, "document")) + require.Equal(t, "v1", decoded.Schema) + + require.ErrorContains( + t, decodeStrictJSON([]byte(`{"schema":"v1","extra":true}`), &decoded, "document"), "unknown field", + ) + require.ErrorContains( + t, decodeStrictJSON([]byte(`{"schema":"v1"} {"schema":"v2"}`), &decoded, "document"), "trailing JSON", + ) +} diff --git a/cmd/statediff-rewriter/write_journal.go b/cmd/statediff-rewriter/write_journal.go new file mode 100644 index 00000000..dab487dd --- /dev/null +++ b/cmd/statediff-rewriter/write_journal.go @@ -0,0 +1,212 @@ +package main + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +const ( + writeJournalSchema = "statediff-rewriter-write-journal/v1" + writeJournalIssued = "issued" + writeJournalObserved = "observed" + writeOutcomeConfirmed = "confirmed" + writeOutcomeUncertain = "uncertain" + writeOutcomeReconciled = "reconciled" +) + +type writeIntent struct { + Operation string `json:"operation"` + Ordinal uint64 `json:"ordinal"` + Height uint64 `json:"height"` + Key string `json:"key"` + OldSHA256 string `json:"expected_old_sha256"` + NewSHA256 string `json:"expected_new_sha256"` + IfMatch string `json:"if_match"` + PUTAttempts uint64 `json:"put_attempts"` + ConfirmedPUTs uint64 `json:"confirmed_puts"` + UncertainPUTs uint64 `json:"uncertain_puts_verified"` + Conflicts uint64 `json:"conditional_conflicts,omitempty"` +} + +type writeObservedResult struct { + Ordinal uint64 `json:"ordinal"` + ObservedSHA256 string `json:"observed_sha256"` + PostPUTETag string `json:"post_put_etag"` + Outcome string `json:"outcome"` +} + +type writeJournal struct { + Schema string `json:"schema"` + Checksum string `json:"checksum"` + RunID string `json:"run_id"` + ManifestHash string `json:"manifest_sha256"` + Operation string `json:"operation"` + Start uint64 `json:"start_ordinal"` + End uint64 `json:"end_ordinal"` + BatchRecords uint64 `json:"batch_records"` + BatchBytes int64 `json:"batch_estimated_bytes"` + AlreadyTarget uint64 `json:"already_target"` + State string `json:"state"` + Intents []writeIntent `json:"intents"` + Results []writeObservedResult `json:"results,omitempty"` +} + +func writeJournalPath(checkpointPath string) string { + return checkpointPath + ".write-journal.json" +} + +func writeJournalChecksum(journal writeJournal) (string, error) { + journal.Checksum = "" + body, err := json.Marshal(journal) + if err != nil { + return "", err + } + return sha256Hex(body), nil +} + +func saveWriteJournal(path string, journal writeJournal) error { + if journal.Schema != "" && journal.Schema != writeJournalSchema { + return fmt.Errorf("unsupported write journal schema %q", journal.Schema) + } + journal.Schema = writeJournalSchema + if err := validateWriteJournal(journal, journal.RunID, journal.ManifestHash, journal.Operation); err != nil { + return err + } + if err := validateMutableStatePath(path, "write journal"); err != nil { + return err + } + checksum, err := writeJournalChecksum(journal) + if err != nil { + return fmt.Errorf("hash write journal: %w", err) + } + journal.Checksum = checksum + _, err = atomicJSON(path, journal) + return err +} + +func loadWriteJournal(path, runID, manifestHash, operation string) (writeJournal, bool, error) { + body, found, err := readMutableStateFile(path, "write journal") + if !found && err == nil { + return writeJournal{}, false, nil + } + if err != nil { + return writeJournal{}, false, err + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + var journal writeJournal + if err := decoder.Decode(&journal); err != nil { + return writeJournal{}, false, fmt.Errorf("decode write journal: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return writeJournal{}, false, fmt.Errorf("write journal has trailing JSON") + } + if journal.Schema != writeJournalSchema { + return writeJournal{}, false, fmt.Errorf("unsupported write journal schema %q", journal.Schema) + } + wantChecksum, err := writeJournalChecksum(journal) + if err != nil { + return writeJournal{}, false, fmt.Errorf("hash write journal: %w", err) + } + if journal.Checksum == "" || journal.Checksum != wantChecksum { + return writeJournal{}, false, fmt.Errorf("write journal checksum mismatch") + } + if err := validateWriteJournal(journal, runID, manifestHash, operation); err != nil { + return writeJournal{}, false, err + } + return journal, true, nil +} + +func validateWriteJournal(journal writeJournal, runID, manifestHash, operation string) error { + if journal.Schema != writeJournalSchema { + return fmt.Errorf("unsupported write journal schema %q", journal.Schema) + } + if runID == "" || manifestHash == "" || (operation != applyMode && operation != rollbackMode) || + journal.RunID != runID || journal.ManifestHash != manifestHash || journal.Operation != operation { + return fmt.Errorf("write journal does not match run/manifest/operation") + } + if journal.Start == 0 || journal.End < journal.Start || journal.BatchRecords != journal.End-journal.Start+1 || + journal.BatchBytes <= 0 || journal.AlreadyTarget > journal.BatchRecords || + uint64(len(journal.Intents)) != journal.BatchRecords-journal.AlreadyTarget || + (journal.State != writeJournalIssued && journal.State != writeJournalObserved) || len(journal.Intents) == 0 { + return fmt.Errorf("write journal range, state, or intents are invalid") + } + previousOrdinal := uint64(0) + for _, intent := range journal.Intents { + if intent.Operation != operation || intent.Ordinal < journal.Start || intent.Ordinal > journal.End || + intent.Ordinal <= previousOrdinal || intent.Height == 0 || intent.Key == "" || intent.IfMatch == "" { + return fmt.Errorf("write journal has an invalid intent") + } + if _, err := hex.DecodeString(intent.OldSHA256); err != nil || len(intent.OldSHA256) != 64 { + return fmt.Errorf("write journal intent %d has an invalid old SHA-256", intent.Ordinal) + } + if _, err := hex.DecodeString(intent.NewSHA256); err != nil || len(intent.NewSHA256) != 64 { + return fmt.Errorf("write journal intent %d has an invalid new SHA-256", intent.Ordinal) + } + if intent.ConfirmedPUTs > 1 || intent.UncertainPUTs > 1 || + intent.ConfirmedPUTs > intent.PUTAttempts || intent.UncertainPUTs > intent.PUTAttempts-intent.ConfirmedPUTs || + intent.Conflicts > intent.PUTAttempts || + intent.ConfirmedPUTs+intent.UncertainPUTs > 1 { + return fmt.Errorf("write journal intent %d has inconsistent PUT counters", intent.Ordinal) + } + previousOrdinal = intent.Ordinal + } + intentsByOrdinal := make(map[uint64]writeIntent, len(journal.Intents)) + for _, intent := range journal.Intents { + intentsByOrdinal[intent.Ordinal] = intent + } + seenResults := make(map[uint64]struct{}, len(journal.Results)) + for _, result := range journal.Results { + if result.Ordinal < journal.Start || result.Ordinal > journal.End || result.PostPUTETag == "" || + (result.Outcome != writeOutcomeConfirmed && result.Outcome != writeOutcomeUncertain && result.Outcome != writeOutcomeReconciled) { + return fmt.Errorf("write journal has an invalid observed result") + } + if result.Outcome == writeOutcomeReconciled && operation != applyMode { + return fmt.Errorf("write journal has an invalid reconciled result for %s", operation) + } + intent, found := intentsByOrdinal[result.Ordinal] + if !found { + return fmt.Errorf("write journal result %d has no matching intent", result.Ordinal) + } + expectedSHA := intent.NewSHA256 + if operation == rollbackMode { + expectedSHA = intent.OldSHA256 + } + if result.ObservedSHA256 != expectedSHA { + return fmt.Errorf("write journal result %d observed the wrong target", result.Ordinal) + } + if (result.Outcome == writeOutcomeConfirmed && intent.ConfirmedPUTs == 0) || + (result.Outcome == writeOutcomeUncertain && intent.UncertainPUTs == 0) || + (result.Outcome == writeOutcomeReconciled && (intent.PUTAttempts == 0 || intent.Conflicts == 0 || intent.ConfirmedPUTs != 0 || intent.UncertainPUTs != 0)) { + return fmt.Errorf("write journal result %d has no matching PUT counter", result.Ordinal) + } + if _, found := seenResults[result.Ordinal]; found { + return fmt.Errorf("write journal has duplicate result ordinal %d", result.Ordinal) + } + if _, err := hex.DecodeString(result.ObservedSHA256); err != nil || len(result.ObservedSHA256) != 64 { + return fmt.Errorf("write journal result %d has an invalid SHA-256", result.Ordinal) + } + seenResults[result.Ordinal] = struct{}{} + } + if journal.State == writeJournalObserved && len(journal.Results) != len(journal.Intents) { + return fmt.Errorf("observed write journal has incomplete results") + } + return nil +} + +func removeWriteJournal(path string) error { + if err := os.Remove(path); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + return syncDir(filepath.Dir(path)) +} diff --git a/cmd/statediff-rewriter/write_journal_test.go b/cmd/statediff-rewriter/write_journal_test.go new file mode 100644 index 00000000..3c08cc1e --- /dev/null +++ b/cmd/statediff-rewriter/write_journal_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func testWriteJournal() writeJournal { + return writeJournal{ + RunID: "run", ManifestHash: "manifest", Operation: "apply", + Start: 1, End: 1, BatchRecords: 1, BatchBytes: 1, State: writeJournalIssued, + Intents: []writeIntent{{ + Operation: "apply", Ordinal: 1, Height: 2, Key: "prefix/key", IfMatch: "etag-old", + OldSHA256: "0101010101010101010101010101010101010101010101010101010101010101", + NewSHA256: "0202020202020202020202020202020202020202020202020202020202020202", + }}, + } +} + +func TestWriteJournalRejectsMutationAndWrongObservedTarget(t *testing.T) { + path := filepath.Join(t.TempDir(), "write-journal.json") + journal := testWriteJournal() + require.NoError(t, saveWriteJournal(path, journal)) + loaded, found, err := loadWriteJournal(path, journal.RunID, journal.ManifestHash, journal.Operation) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, journal.Intents, loaded.Intents) + + body, err := os.ReadFile(path) + require.NoError(t, err) + var fields map[string]any + require.NoError(t, json.Unmarshal(body, &fields)) + fields["state"] = writeJournalObserved + mutated, err := json.Marshal(fields) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, mutated, 0o600)) + _, _, err = loadWriteJournal(path, journal.RunID, journal.ManifestHash, journal.Operation) + require.ErrorContains(t, err, "checksum mismatch") + + journal = testWriteJournal() + require.NoError(t, saveWriteJournal(path, journal)) + journal.State = writeJournalObserved + journal.Intents[0].PUTAttempts = 1 + journal.Intents[0].ConfirmedPUTs = 1 + journal.Results = []writeObservedResult{{ + Ordinal: 1, ObservedSHA256: journal.Intents[0].OldSHA256, + PostPUTETag: "etag-new", Outcome: "confirmed", + }} + err = saveWriteJournal(path, journal) + require.ErrorContains(t, err, "wrong target") + loaded, found, err = loadWriteJournal(path, journal.RunID, journal.ManifestHash, journal.Operation) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, writeJournalIssued, loaded.State) +} + +func TestWriteJournalRejectsReconciledRollbackResult(t *testing.T) { + journal := testWriteJournal() + journal.Operation = rollbackMode + journal.Intents[0].Operation = rollbackMode + journal.Intents[0].PUTAttempts = 1 + journal.Intents[0].Conflicts = 1 + journal.State = writeJournalObserved + journal.Results = []writeObservedResult{{ + Ordinal: 1, ObservedSHA256: journal.Intents[0].OldSHA256, + PostPUTETag: "etag-old", Outcome: writeOutcomeReconciled, + }} + + err := saveWriteJournal(filepath.Join(t.TempDir(), "rollback-journal.json"), journal) + require.ErrorContains(t, err, "invalid reconciled result for rollback") +} diff --git a/go.mod b/go.mod index 53a64969..21e82887 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,17 @@ require ( gopkg.in/yaml.v2 v2.4.0 ) +require ( + github.com/aws/aws-sdk-go-v2 v1.41.6 + github.com/aws/aws-sdk-go-v2/config v1.32.7 + github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1 + github.com/aws/smithy-go v1.25.0 + github.com/cosmos/iavl v1.2.4-0.20241218014611-3b0ad9922f9f + github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e + github.com/holiman/uint256 v1.3.2 + golang.org/x/sync v0.19.0 +) + require ( cel.dev/expr v0.24.0 // indirect cloud.google.com/go v0.116.0 // indirect @@ -73,6 +84,21 @@ require ( github.com/VictoriaMetrics/fastcache v1.12.2 // indirect github.com/alitto/pond v1.8.3 // indirect github.com/aws/aws-sdk-go v1.49.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/bgentry/speakeasy v0.2.0 // indirect @@ -102,7 +128,6 @@ require ( github.com/cosmos/btcutil v1.0.5 // indirect github.com/cosmos/go-bip39 v1.0.0 // indirect github.com/cosmos/gogogateway v1.2.0 // indirect - github.com/cosmos/iavl v1.2.4-0.20241218014611-3b0ad9922f9f // indirect github.com/cosmos/ics23/go v0.11.0 // indirect github.com/cosmos/ledger-cosmos-go v0.14.0 // indirect github.com/cosmos/rosetta-sdk-go v0.10.0 // indirect @@ -147,7 +172,6 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/mock v1.6.0 // indirect - github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e // indirect github.com/google/btree v1.1.3 // indirect github.com/google/flatbuffers v24.3.25+incompatible // indirect github.com/google/go-cmp v0.7.0 // indirect @@ -174,7 +198,6 @@ require ( github.com/hashicorp/yamux v0.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect github.com/holiman/bloomfilter/v2 v2.0.3 // indirect - github.com/holiman/uint256 v1.3.2 // indirect github.com/huandu/skiplist v1.2.1 // indirect github.com/huin/goupnp v1.3.0 // indirect github.com/iancoleman/strcase v0.3.0 // indirect @@ -262,7 +285,6 @@ require ( golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.32.0 // indirect @@ -295,8 +317,8 @@ replace ( github.com/99designs/keyring => github.com/cosmos/keyring v1.2.0 // v0.38.x github.com/cometbft/cometbft => github.com/crypto-org-chain/cometbft v0.0.0-20260126040959-178ea8502144 - // solves bug on pruning "version does not exist" - github.com/cosmos/iavl => github.com/cosmos/iavl v1.2.6 + // v1.2.6 plus read-only legacy r/n/o inspection for historical state-diff extraction. + github.com/cosmos/iavl => github.com/Chaintable/iavl v1.2.7-0.20260722064406-ba1209fb0adb // TODO: remove temporary fix for client state query issue v10.5.0-cronos github.com/cosmos/ibc-go/v10 => github.com/crypto-org-chain/ibc-go/v10 v10.0.0-20260421013314-1a60e77d1cba @@ -306,7 +328,7 @@ replace ( // release/v1.15 github.com/ethereum/go-ethereum => github.com/crypto-org-chain/go-ethereum v1.10.20-0.20250815065500-a4fbafcae0dd // DeBank: ethermint-cronos fork carrying the trace_debankBlock emitter (branch v1.7.8, base = ethermint 2593e6ed). - github.com/evmos/ethermint => github.com/Chaintable/ethermint-cronos v0.22.1-0.20260707133333-60189fd0e284 + github.com/evmos/ethermint => github.com/Chaintable/ethermint-cronos v0.22.1-0.20260721171431-e017d5ee7c8f // Fix upstream GHSA-h395-qcrw-5vmq and GHSA-3vp4-m3rf-835h vulnerabilities. // TODO Remove it: https://github.com/cosmos/cosmos-sdk/issues/10409 github.com/gin-gonic/gin => github.com/gin-gonic/gin v1.9.0 diff --git a/go.sum b/go.sum index dfca368f..a4785e0b 100644 --- a/go.sum +++ b/go.sum @@ -657,12 +657,10 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Chaintable/ethermint-cronos v0.22.1-0.20260707075724-8322beb468f7 h1:RiOPGigU7hnJMep5q6fLGdSyPtlEJlnpkNKt81IabGU= -github.com/Chaintable/ethermint-cronos v0.22.1-0.20260707075724-8322beb468f7/go.mod h1:sAlk6BoHuMuVsCpaO15q7FEfXZLL+fD9IPDtpjtSuvg= -github.com/Chaintable/ethermint-cronos v0.22.1-0.20260707123809-aa5017c763c6 h1:HENWuTWGz1ylVIH2aZmaNr4yCt6tAESS+LNvwn1zjcA= -github.com/Chaintable/ethermint-cronos v0.22.1-0.20260707123809-aa5017c763c6/go.mod h1:sAlk6BoHuMuVsCpaO15q7FEfXZLL+fD9IPDtpjtSuvg= -github.com/Chaintable/ethermint-cronos v0.22.1-0.20260707133333-60189fd0e284 h1:RsG1aNAjsAjck8YQLAETN13DuQ+Q5tyZXoXKpg2vlxw= -github.com/Chaintable/ethermint-cronos v0.22.1-0.20260707133333-60189fd0e284/go.mod h1:sAlk6BoHuMuVsCpaO15q7FEfXZLL+fD9IPDtpjtSuvg= +github.com/Chaintable/ethermint-cronos v0.22.1-0.20260721171431-e017d5ee7c8f h1:m8af5LtWWYEfhZu0lO1vRvyuVTEuGH6hZQJnoEpFV90= +github.com/Chaintable/ethermint-cronos v0.22.1-0.20260721171431-e017d5ee7c8f/go.mod h1:sAlk6BoHuMuVsCpaO15q7FEfXZLL+fD9IPDtpjtSuvg= +github.com/Chaintable/iavl v1.2.7-0.20260722064406-ba1209fb0adb h1:2MXbjOC7r2nCaoompdVQ4b4uHLq/YDQnjLlD7Qrn1/g= +github.com/Chaintable/iavl v1.2.7-0.20260722064406-ba1209fb0adb/go.mod h1:GiM43q0pB+uG53mLxLDzimxM9l/5N9UuSY3/D0huuVw= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/DataDog/datadog-go v4.8.3+incompatible h1:fNGaYSuObuQb5nzeTQqowRAd9bpDIRRV4/gUtIBjh8Q= github.com/DataDog/datadog-go v4.8.3+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= @@ -733,6 +731,44 @@ github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX github.com/aws/aws-sdk-go v1.49.0 h1:g9BkW1fo9GqKfwg2+zCD+TW/D36Ux+vtfJ8guF4AYmY= github.com/aws/aws-sdk-go v1.49.0/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= +github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= +github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg= +github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= +github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 h1:xnvDEnw+pnj5mctWiYuFbigrEzSm35x7k4KS/ZkCANg= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14/go.mod h1:yS5rNogD8e0Wu9+l3MUwr6eENBzEeGejvINpN5PAYfY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 h1:SE+aQ4DEqG53RRCAIHlCf//B2ycxGH7jFkpnAh/kKPM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22/go.mod h1:ES3ynECd7fYeJIL6+oax+uIEljmfps0S70BaQzbMd/o= +github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1 h1:kU/eBN5+MWNo/LcbNa4hWDdN76hdcd7hocU5kvu7IsU= +github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1/go.mod h1:Fw9aqhJicIVee1VytBBjH+l+5ov6/PhbtIK/u3rt/ls= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= +github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= +github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= @@ -878,8 +914,6 @@ github.com/cosmos/gogogateway v1.2.0/go.mod h1:iQpLkGWxYcnCdz5iAdLcRBSw3h7NXeOkZ github.com/cosmos/gogoproto v1.4.2/go.mod h1:cLxOsn1ljAHSV527CHOtaIP91kK6cCrZETRBrkzItWU= github.com/cosmos/gogoproto v1.7.2 h1:5G25McIraOC0mRFv9TVO139Uh3OklV2hczr13KKVHCA= github.com/cosmos/gogoproto v1.7.2/go.mod h1:8S7w53P1Y1cHwND64o0BnArT6RmdgIvsBuco6uTllsk= -github.com/cosmos/iavl v1.2.6 h1:Hs3LndJbkIB+rEvToKJFXZvKo6Vy0Ex1SJ54hhtioIs= -github.com/cosmos/iavl v1.2.6/go.mod h1:GiM43q0pB+uG53mLxLDzimxM9l/5N9UuSY3/D0huuVw= github.com/cosmos/ics23/go v0.11.0 h1:jk5skjT0TqX5e5QJbEnwXIS2yI2vnmLOgpQPeM5RtnU= github.com/cosmos/ics23/go v0.11.0/go.mod h1:A8OjxPE67hHST4Icw94hOxxFEJMBG031xIGF/JHNIY0= github.com/cosmos/keyring v1.2.0 h1:8C1lBP9xhImmIabyXW4c3vFjjLiBdGCmfLUfeZlV1Yo= diff --git a/gomod2nix.toml b/gomod2nix.toml index d8404c3e..a762e577 100644 --- a/gomod2nix.toml +++ b/gomod2nix.toml @@ -124,6 +124,63 @@ schema = 3 [mod."github.com/aws/aws-sdk-go"] version = "v1.49.0" hash = "sha256-hbg+kWydUF+yUX9eNtee/pNZKnRGc8fuOcsSdQlAaGs=" + [mod."github.com/aws/aws-sdk-go-v2"] + version = "v1.41.6" + hash = "sha256-d6owyfsZ7IRTKdavt+br9omcKaIYg9hg/LGPts1VkeE=" + [mod."github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream"] + version = "v1.7.9" + hash = "sha256-od9DhmIaRdeAJ8HT4FwAfGByHoK/cX8TBB+0fx2kVkM=" + [mod."github.com/aws/aws-sdk-go-v2/config"] + version = "v1.32.7" + hash = "sha256-z61nfA0WteEqNmhF0K5dRwJwicSQWS+/oOoHqxc+LiU=" + [mod."github.com/aws/aws-sdk-go-v2/credentials"] + version = "v1.19.7" + hash = "sha256-2E23PbP2twoY03jLWgQYoHveXZ7r7/7cZYtWNPMTc+I=" + [mod."github.com/aws/aws-sdk-go-v2/feature/ec2/imds"] + version = "v1.18.17" + hash = "sha256-eMquC4Ggfeg7a644kcwxHUUHIwY6O/XrSymrCqmflhE=" + [mod."github.com/aws/aws-sdk-go-v2/internal/configsources"] + version = "v1.4.22" + hash = "sha256-IZkRPudCRm4UMsjrphfqHXxi7TQEDTDfE7oO1t3463Q=" + [mod."github.com/aws/aws-sdk-go-v2/internal/endpoints/v2"] + version = "v2.7.22" + hash = "sha256-cnRMdIAuY1hAE5xJYxWXjGvUJ11F48SpO5IlSwDi820=" + [mod."github.com/aws/aws-sdk-go-v2/internal/ini"] + version = "v1.8.4" + hash = "sha256-okyFQwcEqbwKwkGK5xp/VYE0fGg9cqG6AuLijIuf5xg=" + [mod."github.com/aws/aws-sdk-go-v2/internal/v4a"] + version = "v1.4.23" + hash = "sha256-ZGBZ+PSn9XNgBdbyPQO+yxudjwsywrmQB4t6NAt5m6s=" + [mod."github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding"] + version = "v1.13.8" + hash = "sha256-vvRwvWMQ6SVNhKkIOwiBzQ5Yr3dEFNO7k6bLWig6NX4=" + [mod."github.com/aws/aws-sdk-go-v2/service/internal/checksum"] + version = "v1.9.14" + hash = "sha256-Efhg49Wj999JwhJ/cU/wmFd0yzJqjaUrr52cNtke+WY=" + [mod."github.com/aws/aws-sdk-go-v2/service/internal/presigned-url"] + version = "v1.13.22" + hash = "sha256-kBbTj5/J+EOrMYCRCoO5kNfh0PlGHeO+gcrvvZnl+MM=" + [mod."github.com/aws/aws-sdk-go-v2/service/internal/s3shared"] + version = "v1.19.22" + hash = "sha256-kSp48EuNE4ZPOAO+gIBe0TvIraGFxux5A2Eut9qZ4Eo=" + [mod."github.com/aws/aws-sdk-go-v2/service/s3"] + version = "v1.99.1" + hash = "sha256-IRnCa832TAV9oyVU1r0NJDUHphh48CMpPEXEccWymLc=" + [mod."github.com/aws/aws-sdk-go-v2/service/signin"] + version = "v1.0.5" + hash = "sha256-d9Ju310g1j8i9+ylDPnRHZEC6GoRGOjmbL+yRpDvjoU=" + [mod."github.com/aws/aws-sdk-go-v2/service/sso"] + version = "v1.30.9" + hash = "sha256-cC4k8KAPEu3Pb3AyRjsuNB4KyVhdJPVf1pr00KZ7sE4=" + [mod."github.com/aws/aws-sdk-go-v2/service/ssooidc"] + version = "v1.35.13" + hash = "sha256-bmEmJ7nNjHoVKVvsQ9WmlzkuJjL0mS/Wd2V+otPIue0=" + [mod."github.com/aws/aws-sdk-go-v2/service/sts"] + version = "v1.41.6" + hash = "sha256-vT36v9lPKBhesIVm4KRp/+nHPD6S6DaZIhCl/HHldrc=" + [mod."github.com/aws/smithy-go"] + version = "v1.25.0" + hash = "sha256-dpsvzF6BFK7XwzVvci3mhLA5KhEZ+xrfuaRWqbmukPU=" [mod."github.com/beorn7/perks"] version = "v1.0.1" hash = "sha256-h75GUqfwJKngCJQVE5Ao5wnO3cfKD9lSIteoLp/3xJ4=" @@ -229,9 +286,9 @@ schema = 3 version = "v1.7.2" hash = "sha256-L9sJZoQGWaix43AJ7rsm1DUng8uoD8HJ6Mb92Ynq8+s=" [mod."github.com/cosmos/iavl"] - version = "v1.2.6" - hash = "sha256-9kLtVepU5b3m2Sne8pBQNvF9LxM374LEmvuLWeYBfFU=" - replaced = "github.com/cosmos/iavl" + version = "v1.2.7-0.20260722064406-ba1209fb0adb" + hash = "sha256-/ceLbgDDUmo5G951c5yItQ/clxhMubCRz58o5onlqlA=" + replaced = "github.com/Chaintable/iavl" [mod."github.com/cosmos/ibc-go/v10"] version = "v10.0.0-20260421013314-1a60e77d1cba" hash = "sha256-dQhXGGD06hVg5VrqnxnVLAYxJBxmyfgc5mI/3RASq30=" @@ -328,9 +385,9 @@ schema = 3 version = "v0.2.2" hash = "sha256-0MLfSJKdeK3Z7tWAXTdzwB4091dmyxIX38S5SKH5QAw=" [mod."github.com/evmos/ethermint"] - version = "v0.22.1-0.20260622075215-2593e6edcfff" - hash = "sha256-aDgIpln5Hyq9788M+hMbbxAeSm4Tdf1Ho5rndYafOts=" - replaced = "github.com/crypto-org-chain/ethermint" + version = "v0.22.1-0.20260721171431-e017d5ee7c8f" + hash = "sha256-TAo8sdTfOIPr3dCdsb2Xdu4bsYLMSEGzhxzdX7+v45Y=" + replaced = "github.com/Chaintable/ethermint-cronos" [mod."github.com/fatih/color"] version = "v1.17.0" hash = "sha256-QsKMy3MsvjbYNcA9jP8w6c3wpmWDZ0079bybAEzmXR0="