-
Notifications
You must be signed in to change notification settings - Fork 455
fix(gnovm): embedded type identity — struct field name + interface method-set flatten #5739
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ltzmaxwell
wants to merge
42
commits into
gnolang:master
Choose a base branch
from
ltzmaxwell:fix/alias_2
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
42 commits
Select commit
Hold shift + click to select a range
6fb5ea7
test(gnovm): add failing filetest alias2
ltzmaxwell ce32d09
fix(gnovm): preserve embedded alias name as struct field name
ltzmaxwell 813ad3b
refactor(gnovm): reuse unconst in pickEmbedName
ltzmaxwell d0a7b5e
test(gnovm): cover selector, pointer, and interface alias embeds
ltzmaxwell fbfbab4
refactor(gnovm): inline pickEmbedName, drop dead embed-name fallback
ltzmaxwell bd22a3c
test(gnovm): cover alias-to-defined-type embedded field name
ltzmaxwell 155f1a7
docs(gnovm): explain what each alias-embed filetest guards
ltzmaxwell 589ab87
fix(gnovm): name embedded interfaces from resolved type, not alias sp…
ltzmaxwell 6426f3d
fix(gnovm): flatten embedded interface method sets for type identity
ltzmaxwell 20a51ce
refactor(gnovm): build interface methods with embed=false before flat…
ltzmaxwell 7f226b4
docs(gnovm): tighten flatten comment, note legacy embedded-interface …
ltzmaxwell e2c3d98
fix(gnovm): preserve cross-package unexported-method identity when fl…
ltzmaxwell 6e9c403
test(gnovm): unit-test flattenInterfaceMethods origin-pkgpath and dedup
ltzmaxwell bccbd4f
test(gnovm): move flatten test into types_test.go (matches types.go)
ltzmaxwell 280b575
test(gnovm): cover struct embedding an interface alias (spelling rule)
ltzmaxwell 37c99e0
fix(gnovm): sort interface methods by the same package fallback emiss…
ltzmaxwell 216e8ae
test(gnovm): clarify provenance test — empty PkgPath is direct-decl o…
ltzmaxwell 2c95fec
test(gnovm): filetest for interface identity across construction prov…
ltzmaxwell 5cec6f6
test(gnovm): codec parity for FieldType.PkgPath wire field
ltzmaxwell 67d07ae
test(gnovm): multi-level cross-package embed preserves unexported ori…
ltzmaxwell 4003909
test(gnovm): pin that doOpInterfaceType flattens embeds (runtime path)
ltzmaxwell eeed186
docs(gnovm): ADR follow-up — migrate+re-flatten persisted types, then…
ltzmaxwell 423483f
docs(gnovm): reframe ADR follow-up as fresh/migrate if-else, decision…
ltzmaxwell a1dd1a0
docs(gnovm): ADR — precise master behavior (resolved-name embed; alia…
ltzmaxwell 8fcbf67
docs(gnovm): trim ADR — drop transient-state note, keep the flatten v…
ltzmaxwell d3ab1e6
test(gnovm): gofmt alias2 (blank line between func and const decls)
ltzmaxwell a769982
test(gnovm): fold alias5 into iface_embed_id (consistent filetest cat…
ltzmaxwell 6ad03c9
test(gnovm): two same-name unexported methods from different packages…
ltzmaxwell 8f864b3
test(gnovm): pin same-name/conflicting-signature embed is rejected
ltzmaxwell a3b14e2
refactor(gnovm): /simplify — drop dead Len, extract originPkg, stamp …
ltzmaxwell 0c7c6d3
fix(gnovm): gate unexported interface-method selection by origin pack…
ltzmaxwell 7302974
refactor(gnovm): slices.ContainsFunc for the flatten no-embed fast path
ltzmaxwell 880040d
fix(gnovm)!: assume flattened interfaces; hard-error on pre-flattenin…
ltzmaxwell c8c7329
refactor(gnovm): debugAssert-gate hot unflattened-interface checks, k…
ltzmaxwell d887f38
test(gnovm): t.Parallel in hard-error subtests (tparallel lint)
ltzmaxwell 37f6ea2
Merge branch 'master' into fix/alias_2
ltzmaxwell df6772e
Merge branch 'master' into fix/alias_2
ltzmaxwell a43ef22
refactor(gnovm): move unflattened-interface check to decode boundary …
ltzmaxwell 8d5ab2e
refactor(gnovm): shorten unflattened-interface panic message
ltzmaxwell 72145c8
refactor(gnovm): minimal unflattened-interface panic message
ltzmaxwell f6e7f09
test(gnovm): pin rune/byte builtin-alias embeds distinct from int32/u…
ltzmaxwell a00dde6
test(gnovm): cover doOpStructType embed naming (unit + filetest that …
ltzmaxwell File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| # Interface method-set flattening and cross-package unexported-method identity | ||
|
|
||
| ## Context | ||
|
|
||
| GnoVM derives an anonymous interface's `TypeID` from its method list. Before | ||
| this change, an *embedded* interface was kept as a single named entry in | ||
| `InterfaceType.Methods` (e.g. `interface{ Stringer }` stored a method-list | ||
| entry named `Stringer` whose type was the embedded interface). That made the | ||
| embedded-interface *name* — including an alias spelling — part of the | ||
| `TypeID`, so GnoVM diverged from Go: | ||
|
|
||
| - `interface{ Stringer }` was not identical to `interface{ Str() string }`. | ||
| - `interface{ SAlias }` (where `type SAlias = Stringer`) was not identical to | ||
| `interface{ Stringer }`. | ||
|
|
||
| **Precise behavior on master (verified):** master named the embed entry from the | ||
| *resolved* type — `fillEmbeddedName` does `case *DeclaredType: ft.Name = ct.Name` | ||
| — and never flattened. So the two divergences are not symmetric: | ||
|
|
||
| | comparison | master | Go | | ||
| |---|---|---| | ||
| | `interface{ SAlias }` vs `interface{ Stringer }` | **identical** (alias resolves to the same `Stringer` entry name) | identical | | ||
| | `interface{ Stringer }` vs `interface{ Str() string }` | **distinct** (entry named `Stringer` vs method `Str`) | identical | | ||
|
|
||
| So on master the alias case was already correct; only embed-vs-explicit | ||
| diverged. No naming policy — resolved or spelled — can fix embed-vs-explicit, | ||
| because the embed is still one named entry rather than its methods. Only | ||
| flattening removes the embed name from identity. | ||
|
|
||
| Go computes interface identity from the **flattened method set**; embedding | ||
| contributes methods, not a name. PR #5739 therefore flattens embedded | ||
| interfaces into their constituent methods at type construction | ||
| (`flattenInterfaceMethods`, called from `doOpInterfaceType` and | ||
| `staticTypeFromAST`), so the embed/alias spelling no longer leaks into the | ||
| `TypeID`. | ||
|
|
||
| ## The cross-package unexported-method problem | ||
|
|
||
| Flattening exposed a latent representational gap. An unexported interface | ||
| method's identity in Go is `(pkgpath, name)` — `p.sec` and `q.sec` are | ||
| distinct methods, and a type outside `p` cannot satisfy an interface | ||
| containing `p.sec` (the "sealed interface" pattern). GnoVM encodes that | ||
| package qualification **once**, on the containing `InterfaceType.PkgPath`, | ||
| used by `FieldTypeList.TypeIDForPackage` and by interface-satisfaction gating | ||
| in `VerifyImplementedBy` / `FindEmbeddedFieldType`. | ||
|
|
||
| When flattening hoists an unexported method out of an interface defined in | ||
| package `P` into an anonymous interface in package `Q`, that single-pkgpath | ||
| encoding re-qualifies the method to `Q`. A `/challenge` pass reproduced two | ||
| failures (both regressions vs the baseline before this change, confirmed against a | ||
| real-Go oracle): | ||
|
|
||
| 1. **Identity over-collapse.** `interface{ p.Sec }` (with unexported `p.sec`) | ||
| became identical to `interface{ sec() int }` declared in `q` — Go treats | ||
| them as distinct. | ||
| 2. **Satisfaction bypass (security-relevant).** A type declared in `q` with a | ||
| `sec()` method was accepted as satisfying `p.Sec`, bypassing the sealed- | ||
| interface mechanism. Go correctly rejects it. | ||
|
|
||
| Over-collapse is the dangerous direction (type confusion / access-control | ||
| bypass), and it is consensus-relevant in a VM. | ||
|
|
||
| ## Decision | ||
|
|
||
| Make flattening fully Go-faithful by recording each method's **origin | ||
| package** alongside the method, instead of relying on the enclosing | ||
| interface's single `PkgPath`: | ||
|
|
||
| - Add `FieldType.PkgPath` — the defining package of an unexported method | ||
| (empty for exported methods and for legacy/non-flattened entries, which | ||
| fall back to the enclosing interface's `PkgPath`). | ||
| - `flattenInterfaceMethods` stamps the origin package on each hoisted (and | ||
| directly-declared) unexported method, and deduplicates on `(pkgpath, name)` | ||
| so that two same-named unexported methods from different packages coexist. | ||
| - `FieldTypeList.Less` and `FieldTypeList.TypeIDForPackage` key/qualify on the | ||
| per-method `PkgPath` when set. | ||
| - `VerifyImplementedBy` gates unexported access against the method's origin | ||
| package, not the enclosing interface's package. | ||
|
|
||
| This is fully faithful to Go, including the pathological case of two distinct | ||
| same-package sealed interfaces with identical method sets. | ||
|
|
||
| ### Cost | ||
|
|
||
| `FieldType` is amino-serialized (`gnolang.proto` / `pb3_gen.go`), so adding | ||
| `PkgPath` changes the persisted shape of the type representation — a | ||
| serialization-format change requiring a coordinated chain upgrade, on top of | ||
| the `TypeID` changes flattening already implies. The new field is appended | ||
| (proto field 5) and defaults empty, so legacy-decoded entries behave exactly | ||
| as before. | ||
|
|
||
| ## Alternatives considered | ||
|
|
||
| ### Alternative A — do not hoist unsafe embeds (rejected, recorded for trace) | ||
|
|
||
| Flatten only when identity is preserved: hoist exported methods and | ||
| unexported methods whose origin package equals the enclosing interface; for an | ||
| embed carrying cross-package unexported methods, keep it as a sub-interface | ||
| entry and reuse the existing recursive satisfaction path. | ||
|
|
||
| - **Pro:** sound (no over-collapse, no bypass), small diff, **no serialization | ||
| change** — `FieldType` shape is untouched. | ||
| - **Con:** conservative — it *over-distinguishes* in one case: two distinct | ||
| sealed interfaces *in the same package* with identical method sets, embedded | ||
| from a *third* package, are treated as distinct where Go treats them as | ||
| identical. Over-distinction is the safe direction (a legitimate equality is | ||
| missed; satisfaction can never be forged), deterministic across nodes, and | ||
| the triggering case is exotic. | ||
|
|
||
| This was rejected only because we chose full Go-fidelity over avoiding the | ||
| serialization change. If the serialization/migration cost is later judged not | ||
| worth the pathological case, Alternative A is the drop-in fallback: it closes | ||
| the same security hole with no persisted-shape change. This ADR exists so that | ||
| trade-off can be revisited. | ||
|
|
||
| ### Alternative B — keep the embed-entry representation (rejected) | ||
|
|
||
| Name embedded interfaces from the resolved type (the minimal fix that shipped | ||
| first on this branch). Sound, but only equalizes alias-vs-target; leaves | ||
| `interface{ Stringer } != interface{ Str() string }` diverging from Go. | ||
|
|
||
| ## Consequences | ||
|
|
||
| - Interface identity matches Go across embedding, aliasing, multi-level, | ||
| diamond, order, mixed embed+direct, and cross-package (exported and | ||
| unexported) method sets. | ||
| - Consensus-breaking: `TypeID`s of anonymous interfaces that embed other | ||
| interfaces change, and the `FieldType` serialization gains a field. Must | ||
| land with a chain upgrade (same release class as #5737: coordinated | ||
| upgrade / fresh genesis). | ||
| - Runtime **assumes flattened** interfaces. The legacy embedded-interface | ||
| branches in `FindEmbeddedFieldType` / `VerifyImplementedBy` are dropped; an | ||
| `InterfaceKind` entry in `Methods` (only possible by decoding | ||
| bytes persisted before this change) is a **hard error** (`panicUnflattened`), | ||
| enforced where the concern lives: ungated at the **decode boundary** | ||
| (`fillType`, reached from both type-entry decode and object loads — store | ||
| bytes are external input, so this check stays in production), and under | ||
| `-tags debugAssert` at the interior sites (method resolution, satisfaction, | ||
| `TypeID`), which may assume the invariant on a validated store. | ||
|
|
||
| ## Rollout: state persisted before this change is unsupported (decided) | ||
|
|
||
| Every `InterfaceType` is born one of three ways: **preprocess (AST)**, | ||
| **runtime (`doOpInterfaceType`)**, or **decode**. The first two always flatten; | ||
| decode faithfully reproduces stored bytes. So an unflattened interface can | ||
| reach runtime in exactly one situation: decoding bytes persisted **before | ||
| this change**. | ||
|
|
||
| Tolerating those bytes (the recursion branches this PR originally kept) was a | ||
| half-measure: the type's *identity* already moved with this change, so | ||
| resolution/satisfaction would "work" against a type whose equality, type-keyed | ||
| store entries, and hashes are silently split from post-change construction. | ||
| Silent-wrong loses to loud-fail on a chain, so the branches are **dropped** | ||
| and a decoded unflattened interface **panics** with an actionable message. | ||
|
|
||
| A network carrying state persisted before this change has two supported paths, both of which | ||
| make unflattened bytes impossible (the panic is then a dead invariant check): | ||
|
|
||
| - **Fresh genesis / tx replay** (how gno.land testnets regenesis): all state | ||
| is reconstructed through the new VM, flattened by construction. The only | ||
| pre-fork audit is source acceptance — historical txs must still preprocess | ||
| (this PR tightens one case: cross-package unexported-method selection). | ||
| - **Re-flatten migration** (only if in-place state must survive): walk the | ||
| type table, flatten every `InterfaceType` (stamping origin `PkgPath`), | ||
| remap old→new `TypeID`s in all object refs, merge newly-colliding entries, | ||
| and verify no `InterfaceKind` entry remains. | ||
|
|
||
| Conflict handling (same-name, different-signature embedded methods) needs no | ||
| follow-up: `flattenInterfaceMethods` `panic`s, but during preprocessing that is | ||
| wrapped into a positioned `*PreprocessError` (the same idiom as other type | ||
| errors, e.g. "struct has no field"), so it surfaces as a matchable `// Error:`. | ||
| go/types also rejects it as a `// TypeCheckError:`. Both are pinned by | ||
| `iface_embed_conflict.gno`, matching Go's "duplicate method" compile error. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the one ungated legacy-state check, placed here deliberately: store bytes are external input, and
fillTypesees every stored type — type entries viaGetTypeSafe, and object-carried anonymous types viafillTypesOfValue(onlyDeclaredTypecollapses toRefTypeon persist; unnamed types nest inline, so aGetTypeSafe-only check would miss them). An embed entry can only come from bytes persisted before flattening, so it fails at decode, before any semantics run.The interior sites (
FindEmbeddedFieldType/VerifyImplementedBy/TypeID) assume the invariant on a validated store and only assert under-tags debugAssert.