Skip to content

fix(table): return defensive metadata getter copies#1444

Open
fallintoplace wants to merge 5 commits into
apache:mainfrom
fallintoplace:fix/metadata-defensive-getters
Open

fix(table): return defensive metadata getter copies#1444
fallintoplace wants to merge 5 commits into
apache:mainfrom
fallintoplace:fix/metadata-defensive-getters

Conversation

@fallintoplace

@fallintoplace fallintoplace commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Return defensive copies from table metadata getters so callers cannot mutate persisted metadata through returned maps, slices, pointers, or nested values.

The clones cover schemas and defaults, partition specs, snapshots and refs, sort orders, statistics/blob metadata, partition statistics, encryption keys, and properties. Nil slice behavior is preserved.

Testing

  • go test ./table ./cmd/iceberg
  • regression coverage mutates every returned getter value—including binary defaults, source ID slices, blob metadata, and encryption properties—and verifies the underlying metadata remains unchanged

@fallintoplace
fallintoplace requested a review from zeroshade as a code owner July 10, 2026 22:13

@zeroshade zeroshade left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good direction on defensive copies for the metadata getters. Two of them are still shallow, which defeats the protection for those types (inline).

Comment thread table/metadata.go Outdated
func (c *commonMetadata) LastUpdatedMillis() int64 { return c.LastUpdatedMS }
func (c *commonMetadata) LastColumnID() int { return c.LastColumnId }
func (c *commonMetadata) Schemas() []*iceberg.Schema { return c.SchemaList }
func (c *commonMetadata) Schemas() []*iceberg.Schema { return slices.Clone(c.SchemaList) }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

slices.Clone copies the outer slice but not the *iceberg.Schema pointees, so callers still receive the original Schema objects and can mutate exported fields like ID/IdentifierFieldIDs, corrupting metadata state. To actually protect it, clone each schema (or return immutable copies).

Comment thread table/metadata.go Outdated

func (c *commonMetadata) PartitionSpecs() []iceberg.PartitionSpec {
return c.Specs
return slices.Clone(c.Specs)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same shallow-copy issue: slices.Clone(c.Specs) copies the PartitionSpec structs but they still share their internal fields slice and each field's SourceIDs backing array, so specs[i].Field(j).SourceIDs[k] = ... mutates the original. A deep clone of each spec (fields + SourceIDs, rebuilding derived maps) is needed here — you already do the SourceIDs clone in the builder clone() around line 1722.

@laskoviymishka laskoviymishka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for this — defending the metadata getters with copies is the right call, and the SnapshotRef/Snapshot/Properties cases look solid.

I'd hold it before merging, though. There's still one shallow copy live: cloneNestedFields deep-copies each field's Type but leaves InitialDefault/WriteDefault as shared any values, and those can hold a []byte (BinaryLiteral/FixedLiteral) — so a caller can still mutate through schema.Fields()[n].WriteDefault back into the original. I think that's the same class zeroshade flagged in the still-open shallow-copy thread, so worth closing them together with a small cloneDefault helper.

The other thing is the new test. The SourceIDs assertions don't actually exercise the clones they're guarding — PartitionField.SourceIDs and SortField.SourceIDs are json:"-", so the final JSONEq never observes them, and a few of the mutations run against value copies. As written the suite passes whether or not those SourceIDs clones exist, which leaves the regression net with a hole right where it counts. I'd assert slice-distinctness directly instead of leaning on the JSON round-trip.

A few things I'd want before merge:

  • deep-copy InitialDefault/WriteDefault in cloneNestedFields
  • make the SourceIDs / SortOrder test assertions actually fail if the clone is removed
  • decide Statistics()/PartitionStatistics()/EncryptionKeys() — clone them too, or document them as uncopied and defer

The nil→non-nil slice change and the cold-cache cost on cloneSchema are worth a look too, but they're not blockers. Once the copy gap and the test are sorted, happy to take another pass.

Comment thread table/metadata.go

func (c *commonMetadata) Properties() iceberg.Properties {
return c.Props
return maps.Clone(c.Props)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

While we're clamping down here, the getters just below still hand out shared internal state — Statistics(), PartitionStatistics() and EncryptionKeys() yield value copies of the top-level structs, but BlobMetadata.Properties/Fields and EncryptionKey.Properties are still the internal maps/slices. metadata.Statistics()...BlobMetadata[0].Properties["x"] = "y" mutates the real StatisticsList.

Same class as what this PR fixes, on the same interface. Either deep-clone these too, or explicitly document which getters guarantee copies and defer the rest — either's fine, but I'd rather not ship an interface where half the getters are safe and half aren't, silently.

Comment thread table/metadata.go
return nil
}

return iceberg.NewSchemaWithIdentifiers(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth a thought on the hot path: cloning through NewSchemaWithIdentifiers runs init(), so every clone comes back with cold lazy caches (idToName, nameToID, etc.). CurrentSchema()/Schemas() get hit per-file during scan planning, so this is a fair bit of re-derivation and allocation on large tables. (Also schema.Fields() already returns a slices.Clone, so cloneNestedFields clones the top-level slice a second time.)

Not blocking, but I'd consider either caching the clone or shallow-cloning while sharing the atomic cache pointers and copying only the mutable exported fields (ID, IdentifierFieldIDs). wdyt?

Comment thread table/metadata.go
}

func cloneSchemas(schemas []*iceberg.Schema) []*iceberg.Schema {
clones := make([]*iceberg.Schema, len(schemas))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One behavior change to watch: make([]T, len(x)) turns a nil SchemaList into a non-nil empty slice, so Schemas() now returns [] where it used to return nil (and marshals to [] instead of null). Same for clonePartitionSpecs/cloneSortOrders/cloneSnapshots. Any caller using == nil to mean "unset", or comparing JSON round-trips, will shift. I'd guard each with if x == nil { return nil }.

Comment thread table/metadata.go
func cloneNestedFields(fields []iceberg.NestedField) []iceberg.NestedField {
clones := slices.Clone(fields)
for i := range clones {
clones[i].Type = cloneSchemaType(clones[i].Type)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think there's still a shallow copy hiding here. We deep-copy Type but leave InitialDefault and WriteDefault as shared any values — and those can hold a []byte (BinaryLiteral/FixedLiteral), so a caller can do schema.Fields()[0].WriteDefault.([]byte)[0] = 0xFF and reach right back into the original.

This looks like the class zeroshade was pointing at in the shallow-copy thread. I'd add a small cloneDefault(v any) any that slices.Clones the []byte-backed cases and passes everything else through, then apply it to both defaults here.

Comment thread table/metadata.go
fields := make([]iceberg.PartitionField, spec.NumFields())
for i := range fields {
fields[i] = spec.Field(i)
fields[i].SourceIDs = slices.Clone(fields[i].SourceIDs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: this slices.Clone is redundant — NewPartitionSpecID already clones SourceIDs when it builds the field (partitions.go:372), so we clone twice. Can drop the explicit one here.


partitionSpecs := metadata.PartitionSpecs()
partitionField := partitionSpecs[0].Field(0)
partitionField.SourceIDs[0] = 99

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This block doesn't actually guard the SourceIDs clone. PartitionField.SourceIDs is json:"-" (partitions.go:47), so the closing JSONEq never sees it change — and Field(0) returns a value copy, so partitionField.Name = "mutated" is a no-op too. The whole assertion would still pass if we deleted slices.Clone(fields[i].SourceIDs) from the clone helper. Same holds for the PartitionSpec() / PartitionSpecByID() blocks just below.

I'd assert the returned SourceIDs is a distinct backing slice directly — mutate it, then read metadata.Specs[0].Field(0).SourceIDs back (the test is in package table) and require it unchanged — rather than relying on the JSON round-trip.

sortOrders := metadata.SortOrders()
fields := sortOrders[0].Fields()
for _, field := range fields {
field.SourceIDs[0] = 99

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same dead-coverage issue here — Fields() yields SortField by value, so field.SourceIDs[0] = 99 mutates a loop-local copy, and SortField.SourceIDs is json:"-" on top of that. Nothing in this block fails if cloneSortOrder is removed. Since we're in package table, I'd reach into sortOrders[0].fields directly and mutate a serializable field (Direction/NullOrder), or compare the slice identity against the original.

@fallintoplace fallintoplace changed the title fix: protect metadata getter state fix(table): return defensive metadata getter copies Jul 14, 2026

@zeroshade zeroshade left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-reviewed — the shallow-copy issue I flagged is addressed: Schemas() and PartitionSpecs() now deep-copy via cloneSchemas/clonePartitionSpecs (including each field's SourceIDs), so callers can't mutate the stored metadata. Thanks @fallintoplace!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants