This is an automated email from the ASF dual-hosted git repository.

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 2ee04d9b2 feat(table): validate snapshot manifests (#2397)
2ee04d9b2 is described below

commit 2ee04d9b2dc9937986f2a749501055770df4a459
Author: Minh Vu <[email protected]>
AuthorDate: Wed Nov 4 19:08:17 2026 +0100

    feat(table): validate snapshot manifests (#2397)
    
    * feat(table): validate snapshot manifests
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * fix(table): validate tombstone entry content during snapshot audit
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * test(table): exercise snapshot tombstones with valid fixtures
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * fix(table): reject missing required v2/v3 manifest counts
    
    * test(table): cover required manifest-list counts
    
    * feat(table): validate deletion vector targets
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * fix(table): report invalid deletion vector blob ranges during audits
    
    * test(table): cover invalid and missing deletion vector ranges
    
    * style(table): align snapshot validation issue constants
    
    * fix(table): align snapshot audit with inspect and projected manifests
    
    * fix(table): do not mark canceled snapshot audits complete
    
    * fix(table): use snapshot lookup sentinels in validation
    
    * fix(table): infer optional v1 embedded manifest spec IDs in audit
    
    * test(table): verify v1 snapshot audit without header spec IDs
    
    * style(table): satisfy nlreturn in snapshot audit
    
    * test(table): use a non-empty manifest for v1 header validation
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * fix(table): de-duplicate snapshot manifest audit issues and share v1 spec 
parsing
    
    * docs(table): preserve escaped newlines in snapshot audit example
    
    * fix(table): validate embedded v1 header specs during snapshot audits
    
    * fix(table): reject deletion vector blob ranges beyond file size
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
---
 table/inspect.go                |   4 +-
 table/validate_snapshot.go      | 433 ++++++++++++++++++++++++
 table/validate_snapshot_test.go | 720 ++++++++++++++++++++++++++++++++++++++++
 website/src/api.md              |  37 +++
 4 files changed, 1192 insertions(+), 2 deletions(-)

diff --git a/table/inspect.go b/table/inspect.go
index f8548df20..f8817001e 100644
--- a/table/inspect.go
+++ b/table/inspect.go
@@ -36,8 +36,8 @@ import (
 // so on) as queryable Arrow tables. It mirrors the metadata tables offered by
 // the Java, PyIceberg, and Rust clients.
 //
-// Obtain one via Table.Inspect. Each method returns an array.RecordReader that
-// the caller is responsible for releasing.
+// Obtain one via Table.Inspect. Metadata-table methods return an
+// array.RecordReader that the caller is responsible for releasing.
 type InspectTable struct {
        tbl   Table
        alloc memory.Allocator
diff --git a/table/validate_snapshot.go b/table/validate_snapshot.go
new file mode 100644
index 000000000..832085d4b
--- /dev/null
+++ b/table/validate_snapshot.go
@@ -0,0 +1,433 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+       "context"
+       "fmt"
+
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/twmb/avro/ocf"
+
+       "github.com/apache/iceberg-go"
+)
+
+// SnapshotValidationIssueCode describes a violation of the Iceberg snapshot
+// manifest and content-file invariants.
+type SnapshotValidationIssueCode string
+
+const (
+       SnapshotIssueDuplicateManifest           SnapshotValidationIssueCode = 
"duplicate-manifest"
+       SnapshotIssueDuplicateLiveFile           SnapshotValidationIssueCode = 
"duplicate-live-file"
+       SnapshotIssueDuplicateDeletionVector     SnapshotValidationIssueCode = 
"duplicate-deletion-vector"
+       SnapshotIssueMissingDeletionVectorTarget SnapshotValidationIssueCode = 
"missing-deletion-vector-target"
+       SnapshotIssueInvalidDeletionVectorRange  SnapshotValidationIssueCode = 
"invalid-deletion-vector-range"
+       SnapshotIssueUnknownPartitionSpec        SnapshotValidationIssueCode = 
"unknown-partition-spec"
+       SnapshotIssueIncompatibleManifestVersion SnapshotValidationIssueCode = 
"incompatible-manifest-version"
+       SnapshotIssueInvalidManifestContent      SnapshotValidationIssueCode = 
"invalid-manifest-content"
+       SnapshotIssueInvalidFileContent          SnapshotValidationIssueCode = 
"invalid-file-content"
+       SnapshotIssueInvalidFileFormat           SnapshotValidationIssueCode = 
"invalid-file-format"
+       SnapshotIssueEmptyManifestPath           SnapshotValidationIssueCode = 
"empty-manifest-path"
+       SnapshotIssueEmptyFilePath               SnapshotValidationIssueCode = 
"empty-file-path"
+       SnapshotIssueManifestCountMismatch       SnapshotValidationIssueCode = 
"manifest-count-mismatch"
+)
+
+// SnapshotValidationIssue identifies a manifest-level or live-file-level
+// problem. FirstManifestPath is populated for duplicate paths. Detail gives
+// additional context such as the mismatched manifest count.
+type SnapshotValidationIssue struct {
+       Code              SnapshotValidationIssueCode
+       ManifestPath      string
+       FilePath          string
+       FirstManifestPath string
+       PartitionSpecID   int32
+       Detail            string
+}
+
+// SnapshotValidationReport describes the metadata inspected for one snapshot.
+// The audit does not read data-file contents or verify their storage 
existence.
+type SnapshotValidationReport struct {
+       SnapshotID       int64
+       ManifestsChecked int64
+       LiveFilesChecked int64
+       Issues           []SnapshotValidationIssue
+       Complete         bool
+}
+
+// Valid is true only after the entire audit completes without issues.
+func (r SnapshotValidationReport) Valid() bool {
+       return r.Complete && len(r.Issues) == 0
+}
+
+// ValidateSnapshot audits the manifest list and manifest entries of a 
snapshot.
+//
+// It checks that the manifest list references known partition specs, that
+// manifest-list descriptors and file contents are valid for the table's 
format version, and
+// that a content file has at most one live reference across all manifests.
+// It also checks that each live deletion vector has a target path and that no 
data
+// file has more than one live deletion vector.
+// Deleted entries do not count as live references. Manifest entry counts are
+// checked only when the manifest list provides them (v1 may omit them).
+// For embedded v1 manifests, missing header spec IDs are inferred only when
+// the partition fields match exactly one known partition spec.
+//
+// The snapshot is never modified. I/O errors and cancellation return the
+// partial report with Complete=false and a non-nil error. Cancellation is
+// checked between reads; in-progress reads may not be interruptible. The audit
+// reads projected manifest metadata without column statistics and does not
+// read or stat data files.
+//
+// Spec: https://iceberg.apache.org/spec/#content-file-uniqueness
+// Spec: https://iceberg.apache.org/spec/#manifest-lists
+func (i InspectTable) ValidateSnapshot(ctx context.Context, snapshotID int64) 
(*SnapshotValidationReport, error) {
+       t := i.tbl
+       if t.metadata == nil {
+               return nil, fmt.Errorf("%w: table metadata is nil", 
ErrInvalidMetadata)
+       }
+
+       snapshot := t.metadata.SnapshotByID(snapshotID)
+       if snapshot == nil {
+               return nil, fmt.Errorf("%w: snapshot %d not found", 
ErrSnapshotNotFound, snapshotID)
+       }
+       report := &SnapshotValidationReport{SnapshotID: snapshotID}
+       if err := ctx.Err(); err != nil {
+               return report, err
+       }
+
+       fsF := sharedSnapshotManifestFSF(t.fsF)
+       fs, err := fsF(ctx)
+       if err != nil {
+               return report, fmt.Errorf("load snapshot %d FileIO: %w", 
snapshotID, err)
+       }
+       if fs == nil {
+               return report, fmt.Errorf("snapshot %d FileIO is nil", 
snapshotID)
+       }
+
+       manifestSet, err := t.manifestSetWithFSF(ctx, *snapshot, fsF)
+       if err != nil {
+               return report, fmt.Errorf("read snapshot %d manifest list: %w", 
snapshotID, err)
+       }
+       manifests := manifestSet.borrowAllManifests()
+       if err := ctx.Err(); err != nil {
+               return report, err
+       }
+
+       specs := t.metadata.PartitionSpecs()
+       specIDs := make(map[int]struct{}, len(specs))
+       specsByID := make(map[int32]iceberg.PartitionSpec, len(specs))
+       for _, spec := range specs {
+               specIDs[spec.ID()] = struct{}{}
+               specsByID[int32(spec.ID())] = spec
+       }
+       version := t.metadata.FormatVersion()
+       seenManifests := make(map[string]struct{}, len(manifests))
+       // The map intentionally tracks every live content file, regardless of 
its
+       // partition spec, entry status (ADDED/EXISTING), or file format.
+       seenFiles := make(map[deletionVectorBlobKey]string)
+       seenVectorTargets := make(map[string]snapshotValidationVector)
+
+       for _, manifest := range manifests {
+               if err := ctx.Err(); err != nil {
+                       return report, err
+               }
+               report.ManifestsChecked++
+               if manifest == nil {
+                       report.Issues = append(report.Issues, 
SnapshotValidationIssue{
+                               Code: SnapshotIssueEmptyManifestPath, Detail: 
"manifest entry is nil",
+                       })
+
+                       continue
+               }
+               path := manifest.FilePath()
+               if path == "" {
+                       report.Issues = append(report.Issues, 
SnapshotValidationIssue{
+                               Code: SnapshotIssueEmptyManifestPath,
+                       })
+
+                       continue
+               }
+               if _, exists := seenManifests[path]; exists {
+                       report.Issues = append(report.Issues, 
SnapshotValidationIssue{
+                               Code: SnapshotIssueDuplicateManifest, 
ManifestPath: path,
+                       })
+                       // Do not re-read the same manifest and generate a 
duplicate-file
+                       // issue for every live entry in it.
+                       continue
+               }
+               seenManifests[path] = struct{}{}
+
+               if snapshot.ManifestList == "" && snapshot.ManifestLocations != 
nil {
+                       specID, known, detail, err := 
embeddedValidationSpecID(ctx, fs, path, specsByID)
+                       if err != nil {
+                               return report, fmt.Errorf("read snapshot %d 
manifest %q header: %w", snapshotID, path, err)
+                       }
+                       if known {
+                               validateSnapshotSpec(report, path, specID, 
specIDs)
+                       }
+                       if detail != "" {
+                               report.Issues = append(report.Issues, 
SnapshotValidationIssue{
+                                       Code: 
SnapshotIssueUnknownPartitionSpec, ManifestPath: path,
+                                       PartitionSpecID: specID, Detail: detail,
+                               })
+                       }
+               } else {
+                       validateSnapshotSpec(report, path, 
manifest.PartitionSpecID(), specIDs)
+               }
+               validateSnapshotManifestDescriptor(report, manifest, version)
+
+               var added, existing, deleted int64
+               for entry, entryErr := range iceberg.EntriesWithProjection(fs, 
manifest, false, iceberg.ManifestEntryProjection{}) {
+                       if err := ctx.Err(); err != nil {
+                               return report, err
+                       }
+                       if entryErr != nil {
+                               return report, fmt.Errorf("read snapshot %d 
manifest %q: %w", snapshotID, path, entryErr)
+                       }
+                       if entry == nil {
+                               return report, fmt.Errorf("read snapshot %d 
manifest %q: nil entry", snapshotID, path)
+                       }
+
+                       switch entry.Status() {
+                       case iceberg.EntryStatusDELETED:
+                               deleted++
+                       case iceberg.EntryStatusADDED:
+                               added++
+                       case iceberg.EntryStatusEXISTING:
+                               existing++
+                       default:
+                               return report, fmt.Errorf("read snapshot %d 
manifest %q: unexpected entry status %d",
+                                       snapshotID, path, entry.Status())
+                       }
+
+                       if entry.Status() != iceberg.EntryStatusDELETED {
+                               report.LiveFilesChecked++
+                       }
+                       file := entry.DataFile()
+                       if file == nil {
+                               return report, fmt.Errorf("read snapshot %d 
manifest %q: nil data file", snapshotID, path)
+                       }
+                       filePath := file.FilePath()
+                       if filePath == "" {
+                               report.Issues = append(report.Issues, 
SnapshotValidationIssue{
+                                       Code: SnapshotIssueEmptyFilePath, 
ManifestPath: path,
+                               })
+
+                               continue
+                       }
+                       validateSnapshotFile(report, version, manifest, file)
+                       // Tombstones still have to obey the manifest's content 
and file-format
+                       // constraints, but they are not live references for 
uniqueness checks.
+                       if entry.Status() == iceberg.EntryStatusDELETED {
+                               continue
+                       }
+                       key := deletionVectorBlobKey{path: filePath}
+                       validVectorRange := true
+                       if IsDeletionVector(file) {
+                               offset, length := file.ContentOffset(), 
file.ContentSizeInBytes()
+                               // Check the range against the recorded 
container length without
+                               // adding offset and length, which could 
overflow int64.
+                               if offset == nil || length == nil || *offset < 
0 || *length <= 0 ||
+                                       file.FileSizeBytes() < 0 || *offset > 
file.FileSizeBytes()-*length {
+                                       validVectorRange = false
+                                       report.Issues = append(report.Issues, 
SnapshotValidationIssue{
+                                               Code: 
SnapshotIssueInvalidDeletionVectorRange, ManifestPath: path, FilePath: filePath,
+                                               Detail: "deletion vector blob 
range must be within file_size_in_bytes",
+                                       })
+                               } else {
+                                       key.offset, key.length = *offset, 
*length
+                               }
+                               target := file.ReferencedDataFile()
+                               if target == nil || *target == "" {
+                                       report.Issues = append(report.Issues, 
SnapshotValidationIssue{
+                                               Code: 
SnapshotIssueMissingDeletionVectorTarget, ManifestPath: path, FilePath: 
filePath,
+                                       })
+                               } else if validVectorRange {
+                                       if previous, exists := 
seenVectorTargets[*target]; exists {
+                                               // A repeated reference to the 
same blob is already reported by
+                                               // the content-file uniqueness 
check below.
+                                               if previous.key != key {
+                                                       report.Issues = 
append(report.Issues, SnapshotValidationIssue{
+                                                               Code: 
SnapshotIssueDuplicateDeletionVector, ManifestPath: path,
+                                                               
FirstManifestPath: previous.manifest, FilePath: filePath, Detail: *target,
+                                                       })
+                                               }
+                                       } else {
+                                               seenVectorTargets[*target] = 
snapshotValidationVector{key: key, manifest: path}
+                                       }
+                               }
+                       }
+                       // An invalid blob location cannot safely participate 
in uniqueness
+                       // checks. Keep auditing later entries instead of 
failing the scan.
+                       if !validVectorRange {
+                               continue
+                       }
+                       if firstManifest, exists := seenFiles[key]; exists {
+                               report.Issues = append(report.Issues, 
SnapshotValidationIssue{
+                                       Code: SnapshotIssueDuplicateLiveFile, 
ManifestPath: path,
+                                       FirstManifestPath: firstManifest, 
FilePath: filePath,
+                               })
+                       } else {
+                               seenFiles[key] = path
+                       }
+               }
+               validateManifestEntryCount(report, path, "added", 
manifest.AddedDataFiles(), added, manifest.Version())
+               validateManifestEntryCount(report, path, "existing", 
manifest.ExistingDataFiles(), existing, manifest.Version())
+               validateManifestEntryCount(report, path, "deleted", 
manifest.DeletedDataFiles(), deleted, manifest.Version())
+       }
+       if err := ctx.Err(); err != nil {
+               return report, err
+       }
+
+       report.Complete = true
+
+       return report, nil
+}
+
+type snapshotValidationVector struct {
+       key      deletionVectorBlobKey
+       manifest string
+}
+
+// Older v1 embedded snapshots have no manifest-list descriptor. Prefer their
+// optional header spec ID, or infer one only when the required partition-spec
+// fields identify exactly one spec in the table metadata.
+func embeddedValidationSpecID(
+       ctx context.Context, fs iceio.IO, path string, specs 
map[int32]iceberg.PartitionSpec,
+) (int32, bool, string, error) {
+       f, err := fs.Open(ctx, path)
+       if err != nil {
+               return 0, false, "", err
+       }
+       defer f.Close()
+       reader, err := ocf.NewReader(f)
+       if err != nil {
+               return 0, false, "", err
+       }
+       defer reader.Close()
+
+       header, err := parseEmbeddedManifestSpecHeader(reader.Metadata())
+       if err != nil {
+               return 0, false, "", err
+       }
+       id, known, ambiguous := header.resolve(specs)
+       if !known {
+               if ambiguous {
+                       return 0, false, "embedded v1 header matches multiple 
partition specs", nil
+               }
+
+               return 0, false, "embedded v1 header has no matching partition 
spec", nil
+       }
+       if spec, exists := specs[id]; exists &&
+               !iceberg.NewPartitionSpecID(int(id), 
header.fields...).Equals(spec) {
+               return id, true, "embedded v1 header partition fields differ 
from table spec", nil
+       }
+
+       return id, true, "", nil
+}
+
+func validateSnapshotSpec(report *SnapshotValidationReport, path string, 
specID int32, specIDs map[int]struct{}) {
+       if _, found := specIDs[int(specID)]; !found {
+               report.Issues = append(report.Issues, SnapshotValidationIssue{
+                       Code: SnapshotIssueUnknownPartitionSpec, ManifestPath: 
path, PartitionSpecID: specID,
+               })
+       }
+}
+
+func validateSnapshotManifestDescriptor(
+       report *SnapshotValidationReport,
+       manifest iceberg.ManifestFile,
+       version int,
+) {
+       path := manifest.FilePath()
+       if manifest.Version() < 1 || manifest.Version() > version {
+               report.Issues = append(report.Issues, SnapshotValidationIssue{
+                       Code: SnapshotIssueIncompatibleManifestVersion, 
ManifestPath: path,
+                       Detail: fmt.Sprintf("manifest v%d in table v%d", 
manifest.Version(), version),
+               })
+       }
+       content := manifest.ManifestContent()
+       if content != iceberg.ManifestContentData &&
+               (version == 1 || content != iceberg.ManifestContentDeletes) {
+               report.Issues = append(report.Issues, SnapshotValidationIssue{
+                       Code: SnapshotIssueInvalidManifestContent, 
ManifestPath: path,
+                       Detail: fmt.Sprintf("manifest content %s in table v%d", 
content, version),
+               })
+       }
+}
+
+func validateSnapshotFile(
+       report *SnapshotValidationReport,
+       version int,
+       manifest iceberg.ManifestFile,
+       file iceberg.DataFile,
+) {
+       path := manifest.FilePath()
+       content := file.ContentType()
+       manifestContent := manifest.ManifestContent()
+       if (manifestContent == iceberg.ManifestContentData && content != 
iceberg.EntryContentData) ||
+               (manifestContent == iceberg.ManifestContentDeletes &&
+                       content != iceberg.EntryContentPosDeletes && content != 
iceberg.EntryContentEqDeletes) ||
+               (version == 1 && content != iceberg.EntryContentData) {
+               report.Issues = append(report.Issues, SnapshotValidationIssue{
+                       Code: SnapshotIssueInvalidFileContent, ManifestPath: 
path,
+                       FilePath: file.FilePath(),
+                       Detail:   fmt.Sprintf("file content %s in manifest %s", 
content, manifestContent),
+               })
+       }
+
+       switch file.FileFormat() {
+       case iceberg.AvroFile, iceberg.OrcFile, iceberg.ParquetFile:
+       case iceberg.PuffinFile:
+               if version < 3 || content != iceberg.EntryContentPosDeletes {
+                       report.Issues = append(report.Issues, 
SnapshotValidationIssue{
+                               Code: SnapshotIssueInvalidFileFormat, 
ManifestPath: path, FilePath: file.FilePath(),
+                               Detail: "Puffin is only valid for positional 
deletes in v3",
+                       })
+               }
+       default:
+               report.Issues = append(report.Issues, SnapshotValidationIssue{
+                       Code: SnapshotIssueInvalidFileFormat, ManifestPath: 
path, FilePath: file.FilePath(),
+                       Detail: fmt.Sprintf("unknown file format %q", 
file.FileFormat()),
+               })
+       }
+}
+
+func validateManifestEntryCount(
+       report *SnapshotValidationReport, manifestPath, kind string, expected 
int32, actual int64, version int,
+) {
+       // Only v1 manifest lists permit omitted counts. Decoders represent null
+       // with -1. Negative counts in v2/v3 (and values below -1 in v1) are 
invalid.
+       if expected == -1 && version == 1 {
+               return
+       }
+       if expected < 0 {
+               report.Issues = append(report.Issues, SnapshotValidationIssue{
+                       Code: SnapshotIssueManifestCountMismatch, ManifestPath: 
manifestPath,
+                       Detail: fmt.Sprintf("%s entries: invalid count %d in 
v%d manifest list", kind, expected, version),
+               })
+
+               return
+       }
+       if int64(expected) == actual {
+               return
+       }
+       report.Issues = append(report.Issues, SnapshotValidationIssue{
+               Code: SnapshotIssueManifestCountMismatch, ManifestPath: 
manifestPath,
+               Detail: fmt.Sprintf("%s entries: manifest list reports %d, 
found %d", kind, expected, actual),
+       })
+}
diff --git a/table/validate_snapshot_test.go b/table/validate_snapshot_test.go
new file mode 100644
index 000000000..065837bfc
--- /dev/null
+++ b/table/validate_snapshot_test.go
@@ -0,0 +1,720 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package table
+
+import (
+       "bytes"
+       "context"
+       "encoding/json"
+       "fmt"
+       "math"
+       "strconv"
+       "testing"
+       "time"
+
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+       "github.com/twmb/avro/ocf"
+)
+
+const snapshotValidationTestID int64 = 41
+
+func v1ValidationEntry(t *testing.T, status iceberg.ManifestEntryStatus, path 
string) iceberg.ManifestEntry {
+       t.Helper()
+
+       data := newTestDataFile(t, iceberg.NewPartitionSpec(), path, nil)
+       snapshotID := snapshotValidationTestID
+
+       return iceberg.NewManifestEntry(status, &snapshotID, nil, nil, data)
+}
+
+// v1SnapshotValidationTable builds actual v1 Avro manifests and a v1 manifest
+// list. No data files are written because the audit only reads metadata.
+func v1SnapshotValidationTable(
+       t *testing.T,
+       embedded bool,
+       modify func([]iceberg.ManifestFile) []iceberg.ManifestFile,
+       groups ...[]iceberg.ManifestEntry,
+) *Table {
+       t.Helper()
+
+       ctx := context.Background()
+       spec := iceberg.NewPartitionSpec()
+       txn, fs := createTestTransactionWithMemIO(t, spec)
+       txn.meta.formatVersion = 1
+
+       manifests := make([]iceberg.ManifestFile, 0, len(groups))
+       for i, entries := range groups {
+               path := 
fmt.Sprintf("mem://default/table-location/metadata/validate-%d.avro", i)
+               var buf bytes.Buffer
+               manifest, err := iceberg.WriteManifest(path, &buf, 1, spec, 
simpleSchema(), snapshotValidationTestID, entries)
+               require.NoError(t, err)
+               require.NoError(t, fs.WriteFile(ctx, path, buf.Bytes()))
+               manifests = append(manifests, manifest)
+       }
+       if modify != nil {
+               manifests = modify(manifests)
+       }
+
+       snapshot := Snapshot{
+               SnapshotID:  snapshotValidationTestID,
+               TimestampMs: time.Now().UnixMilli(),
+               Summary:     &Summary{Operation: OpAppend},
+       }
+       if embedded {
+               for _, mf := range manifests {
+                       snapshot.ManifestLocations = 
append(snapshot.ManifestLocations, mf.FilePath())
+               }
+       } else {
+               const listPath = 
"mem://default/table-location/metadata/validation-list.avro"
+               var listBuf bytes.Buffer
+               require.NoError(t, iceberg.WriteManifestList(1, &listBuf, 
snapshotValidationTestID,
+                       nil, nil, 0, manifests))
+               require.NoError(t, fs.WriteFile(ctx, listPath, listBuf.Bytes()))
+               snapshot.ManifestList = listPath
+       }
+
+       require.NoError(t, txn.meta.AddSnapshot(&snapshot))
+       require.NoError(t, txn.meta.SetSnapshotRef(MainBranch, 
snapshotValidationTestID, BranchRef))
+       meta, err := txn.meta.Build()
+       require.NoError(t, err)
+
+       return New(Identifier{"db", "audit"}, meta, "metadata.json",
+               func(context.Context) (iceio.IO, error) { return fs, nil }, nil)
+}
+
+func TestValidateSnapshotV1UniqueLiveFiles(t *testing.T) {
+       tbl := v1SnapshotValidationTable(t, false, nil,
+               []iceberg.ManifestEntry{
+                       v1ValidationEntry(t, iceberg.EntryStatusADDED, 
"file://first.parquet"),
+               },
+               []iceberg.ManifestEntry{
+                       v1ValidationEntry(t, iceberg.EntryStatusEXISTING, 
"file://second.parquet"),
+               },
+       )
+
+       report, err := tbl.Inspect().ValidateSnapshot(context.Background(), 
snapshotValidationTestID)
+       require.NoError(t, err)
+       require.NotNil(t, report)
+       assert.True(t, report.Valid())
+       assert.True(t, report.Complete)
+       assert.Equal(t, snapshotValidationTestID, report.SnapshotID)
+       assert.Equal(t, int64(2), report.ManifestsChecked)
+       assert.Equal(t, int64(2), report.LiveFilesChecked)
+       assert.Empty(t, report.Issues)
+}
+
+func TestValidateSnapshotV1DuplicateLiveFileAcrossManifests(t *testing.T) {
+       tbl := v1SnapshotValidationTable(t, false, nil,
+               []iceberg.ManifestEntry{
+                       v1ValidationEntry(t, iceberg.EntryStatusADDED, 
"file://same.parquet"),
+               },
+               []iceberg.ManifestEntry{
+                       v1ValidationEntry(t, iceberg.EntryStatusEXISTING, 
"file://same.parquet"),
+               },
+       )
+
+       report, err := tbl.Inspect().ValidateSnapshot(context.Background(), 
snapshotValidationTestID)
+       require.NoError(t, err)
+       assert.False(t, report.Valid())
+       assert.True(t, report.Complete)
+       assert.Equal(t, int64(2), report.LiveFilesChecked)
+       require.Len(t, report.Issues, 1)
+       assert.Equal(t, SnapshotIssueDuplicateLiveFile, report.Issues[0].Code)
+       assert.Equal(t, "file://same.parquet", report.Issues[0].FilePath)
+       assert.Contains(t, report.Issues[0].FirstManifestPath, 
"validate-0.avro")
+       assert.Contains(t, report.Issues[0].ManifestPath, "validate-1.avro")
+}
+
+func TestValidateSnapshotV1DuplicateLiveFileWithinManifest(t *testing.T) {
+       tbl := v1SnapshotValidationTable(t, false, nil,
+               []iceberg.ManifestEntry{
+                       v1ValidationEntry(t, iceberg.EntryStatusADDED, 
"file://same.parquet"),
+                       v1ValidationEntry(t, iceberg.EntryStatusEXISTING, 
"file://same.parquet"),
+               },
+       )
+
+       report, err := tbl.Inspect().ValidateSnapshot(context.Background(), 
snapshotValidationTestID)
+       require.NoError(t, err)
+       assert.False(t, report.Valid())
+       require.Len(t, report.Issues, 1)
+       assert.Equal(t, SnapshotIssueDuplicateLiveFile, report.Issues[0].Code)
+       assert.Equal(t, report.Issues[0].FirstManifestPath, 
report.Issues[0].ManifestPath)
+}
+
+func TestValidateSnapshotV1IgnoresDeletedEntriesForUniqueness(t *testing.T) {
+       tbl := v1SnapshotValidationTable(t, false, nil,
+               []iceberg.ManifestEntry{
+                       v1ValidationEntry(t, iceberg.EntryStatusADDED, 
"file://same.parquet"),
+               },
+               []iceberg.ManifestEntry{
+                       v1ValidationEntry(t, iceberg.EntryStatusDELETED, 
"file://same.parquet"),
+               },
+       )
+
+       report, err := tbl.Inspect().ValidateSnapshot(context.Background(), 
snapshotValidationTestID)
+       require.NoError(t, err)
+       assert.True(t, report.Valid())
+       assert.Equal(t, int64(1), report.LiveFilesChecked)
+}
+
+func TestValidateSnapshotV1EmbeddedManifests(t *testing.T) {
+       tbl := v1SnapshotValidationTable(t, true, nil,
+               []iceberg.ManifestEntry{
+                       v1ValidationEntry(t, iceberg.EntryStatusADDED, 
"file://embedded.parquet"),
+               },
+       )
+
+       report, err := tbl.Inspect().ValidateSnapshot(context.Background(), 
snapshotValidationTestID)
+       require.NoError(t, err)
+       assert.True(t, report.Valid())
+       assert.Equal(t, int64(1), report.ManifestsChecked)
+       assert.Equal(t, int64(1), report.LiveFilesChecked)
+}
+
+func TestEmbeddedValidationSpecIDV1OptionalHeaders(t *testing.T) {
+       fs := iceio.NewMemFS()
+       spec := iceberg.NewPartitionSpecID(7, iceberg.PartitionField{
+               SourceIDs: []int{1}, FieldID: 1000, Name: "id", Transform: 
iceberg.IdentityTransform{},
+       })
+       const path = "mem://validation/legacy-v1.avro"
+       var original bytes.Buffer
+       snapshotID := snapshotValidationTestID
+       entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, 
&snapshotID, nil, nil,
+               newTestDataFile(t, spec, "file://header-fixture.parquet", 
map[int]any{1000: int32(1)}))
+       _, err := iceberg.WriteManifest(path, &original, 1, spec, 
simpleSchema(), snapshotID,
+               []iceberg.ManifestEntry{entry})
+       require.NoError(t, err)
+       reader, err := ocf.NewReader(bytes.NewReader(original.Bytes()))
+       require.NoError(t, err)
+       metadata := make(map[string][]byte)
+       for key, value := range reader.Metadata() {
+               if key != "partition-spec-id" && key != "avro.schema" && key != 
"avro.codec" {
+                       metadata[key] = value
+               }
+       }
+       schema := reader.Schema()
+       require.NoError(t, reader.Close())
+
+       writeHeader := func() {
+               var output bytes.Buffer
+               writer, err := ocf.NewWriter(&output, schema, 
ocf.WithMetadata(metadata))
+               require.NoError(t, err)
+               require.NoError(t, writer.Close())
+               require.NoError(t, fs.WriteFile(t.Context(), path, 
output.Bytes()))
+       }
+       writeHeader()
+       got, known, detail, err := embeddedValidationSpecID(t.Context(), fs, 
path, map[int32]iceberg.PartitionSpec{7: spec})
+       require.NoError(t, err)
+       require.Empty(t, detail)
+       require.True(t, known)
+       require.EqualValues(t, spec.ID(), got)
+
+       var fields []map[string]json.RawMessage
+       require.NoError(t, json.Unmarshal(metadata["partition-spec"], &fields))
+       for _, field := range fields {
+               delete(field, "field-id")
+       }
+       metadata["partition-spec"], err = json.Marshal(fields)
+       require.NoError(t, err)
+       writeHeader()
+       got, known, detail, err = embeddedValidationSpecID(t.Context(), fs, 
path, map[int32]iceberg.PartitionSpec{7: spec})
+       require.NoError(t, err)
+       require.Empty(t, detail)
+       require.True(t, known)
+       require.EqualValues(t, spec.ID(), got)
+
+       ambiguous := iceberg.NewPartitionSpecID(8, spec.Field(0))
+       _, known, detail, err = embeddedValidationSpecID(t.Context(), fs, path, 
map[int32]iceberg.PartitionSpec{7: spec, 8: ambiguous})
+       require.NoError(t, err)
+       require.False(t, known)
+       require.Contains(t, detail, "multiple partition specs")
+
+       unrelated := iceberg.NewPartitionSpecID(7, iceberg.PartitionField{
+               SourceIDs: []int{1}, FieldID: 1000, Name: "id", Transform: 
iceberg.BucketTransform{NumBuckets: 4},
+       })
+       _, known, detail, err = embeddedValidationSpecID(t.Context(), fs, path, 
map[int32]iceberg.PartitionSpec{7: unrelated})
+       require.NoError(t, err)
+       require.False(t, known)
+       require.Contains(t, detail, "no matching partition spec")
+}
+
+func TestValidateSnapshotV1RepeatedManifest(t *testing.T) {
+       tbl := v1SnapshotValidationTable(t, false,
+               func(manifests []iceberg.ManifestFile) []iceberg.ManifestFile {
+                       return append(manifests, manifests[0])
+               },
+               []iceberg.ManifestEntry{
+                       v1ValidationEntry(t, iceberg.EntryStatusADDED, 
"file://same.parquet"),
+                       v1ValidationEntry(t, iceberg.EntryStatusEXISTING, 
"file://other.parquet"),
+               },
+       )
+
+       report, err := tbl.Inspect().ValidateSnapshot(context.Background(), 
snapshotValidationTestID)
+       require.NoError(t, err)
+       assert.True(t, report.Complete)
+       assert.False(t, report.Valid())
+       assert.Equal(t, int64(2), report.ManifestsChecked)
+       assert.Equal(t, int64(2), report.LiveFilesChecked)
+       require.Len(t, report.Issues, 1)
+       assert.Equal(t, SnapshotIssueDuplicateManifest, report.Issues[0].Code)
+}
+
+func TestValidateSnapshotV1EmptyManifestList(t *testing.T) {
+       tbl := v1SnapshotValidationTable(t, false, nil)
+
+       report, err := tbl.Inspect().ValidateSnapshot(context.Background(), 
snapshotValidationTestID)
+       require.NoError(t, err)
+       assert.True(t, report.Valid())
+       assert.Zero(t, report.ManifestsChecked)
+       assert.Zero(t, report.LiveFilesChecked)
+}
+
+func TestValidateSnapshotV1MissingManifestReturnsIncompleteReport(t 
*testing.T) {
+       tbl := v1SnapshotValidationTable(t, false,
+               func(_ []iceberg.ManifestFile) []iceberg.ManifestFile {
+                       return []iceberg.ManifestFile{
+                               iceberg.NewManifestBuilder(1,
+                                       
"mem://default/table-location/metadata/missing.avro", 123, 0,
+                                       
snapshotValidationTestID).AddedFiles(1).Build(),
+                       }
+               },
+       )
+
+       report, err := tbl.Inspect().ValidateSnapshot(context.Background(), 
snapshotValidationTestID)
+       require.Error(t, err)
+       require.NotNil(t, report)
+       assert.ErrorContains(t, err, "missing.avro")
+       assert.False(t, report.Complete)
+       assert.False(t, report.Valid())
+       assert.Equal(t, int64(1), report.ManifestsChecked)
+}
+
+func TestValidateSnapshotCanceledContextReturnsIncompleteReport(t *testing.T) {
+       tbl := v1SnapshotValidationTable(t, false, nil)
+       ctx, cancel := context.WithCancel(context.Background())
+       cancel()
+
+       report, err := tbl.Inspect().ValidateSnapshot(ctx, 
snapshotValidationTestID)
+       require.ErrorIs(t, err, context.Canceled)
+       require.NotNil(t, report)
+       assert.False(t, report.Complete)
+       assert.False(t, report.Valid())
+       assert.Zero(t, report.ManifestsChecked)
+}
+
+func TestValidateSnapshotRejectsUnknownSnapshot(t *testing.T) {
+       tbl := v1SnapshotValidationTable(t, false, nil)
+
+       report, err := tbl.Inspect().ValidateSnapshot(context.Background(), 999)
+       require.Error(t, err)
+       assert.Nil(t, report)
+       assert.ErrorIs(t, err, ErrSnapshotNotFound)
+}
+
+func TestValidateSnapshotWithoutMetadata(t *testing.T) {
+       var tbl Table
+       report, err := tbl.Inspect().ValidateSnapshot(t.Context(), 1)
+       require.ErrorIs(t, err, ErrInvalidMetadata)
+       require.Nil(t, report)
+}
+
+func TestValidateSnapshotV1RejectsIncompatibleManifestDescriptor(t *testing.T) 
{
+       manifest := iceberg.NewManifestBuilder(2, "file://manifest.avro", 123, 
99,
+               snapshotValidationTestID).
+               Content(iceberg.ManifestContentDeletes).
+               Build()
+       report := &SnapshotValidationReport{}
+       validateSnapshotSpec(report, manifest.FilePath(), 
manifest.PartitionSpecID(), map[int]struct{}{0: {}})
+       validateSnapshotManifestDescriptor(report, manifest, 1)
+
+       require.Len(t, report.Issues, 3)
+       assert.Equal(t, SnapshotIssueUnknownPartitionSpec, 
report.Issues[0].Code)
+       assert.Equal(t, int32(99), report.Issues[0].PartitionSpecID)
+       assert.Equal(t, SnapshotIssueIncompatibleManifestVersion, 
report.Issues[1].Code)
+       assert.Equal(t, SnapshotIssueInvalidManifestContent, 
report.Issues[2].Code)
+}
+
+func TestValidateManifestEntryCountOptionalV1(t *testing.T) {
+       report := &SnapshotValidationReport{}
+       validateManifestEntryCount(report, "manifest", "added", -1, 2, 1)
+       validateManifestEntryCount(report, "manifest", "added", 2, 2, 1)
+       assert.Empty(t, report.Issues)
+
+       validateManifestEntryCount(report, "manifest", "added", 3, 2, 1)
+       require.Len(t, report.Issues, 1)
+       assert.Equal(t, SnapshotIssueManifestCountMismatch, 
report.Issues[0].Code)
+       assert.Contains(t, report.Issues[0].Detail, "reports 3, found 2")
+}
+
+func TestValidateManifestEntryCountRequiredInV2AndV3(t *testing.T) {
+       for _, version := range []int{2, 3} {
+               report := &SnapshotValidationReport{}
+               validateManifestEntryCount(report, "manifest", "added", -1, 2, 
version)
+               require.Len(t, report.Issues, 1)
+               assert.Equal(t, SnapshotIssueManifestCountMismatch, 
report.Issues[0].Code)
+               assert.Contains(t, report.Issues[0].Detail, "invalid count -1")
+
+               report = &SnapshotValidationReport{}
+               validateManifestEntryCount(report, "manifest", "deleted", 0, 0, 
version)
+               assert.Empty(t, report.Issues)
+       }
+
+       report := &SnapshotValidationReport{}
+       validateManifestEntryCount(report, "manifest", "existing", -2, 0, 1)
+       require.Len(t, report.Issues, 1)
+       assert.Equal(t, SnapshotIssueManifestCountMismatch, 
report.Issues[0].Code)
+}
+
+func writeValidationManifest(
+       t *testing.T, fs iceio.WriteFileIO, version int, path string,
+       content iceberg.ManifestContent, snapshotID int64,
+       added, existing, deleted []iceberg.DataFile,
+) iceberg.ManifestFile {
+       t.Helper()
+       var buf bytes.Buffer
+       wr, err := iceberg.NewManifestWriter(
+               version, &buf, *iceberg.UnpartitionedSpec, simpleSchema(), 
snapshotID,
+               iceberg.WithManifestWriterContent(content))
+       require.NoError(t, err)
+
+       seq := int64(1)
+       for _, file := range added {
+               require.NoError(t, wr.Add(iceberg.NewManifestEntry(
+                       iceberg.EntryStatusADDED, &snapshotID, nil, nil, file)))
+       }
+       for _, file := range existing {
+               var dataSeq, fileSeq *int64
+               if version > 1 {
+                       dataSeq, fileSeq = &seq, &seq
+               }
+               require.NoError(t, wr.Existing(iceberg.NewManifestEntry(
+                       iceberg.EntryStatusEXISTING, &snapshotID, dataSeq, 
fileSeq, file)))
+       }
+       for _, file := range deleted {
+               var dataSeq, fileSeq *int64
+               if version > 1 {
+                       dataSeq, fileSeq = &seq, &seq
+               }
+               require.NoError(t, wr.Delete(iceberg.NewManifestEntry(
+                       iceberg.EntryStatusDELETED, &snapshotID, dataSeq, 
fileSeq, file)))
+       }
+       require.NoError(t, wr.Close())
+       require.NoError(t, fs.WriteFile(context.Background(), path, 
buf.Bytes()))
+       manifest, err := wr.ToManifestFile(
+               path, int64(buf.Len()), 
iceberg.WithManifestFileContent(content))
+       require.NoError(t, err)
+
+       return manifest
+}
+
+func validationTable(
+       t *testing.T, version int, fs iceio.IO, snapshots ...Snapshot,
+) *Table {
+       t.Helper()
+       spec := *iceberg.UnpartitionedSpec
+       meta, err := NewMetadata(simpleSchema(), &spec, UnsortedSortOrder,
+               "mem://validation/table", 
iceberg.Properties{PropertyFormatVersion: strconv.Itoa(version)})
+       require.NoError(t, err)
+       builder, err := MetadataBuilderFromBase(meta, "")
+       require.NoError(t, err)
+
+       for i := range snapshots {
+               require.NoError(t, builder.AddSnapshot(&snapshots[i]))
+       }
+       require.NotEmpty(t, snapshots)
+       require.NoError(t, builder.SetSnapshotRef(
+               MainBranch, snapshots[len(snapshots)-1].SnapshotID, BranchRef))
+       meta, err = builder.Build()
+       require.NoError(t, err)
+
+       return New(Identifier{"db", "diff"}, meta, 
"mem://validation/table/metadata.json",
+               func(context.Context) (iceio.IO, error) { return fs, nil }, nil)
+}
+
+func writeValidationManifestListV3(
+       t *testing.T, fs iceio.WriteFileIO, path string,
+       snapshotID, sequenceNumber int64, manifests []iceberg.ManifestFile,
+) string {
+       t.Helper()
+       out, err := fs.Create(context.Background(), path)
+       require.NoError(t, err)
+       require.NoError(t, iceberg.WriteManifestList(3, out, snapshotID, nil,
+               &sequenceNumber, 0, manifests))
+       require.NoError(t, out.Close())
+
+       return path
+}
+
+func validationDeletionVector(
+       t *testing.T, puffinPath, dataPath string, offset int64,
+) iceberg.DataFile {
+       t.Helper()
+       builder, err := iceberg.NewDataFileBuilder(
+               *iceberg.UnpartitionedSpec, iceberg.EntryContentPosDeletes,
+               puffinPath, iceberg.PuffinFile, nil, nil, nil, 10, 1024)
+       require.NoError(t, err)
+
+       return builder.
+               ReferencedDataFile(dataPath).
+               ContentOffset(offset).
+               ContentSizeInBytes(32).
+               Build()
+}
+
+func TestValidateSnapshotV3DeletionVectors(t *testing.T) {
+       fs := iceio.NewMemFS()
+       prefix := "mem://audit/v3/"
+       vector1 := validationDeletionVector(t, prefix+"deletes.puffin", 
prefix+"a.parquet", 100)
+       vector2 := validationDeletionVector(t, prefix+"deletes.puffin", 
prefix+"b.parquet", 200)
+       manifest := writeValidationManifest(t, fs, 3, prefix+"deletes.avro", 
iceberg.ManifestContentDeletes, 1, []iceberg.DataFile{vector1, vector2}, nil, 
nil)
+       list := writeValidationManifestListV3(t, fs, prefix+"list.avro", 1, 1, 
[]iceberg.ManifestFile{manifest})
+       zero := int64(0)
+       tbl := validationTable(t, 3, fs, Snapshot{SnapshotID: 1, 
SequenceNumber: 1, TimestampMs: time.Now().UnixMilli(), ManifestList: list, 
FirstRowID: &zero, AddedRows: &zero})
+       report, err := tbl.Inspect().ValidateSnapshot(context.Background(), 1)
+       require.NoError(t, err)
+       require.True(t, report.Valid(), "%+v", report.Issues)
+
+       // The same blob referenced twice remains a duplicate.
+       duplicate := writeValidationManifest(t, fs, 3, prefix+"duplicate.avro", 
iceberg.ManifestContentDeletes, 2, []iceberg.DataFile{vector1, vector1}, nil, 
nil)
+       list2 := writeValidationManifestListV3(t, fs, prefix+"list2.avro", 2, 
2, []iceberg.ManifestFile{duplicate})
+       tbl2 := validationTable(t, 3, fs, Snapshot{SnapshotID: 2, 
SequenceNumber: 2, TimestampMs: time.Now().UnixMilli(), ManifestList: list2, 
FirstRowID: &zero, AddedRows: &zero})
+       report, err = tbl2.Inspect().ValidateSnapshot(context.Background(), 2)
+       require.NoError(t, err)
+       require.Len(t, report.Issues, 1)
+       require.Equal(t, SnapshotIssueDuplicateLiveFile, report.Issues[0].Code)
+}
+
+func TestValidateSnapshotEmbeddedUsesHeaderSpecID(t *testing.T) {
+       entry := v1ValidationEntry(t, iceberg.EntryStatusADDED, 
"file://embedded.parquet")
+       tbl := v1SnapshotValidationTable(t, true, nil, 
[]iceberg.ManifestEntry{entry})
+       fs, err := tbl.FS(context.Background())
+       require.NoError(t, err)
+       writable, ok := fs.(iceio.WriteFileIO)
+       require.True(t, ok)
+       path := "mem://default/table-location/metadata/validate-0.avro"
+       var buf bytes.Buffer
+       spec := iceberg.NewPartitionSpecID(42)
+       _, err = iceberg.WriteManifest(path, &buf, 1, spec, simpleSchema(), 
snapshotValidationTestID, []iceberg.ManifestEntry{entry})
+       require.NoError(t, err)
+       require.NoError(t, writable.WriteFile(context.Background(), path, 
buf.Bytes()))
+       report, err := tbl.Inspect().ValidateSnapshot(context.Background(), 
snapshotValidationTestID)
+       require.NoError(t, err)
+       require.Len(t, report.Issues, 1)
+       require.Equal(t, SnapshotIssueUnknownPartitionSpec, 
report.Issues[0].Code)
+       require.Equal(t, int32(42), report.Issues[0].PartitionSpecID)
+}
+
+func TestValidateSnapshotEmbeddedV1RejectsMismatchedHeaderSpec(t *testing.T) {
+       tbl := v1SnapshotValidationTable(t, true, nil,
+               []iceberg.ManifestEntry{v1ValidationEntry(t, 
iceberg.EntryStatusADDED, "file://original.parquet")})
+       fs, err := tbl.FS(t.Context())
+       require.NoError(t, err)
+       writable, ok := fs.(iceio.WriteFileIO)
+       require.True(t, ok)
+
+       // The header ID exists in the table, but the header has a different
+       // partition definition. An audit must not mark this snapshot valid.
+       spec := iceberg.NewPartitionSpecID(0, iceberg.PartitionField{
+               SourceIDs: []int{1}, FieldID: 1000, Name: "id", Transform: 
iceberg.IdentityTransform{},
+       })
+       snapshotID := snapshotValidationTestID
+       entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, 
&snapshotID, nil, nil,
+               newTestDataFile(t, spec, "file://mismatch.parquet", 
map[int]any{1000: int32(1)}))
+       const path = "mem://default/table-location/metadata/validate-0.avro"
+       var buf bytes.Buffer
+       _, err = iceberg.WriteManifest(path, &buf, 1, spec, simpleSchema(), 
snapshotID,
+               []iceberg.ManifestEntry{entry})
+       require.NoError(t, err)
+       require.NoError(t, writable.WriteFile(t.Context(), path, buf.Bytes()))
+
+       report, err := tbl.Inspect().ValidateSnapshot(t.Context(), snapshotID)
+       require.NoError(t, err)
+       require.NotNil(t, report)
+       require.True(t, report.Complete)
+       require.False(t, report.Valid())
+       require.Len(t, report.Issues, 1)
+       require.Equal(t, SnapshotIssueUnknownPartitionSpec, 
report.Issues[0].Code)
+       require.Contains(t, report.Issues[0].Detail, "partition fields differ")
+}
+
+func TestValidateSnapshotChecksDeletedEntries(t *testing.T) {
+       fs := iceio.NewMemFS()
+       const prefix = "mem://audit/deleted-content/"
+       dataFile, err := iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec,
+               iceberg.EntryContentData, prefix+"old.parquet",
+               iceberg.ParquetFile, nil, nil, nil, 1, 1024)
+       require.NoError(t, err)
+       manifest := writeValidationManifest(t, fs, 3, 
prefix+"data-manifest.avro",
+               iceberg.ManifestContentData, 1, nil, nil, 
[]iceberg.DataFile{dataFile.Build()})
+       list := writeValidationManifestListV3(t, fs, prefix+"list.avro", 1, 1,
+               []iceberg.ManifestFile{manifest})
+       zero := int64(0)
+       tbl := validationTable(t, 3, fs, Snapshot{
+               SnapshotID: 1, SequenceNumber: 1, TimestampMs: 
time.Now().UnixMilli(),
+               ManifestList: list, FirstRowID: &zero, AddedRows: &zero,
+       })
+
+       report, err := tbl.Inspect().ValidateSnapshot(t.Context(), 1)
+       require.NoError(t, err)
+       require.True(t, report.Valid(), "%+v", report.Issues)
+       require.Zero(t, report.LiveFilesChecked)
+}
+
+func TestValidateSnapshotFileRejectsMixedContent(t *testing.T) {
+       // ManifestWriter rejects mixed-content entries before encoding them.
+       // Exercise the audit's content rule directly for malformed metadata.
+       builder, err := iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec,
+               iceberg.EntryContentPosDeletes, 
"mem://audit/deleted-content/pos-deletes.parquet",
+               iceberg.ParquetFile, nil, nil, nil, 1, 1024)
+       require.NoError(t, err)
+       manifest := iceberg.NewManifestBuilder(3,
+               "mem://audit/deleted-content/data-manifest.avro", 100, 0, 
1).Build()
+       report := &SnapshotValidationReport{}
+       validateSnapshotFile(report, 3, manifest, builder.Build())
+       require.Len(t, report.Issues, 1)
+       require.Equal(t, SnapshotIssueInvalidFileContent, report.Issues[0].Code)
+}
+
+func TestValidateSnapshotDeletionVectorTargets(t *testing.T) {
+       prefix := "mem://audit/dv-target/"
+       zero := int64(0)
+       cases := []struct {
+               name    string
+               added   []iceberg.DataFile
+               deleted []iceberg.DataFile
+               want    SnapshotValidationIssueCode
+       }{
+               {name: "different vectors same target", added: 
[]iceberg.DataFile{validationDeletionVector(t, prefix+"d.puffin", 
prefix+"a.parquet", 100), validationDeletionVector(t, prefix+"d.puffin", 
prefix+"a.parquet", 200)}, want: SnapshotIssueDuplicateDeletionVector},
+               {name: "distinct targets share puffin", added: 
[]iceberg.DataFile{validationDeletionVector(t, prefix+"d.puffin", 
prefix+"a.parquet", 100), validationDeletionVector(t, prefix+"d.puffin", 
prefix+"b.parquet", 200)}},
+               {name: "replacement tombstone", added: 
[]iceberg.DataFile{validationDeletionVector(t, prefix+"d.puffin", 
prefix+"a.parquet", 200)}, deleted: 
[]iceberg.DataFile{validationDeletionVector(t, prefix+"d.puffin", 
prefix+"a.parquet", 100)}},
+               {name: "empty target", added: 
[]iceberg.DataFile{validationDeletionVector(t, prefix+"d.puffin", "", 100)}, 
want: SnapshotIssueMissingDeletionVectorTarget},
+       }
+       for _, tc := range cases {
+               t.Run(tc.name, func(t *testing.T) {
+                       fs := iceio.NewMemFS()
+                       manifest := writeValidationManifest(t, fs, 3, 
prefix+"delete.avro", iceberg.ManifestContentDeletes, 1, tc.added, nil, 
tc.deleted)
+                       list := writeValidationManifestListV3(t, fs, 
prefix+"list.avro", 1, 1, []iceberg.ManifestFile{manifest})
+                       tbl := validationTable(t, 3, fs, Snapshot{SnapshotID: 
1, SequenceNumber: 1, TimestampMs: time.Now().UnixMilli(), ManifestList: list, 
FirstRowID: &zero, AddedRows: &zero})
+                       report, err := 
tbl.Inspect().ValidateSnapshot(t.Context(), 1)
+                       require.NoError(t, err)
+                       if tc.want == "" {
+                               require.True(t, report.Valid(), "%+v", 
report.Issues)
+
+                               return
+                       }
+                       require.Len(t, report.Issues, 1)
+                       require.Equal(t, tc.want, report.Issues[0].Code)
+               })
+       }
+}
+
+func TestValidateSnapshotRejectsInvalidDeletionVectorBlobRanges(t *testing.T) {
+       const prefix = "mem://audit/invalid-dv-range/"
+       cases := []struct {
+               name      string
+               setOffset bool
+               offset    int64
+               setSize   bool
+               size      int64
+               valid     bool
+       }{
+               {"missing offset", false, 0, true, 32, false},
+               {"missing size", true, 100, false, 0, false},
+               {"negative offset", true, -1, true, 32, false},
+               {"zero size", true, 100, true, 0, false},
+               {"negative size", true, 100, true, -1, false},
+               {"range exceeds container", true, 1000, true, 32, false},
+               {"offset plus size overflows", true, math.MaxInt64 - 4, true, 
16, false},
+               {"range ends at container end", true, 992, true, 32, true},
+               {"valid range", true, 100, true, 32, true},
+       }
+       for _, tc := range cases {
+               t.Run(tc.name, func(t *testing.T) {
+                       builder, err := iceberg.NewDataFileBuilder(
+                               *iceberg.UnpartitionedSpec, 
iceberg.EntryContentPosDeletes,
+                               prefix+"d.puffin", iceberg.PuffinFile, nil, 
nil, nil, 1, 1024)
+                       require.NoError(t, err)
+                       builder.ReferencedDataFile(prefix + "data.parquet")
+                       if tc.setOffset {
+                               builder.ContentOffset(tc.offset)
+                       }
+                       if tc.setSize {
+                               builder.ContentSizeInBytes(tc.size)
+                       }
+                       fs := iceio.NewMemFS()
+                       manifest := writeValidationManifest(t, fs, 3, 
prefix+"manifest.avro",
+                               iceberg.ManifestContentDeletes, 1, 
[]iceberg.DataFile{builder.Build()}, nil, nil)
+                       list := writeValidationManifestListV3(t, fs, 
prefix+"list.avro", 1, 1, []iceberg.ManifestFile{manifest})
+                       zero := int64(0)
+                       tbl := validationTable(t, 3, fs, Snapshot{
+                               SnapshotID: 1, SequenceNumber: 1, TimestampMs: 
time.Now().UnixMilli(),
+                               ManifestList: list, FirstRowID: &zero, 
AddedRows: &zero,
+                       })
+                       report, err := 
tbl.Inspect().ValidateSnapshot(t.Context(), 1)
+                       require.NoError(t, err)
+                       require.True(t, report.Complete)
+                       require.EqualValues(t, 1, report.LiveFilesChecked)
+                       if tc.valid {
+                               require.True(t, report.Valid(), "%+v", 
report.Issues)
+
+                               return
+                       }
+                       require.False(t, report.Valid())
+                       require.Len(t, report.Issues, 1)
+                       require.Equal(t, 
SnapshotIssueInvalidDeletionVectorRange, report.Issues[0].Code)
+                       require.Equal(t, prefix+"d.puffin", 
report.Issues[0].FilePath)
+               })
+       }
+}
+
+func TestValidateSnapshotMissingDeletionVectorTarget(t *testing.T) {
+       fs := iceio.NewMemFS()
+       const prefix = "mem://audit/missing-dv-target/"
+       b, err := iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec, 
iceberg.EntryContentPosDeletes,
+               prefix+"d.puffin", iceberg.PuffinFile, nil, nil, nil, 1, 1024)
+       require.NoError(t, err)
+       file := b.ContentOffset(10).ContentSizeInBytes(32).Build()
+       manifest := writeValidationManifest(t, fs, 3, prefix+"manifest.avro", 
iceberg.ManifestContentDeletes, 1, []iceberg.DataFile{file}, nil, nil)
+       list := writeValidationManifestListV3(t, fs, prefix+"list.avro", 1, 1, 
[]iceberg.ManifestFile{manifest})
+       zero := int64(0)
+       tbl := validationTable(t, 3, fs, Snapshot{SnapshotID: 1, 
SequenceNumber: 1, TimestampMs: time.Now().UnixMilli(), ManifestList: list, 
FirstRowID: &zero, AddedRows: &zero})
+       report, err := tbl.Inspect().ValidateSnapshot(t.Context(), 1)
+       require.NoError(t, err)
+       require.Len(t, report.Issues, 1)
+       require.Equal(t, SnapshotIssueMissingDeletionVectorTarget, 
report.Issues[0].Code)
+}
+
+func TestValidateSnapshotDuplicateDeletionVectorAcrossManifests(t *testing.T) {
+       fs := iceio.NewMemFS()
+       const prefix = "mem://audit/dv-cross-manifest/"
+       a := validationDeletionVector(t, prefix+"a.puffin", 
prefix+"data.parquet", 100)
+       b := validationDeletionVector(t, prefix+"b.puffin", 
prefix+"data.parquet", 200)
+       first := writeValidationManifest(t, fs, 3, prefix+"a.avro", 
iceberg.ManifestContentDeletes, 1, []iceberg.DataFile{a}, nil, nil)
+       second := writeValidationManifest(t, fs, 3, prefix+"b.avro", 
iceberg.ManifestContentDeletes, 1, []iceberg.DataFile{b}, nil, nil)
+       list := writeValidationManifestListV3(t, fs, prefix+"list.avro", 1, 1, 
[]iceberg.ManifestFile{first, second})
+       zero := int64(0)
+       tbl := validationTable(t, 3, fs, Snapshot{SnapshotID: 1, 
SequenceNumber: 1, TimestampMs: time.Now().UnixMilli(), ManifestList: list, 
FirstRowID: &zero, AddedRows: &zero})
+       report, err := tbl.Inspect().ValidateSnapshot(t.Context(), 1)
+       require.NoError(t, err)
+       require.Len(t, report.Issues, 1)
+       require.Equal(t, SnapshotIssueDuplicateDeletionVector, 
report.Issues[0].Code)
+       require.Equal(t, prefix+"a.avro", report.Issues[0].FirstManifestPath)
+       require.Equal(t, prefix+"data.parquet", report.Issues[0].Detail)
+}
diff --git a/website/src/api.md b/website/src/api.md
index e6110ee4b..d851518d4 100644
--- a/website/src/api.md
+++ b/website/src/api.md
@@ -503,6 +503,43 @@ err = txn.RollbackToSnapshot(targetSnapshotID)
 
 Tune retention with the `history.expire.min-snapshots-to-keep`, 
`history.expire.max-snapshot-age-ms`, and `history.expire.max-ref-age-ms` table 
properties (see [Configuration](./configuration.md)).
 
+### Snapshot integrity audit (v1 and later)
+
+Use `ValidateSnapshot` to inspect a snapshot's manifest list and entries 
without
+changing table data. This is especially useful for checking imported v1 tables
+before allowing them into production.
+
+```go
+report, err := tbl.Inspect().ValidateSnapshot(ctx, snapshotID)
+if err != nil {
+    return err // I/O failed or the scan was canceled; report may be 
incomplete.
+}
+if !report.Valid() {
+    for _, issue := range report.Issues {
+        fmt.Printf("%s: %s (%s)\n", issue.Code, issue.FilePath, 
issue.ManifestPath)
+    }
+}
+```
+
+The report checks manifest versions, partition spec references, content types,
+manifest entry counts, duplicate manifest paths, and duplicate live content 
files
+across **all** manifests in a snapshot. It also checks that live deletion 
vectors
+have a target path, that their blob ranges fit within the recorded file size,
+and that no two distinct vectors target the same data file.
+Deleted entries do not count as live.
+A report is valid only when `Complete` is true and `Issues` is empty.
+This is a metadata-only audit: it does not check data-file contents or whether
+data-file objects still exist. The audit works with both v1 manifest lists and
+older v1 snapshots containing embedded `manifests` paths. For embedded
+manifests, the partition spec ID is checked against table metadata when the
+optional header provides it, or inferred when the partition fields match 
exactly
+one historical spec. Missing, ambiguous, or mismatched partition specs are
+reported as issues rather than silently accepted.
+
+The file uniqueness and manifest requirements come from the
+[Iceberg spec](https://iceberg.apache.org/spec/#content-file-uniqueness)
+and [manifest list spec](https://iceberg.apache.org/spec/#manifest-lists).
+
 ## Maintenance
 
 ### Orphan file cleanup

Reply via email to