Skip to content

MapView::iter_unique / len_unique are O(n²), which rules them out on any path that decodes untrusted input #363

Description

@advait-specter

What

buffa/src/view.rs:1927 scans forward from every entry to decide whether a later occurrence of the same key exists:

entries.iter().enumerate().filter_map(move |(i, entry)| {
    if entries[i + 1..].iter().any(|(later_k, _)| *later_k == entry.0) { None } else { Some(entry) }
})

len_unique (:1945) is iter_unique().count(), so it inherits the same cost.

The work is quadratic in n, the total entry count — not in how many duplicates are present. A map with 16k entries and zero duplicate keys pays the same cost as one that is all duplicates.

The doc comment justifies the approach with "duplicate map keys are invalid per the protobuf encoding spec and only arise in adversarial or conformance-test wire data, so n is effectively always small." That reasoning holds for how often duplicates occur, but the runtime does not depend on that — only on how many entries the peer sent.

Why it matters

MapView is the borrowed half of a zero-copy decode, so its entry list is sized by untrusted input. Any consumer that needs the canonical last-wins map — what the wire format specifies, and what an owned HashMap decode produces — currently has to go through these helpers or hand-roll the deduplication.

Measured against main (0.9.1, arm64) on MapView<'_, &str, u32>, counting distinct keys and walking them once:

entries len_unique + iter_unique
64 17.95 µs
256 123.41 µs
1024 1.23 ms
4096 18.76 ms
16384 313.53 ms

A single call at 16k entries is ~313 ms. On a decode path reachable from untrusted bytes, that is a denial-of-service primitive rather than a performance footnote. For reference, the same count-and-walk over an index built in one linear pass runs in ~210 µs at that size.

Constraints anything replacing this would have to respect

Noting these because they narrow the options and are easy to miss from outside the codebase:

  • no_std. MapView has to keep working under check-nostd, so a fix cannot reach for std::collections::HashMap directly. hashbrown is already a workspace dependency and __private::HashMap already aliases per-config, so this looks like it needs no new dependency.
  • MSRV 1.75, per rust-version in the workspace manifest.
  • The current bound is K: PartialEq. Any approach based on hashing rather than scanning would need to tighten that, which is a breaking change to the signature and would want a Breaking changes changelog fragment. Adding separate canonical-iteration methods under the stricter bound would avoid that, if spending a break here is not wanted.

How this was found

Adding validator codegen for view types in protovalidate-buffa. map.min_pairs has to count distinct keys, and per-key rules must not fire on entries a canonical decode would have dropped, so a view-side validator needs exactly the canonical map these helpers produce. We build an index inline in the generated validator instead. That works, but it means every consumer that needs canonical map semantics reimplements the same thing, and the helpers that exist for this purpose are the ones that cannot be used.

Measurements are from a standalone harness against a local main checkout, not from benchmarks/ — it exercises a MapView method directly rather than a message shape.

Activity

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

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