Skip to content

PersistenceCallbacks vtable grows without version negotiation #4303

Description

@romchornyi

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

  1. 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.
  2. Extension struct — freeze PersistenceCallbacks at its current size and pass new callbacks through a separate, separately-negotiated structure.
  3. New creation entry pointplatform_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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions