Problem
platform_wallet_manager_create_impl copies the host-supplied vtable with std::ptr::read(persistence), which reads the full extent of PersistenceCallbacks as the current Rust definition declares it. The host allocates the struct as its header declares it.
Every time a slot is appended, those two sizes diverge for any host that has not rebuilt against the new header. Rust then reads past the end of the host's object, and the trailing bytes are interpreted as Option<unsafe extern "C" fn…> — a non-null value there is called as a callback.
Appending at the end (the convention documented in the struct) keeps existing offsets stable, which prevents mis-assignment of the old slots. It does not prevent the over-read.
Why now
Raised on #4300 by @thepastaclaw and independently by @bfoss765 from the Android side, where the same class of problem was hit. That PR appends two slots (22 → 24, or 38 → 40 with shielded); the same growth already happened twice before, for on_persist_invitations_fn and then release_fn, each time re-pinning ffi_capability_projection_has_stable_v1_layout_values.
So this is a property of the structure, not of any one PR.
Current exposure
- Android — immune.
build_vtable constructs the struct in Rust inside the same crate graph, so the definitions cannot disagree.
- iOS — latent.
build_ios.sh regenerates the header and the Swift package together, so today both sides always agree. The hazard becomes live if the XCFramework and the consuming app are ever built or versioned separately.
Options
- Size-carrying struct — a leading
size: usize (or version: u32) the host sets; Rust reads min(host_size, own_size) and treats absent trailing slots as None.
- Extension struct — freeze
PersistenceCallbacks at its current size and pass new callbacks through a separate, separately-negotiated structure.
- New creation entry point —
platform_wallet_manager_create_v2(…, callbacks_size), keeping the current one at the frozen layout.
(1) is the smallest change that fixes the whole class and keeps a single struct; (2) and (3) avoid touching the existing entry point at all.
Definition of done
A host built against an older header can be passed to a newer library without any read outside the object it allocated, and unset trailing callbacks are observed as None. The existing layout test should then pin the negotiation rule rather than a fixed slot count.
Problem
platform_wallet_manager_create_implcopies the host-supplied vtable withstd::ptr::read(persistence), which reads the full extent ofPersistenceCallbacksas the current Rust definition declares it. The host allocates the struct as its header declares it.Every time a slot is appended, those two sizes diverge for any host that has not rebuilt against the new header. Rust then reads past the end of the host's object, and the trailing bytes are interpreted as
Option<unsafe extern "C" fn…>— a non-null value there is called as a callback.Appending at the end (the convention documented in the struct) keeps existing offsets stable, which prevents mis-assignment of the old slots. It does not prevent the over-read.
Why now
Raised on #4300 by @thepastaclaw and independently by @bfoss765 from the Android side, where the same class of problem was hit. That PR appends two slots (22 → 24, or 38 → 40 with
shielded); the same growth already happened twice before, foron_persist_invitations_fnand thenrelease_fn, each time re-pinningffi_capability_projection_has_stable_v1_layout_values.So this is a property of the structure, not of any one PR.
Current exposure
build_vtableconstructs the struct in Rust inside the same crate graph, so the definitions cannot disagree.build_ios.shregenerates the header and the Swift package together, so today both sides always agree. The hazard becomes live if the XCFramework and the consuming app are ever built or versioned separately.Options
size: usize(orversion: u32) the host sets; Rust readsmin(host_size, own_size)and treats absent trailing slots asNone.PersistenceCallbacksat its current size and pass new callbacks through a separate, separately-negotiated structure.platform_wallet_manager_create_v2(…, callbacks_size), keeping the current one at the frozen layout.(1) is the smallest change that fixes the whole class and keeps a single struct; (2) and (3) avoid touching the existing entry point at all.
Definition of done
A host built against an older header can be passed to a newer library without any read outside the object it allocated, and unset trailing callbacks are observed as
None. The existing layout test should then pin the negotiation rule rather than a fixed slot count.