Skip to content

fix(lang): return AnchorError instead of panicking on truncated zero-copy accounts#4528

Open
abhicris wants to merge 2 commits into
otter-sec:masterfrom
abhicris:fix/account-loader-truncation-panic
Open

fix(lang): return AnchorError instead of panicking on truncated zero-copy accounts#4528
abhicris wants to merge 2 commits into
otter-sec:masterfrom
abhicris:fix/account-loader-truncation-panic

Conversation

@abhicris
Copy link
Copy Markdown
Contributor

Summary

AccountLoader::{load, load_mut, load_init} and the generated #[account(zero)] validator slice into account data using disc.len() + size_of::<T>() (and, for load_init / #[account(zero)], also the discriminator prefix itself) without first verifying that the buffer is that long. Any account that passes the discriminator existence check but is shorter than the zero-copy body — e.g. a discriminator-sized account, or an account truncated by one byte — panics with range end index N out of range for slice of length M and surfaces to clients as Program failed to complete rather than as a structured AnchorError. This complicates client-side error handling, monitoring, and retry logic for any program that uses zero-copy accounts.

What changed

lang/src/accounts/account_loader.rs:

  • Added a required_zero_copy_len::<T> helper that computes disc.len() + size_of::<T>() with checked_add and maps overflow to AccountDidNotDeserialize (defence in depth — overflow is not reachable for any practical T, but the alternative was a silent wrap).
  • Gated the body slice in load, load_mut, and load_init on data.len() >= required and returns AccountDidNotDeserialize when it isn't.
  • Added the missing data.len() < disc.len() check at the top of load_init (load and load_mut already had one — only load_init was missing it).

lang/syn/src/codegen/accounts/constraints.rs (generate_constraint_zeroed):

  • Inserted the same data.len() < disc.len() bounds check ahead of the discriminator slice. The generated code now returns AccountDiscriminatorNotFound (with the account name) instead of panicking inside the user's instruction.

The error codes match what the rest of anchor-lang already returns for the equivalent situation on the non-zero-copy Account path, so client SDKs and AnchorError consumers see no new variants.

How I validated it

New file lang/tests/account_loader_truncation.rs with five regression tests, each exercising one panic site:

Test Site Before After
load_returns_error_when_data_shorter_than_zero_copy_body load slice at account_loader.rs:169 panic range end index 32 out of range for slice of length 8 AccountDidNotDeserialize
load_mut_returns_error_when_data_shorter_than_zero_copy_body load_mut slice at :194 panic range end index 32 out of range for slice of length 31 AccountDidNotDeserialize
load_init_returns_error_when_data_shorter_than_zero_copy_body load_init slice at :220 panic AccountDidNotDeserialize
load_init_returns_error_when_data_shorter_than_discriminator load_init disc-prefix slice at :212 panic AccountDiscriminatorNotFound
load_succeeds_on_exactly_sized_account happy path ok ok

I confirmed by stashing the source-side fix that the four failure-mode tests reproduce the original panics verbatim on master, and that the happy-path test continues to pass with the fix applied.

Run:

cargo test -p anchor-lang --test account_loader_truncation
# test result: ok. 6 passed; 0 failed; 0 ignored

Full anchor-lang suite stays green:

cargo test -p anchor-lang
# all green; nothing else touched

Also clean: cargo clippy -p anchor-lang --lib --tests -- -D warnings, cargo check --workspace --exclude anchor-cli, and rustfmt --check on the touched files.

Out of scope

  • The #[account(zero)] codegen still slices the discriminator prefix on a separate path inside #from_account_info (the generated AccountInfo-to-AccountLoader conversion). With this PR's fix to AccountLoader::try_from, that path no longer panics — it returns AccountDiscriminatorNotFound. No further codegen change required, but worth flagging.
  • The non-zero-copy Account path uses the user's AccountDeserialize impl, which already returns AccountDidNotDeserialize on a short read; not touched here.
  • I did not add a guard on mem::size_of::<T>() > isize::MAX — Solana's runtime account-size cap is far below that, so the checked_add is purely defensive.

Closes #4509.

…copy accounts

`AccountLoader::{load, load_mut, load_init}` and the generated
`#[account(zero)]` validator sliced into account data using
`disc.len() + size_of::<T>()` (and, for `load_init` / `#[account(zero)]`,
also the discriminator prefix itself) without first verifying that the
buffer was that long. Any account that passed the discriminator
existence check but was shorter than the zero-copy body — e.g. a
discriminator-sized account, or an account truncated by 1 byte — would
panic with `range end index N out of range for slice of length M` and
surface to clients as `Program failed to complete` rather than as a
structured `AnchorError`.

This change:

- adds a `required_zero_copy_len::<T>` helper that performs the
  `disc.len() + size_of::<T>()` arithmetic with `checked_add` and maps
  overflow to `AccountDidNotDeserialize`.
- gates the body slice in `load`, `load_mut`, and `load_init` on
  `data.len() >= required` and returns `AccountDidNotDeserialize` when
  it isn't.
- adds the missing `data.len() < disc.len()` check at the top of
  `load_init` (the existing one in `load` / `load_mut` was already
  there).
- adds the same bounds check ahead of the discriminator slice in the
  `generate_constraint_zeroed` codegen, returning
  `AccountDiscriminatorNotFound` instead of panicking inside the
  client's instruction.

Adds `lang/tests/account_loader_truncation.rs` with five regression
tests covering each panic site and one happy-path sanity test; all five
failure-mode tests panic on `master` and return the documented error
codes after this patch.

Closes otter-sec#4509.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 13, 2026

@abhicris is attempting to deploy a commit to the Solana Foundation Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 13, 2026

Greptile Summary

This PR eliminates index-out-of-bounds panics in AccountLoader::{load, load_mut, load_init} and the #[account(zero)] constraint codegen when account data is shorter than expected, replacing hard crashes with structured AnchorError values.

  • account_loader.rs: adds a required_zero_copy_len<T> helper (overflow-safe) and gates every body slice on data.len() >= disc.len() + size_of::<T>(); also back-fills the missing discriminator-length guard in load_init that load/load_mut already had.
  • constraints.rs: adds a data.len() < discriminator.len() guard in generate_constraint_zeroed before the codegen slices the discriminator prefix, returning AccountDiscriminatorNotFound with the account name. A matching body-size guard is not present, so a truncated-body account that passes the discriminator check still defers the error to the user's load_init() call (see inline suggestion).
  • Five regression tests cover all four original panic sites and the happy path; the PR description notes "6 passed" but the file defines 5 #[test] functions.

Confidence Score: 4/5

The core panic-to-error conversion in account_loader.rs is correct and well-tested; the only gap is that the #[account(zero)] codegen path defers a body-too-short error to load_init() rather than catching it during constraint validation.

All four panic sites in account_loader.rs are correctly guarded and covered by regression tests. The constraints.rs codegen fixes the discriminator-slice panic but does not add the matching body-size guard, meaning a truncated-body account with a valid-length discriminator region still produces a structured error (no panic), but without the account-name context that constraint-level checks normally provide. The change is safe to merge and accomplishes its stated goal; the missing guard is a quality gap rather than a regression.

lang/syn/src/codegen/accounts/constraints.rs — the body-size check is absent from generate_constraint_zeroed

Important Files Changed

Filename Overview
lang/src/accounts/account_loader.rs Adds required_zero_copy_len helper and bounds-checks before every body slice in load, load_mut, and load_init; also adds the missing discriminator-length guard in load_init. All panic paths are now covered and return structured AnchorError values.
lang/syn/src/codegen/accounts/constraints.rs Adds discriminator-length guard in generate_constraint_zeroed to prevent the codegen from slicing without bounds checking, but the matching body-size guard is absent — truncated-body accounts pass constraint validation and the error surfaces later from load_init() without account-name context.
lang/tests/account_loader_truncation.rs Five regression tests cover all four panic sites plus the happy path. The PR description claims "6 passed" but only 5 #[test] functions are defined — a minor documentation discrepancy that does not affect correctness.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[AccountLoader load_init called] --> B{is_writable?}
    B -- No --> E1[Err: AccountNotMutable]
    B -- Yes --> C[borrow_mut_data]
    C --> D{data too short for discriminator?}
    D -- Yes --> E2["Err: AccountDiscriminatorNotFound (NEW)"]
    D -- No --> F[slice discriminator prefix]
    F --> G{any byte is nonzero?}
    G -- Yes --> E3[Err: AccountDiscriminatorAlreadySet]
    G -- No --> H{data too short for body?}
    H -- Yes --> E4["Err: AccountDidNotDeserialize (NEW)"]
    H -- No --> I[bytemuck::from_bytes_mut on body slice]
    I --> OK[Ok: RefMut to T]

    style E2 fill:#f9c,stroke:#c66
    style E4 fill:#f9c,stroke:#c66
    style OK fill:#cfc,stroke:#6c6
Loading

Comments Outside Diff (1)

  1. lang/syn/src/codegen/accounts/constraints.rs, line 288-292 (link)

    P2 Body-size truncation is not caught at constraint-validation time. If account data is long enough for the discriminator but shorter than disc.len() + size_of::<T>(), the discriminator check here passes, try_from_unchecked succeeds (ownership only), and the error is deferred to the user's load_init() call — which returns AccountDidNotDeserialize without with_account_name(). Adding an explicit body-size guard here surfaces the error during #[account(zero)] constraint validation with the account name attached, consistent with the new discriminator guard above it.

Reviews (1): Last reviewed commit: "fix(lang): return AnchorError instead of..." | Re-trigger Greptile

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.

AccountLoader::{load, load_mut, load_init} and #[account(zero)] panic on under-sized accounts instead of returning AnchorError

2 participants