Skip to content

perf(rust): remove redundant copies and re-parsing on secondary paths (#125) - #131

Merged
ZhuchkaTriplesix merged 2 commits into
devfrom
issue/125-minor-alloc-cleanup
Sep 28, 2026
Merged

ZhuchkaTriplesix merged 2 commits into
devfrom
issue/125-minor-alloc-cleanup

Conversation

@ZhuchkaTriplesix

Copy link
Copy Markdown
Member

Problem

Several small redundant allocations/re-parses on secondary decode/encode paths, all flagged in #125:

  • get_unverified_header / decode_unverified: token.to_owned() just to move into detach — a &str can be captured directly. get_unverified_header also split the token twice.
  • decode_unverified used dangerous::insecure_decode, which parses the header more than once (into jsonwebtoken's typed Header, even though only claims are read) and re-implements its own lenient segment split.
  • decode_verified_complete: token split again and the signature base64-decoded twice.
  • jws_parse_compact copied the token and returned a signing_input bytes object the only Python caller discards.
  • encode_json: payload_bytes.to_vec() plus two format! allocations.

Fix (each item addressed)

  • get_unverified_header: dropped the token.to_owned() (py.detach only requires Ungil/Send, not 'static — a &str already qualifies) and the redundant ensure_valid_compact_jwt pre-split (parse_compact_header_json already runs the identical split internally).
  • decode_unverified: replaced jsonwebtoken::dangerous::insecure_decode with a new jws::parse_compact_claims_unverified — one strict split (our size cap + segment-count check, unlike insecure_decode's own lenient one that silently misparses extra .s instead of rejecting them), a JSON-object check on the header (matching get_unverified_header's own requirement) with the value discarded, and a payload parse. Same to_owned() removal as above.
  • decode_verified_complete: verify_and_parse is now verify_and_parse_impl with an optional with_signature flag — when set, it decodes the signature segment inline, right where the token is already split for verification, instead of a separate extract_signature_bytes call that re-split the entire token from scratch to reach it. extract_signature_bytes is now dead and removed.
  • jws_parse_compact: no longer computes the unused signing_input; parse_compact_jws's return type drops from a 4-tuple to a 3-tuple. Updated the Python caller and .pyi stub.
  • encode_json (and the RSA fast path it shares with encode via sign_compact_with_cached_rsa): both now build the token through one pre-sized String (signing_input_string, plus appending the signature) instead of a chain of Engine::encode calls into throwaway Strings and two format!s.

Behavior

No behavioral change, with one narrow, previously-untested exception: decode_unverified now accepts a header that's valid JSON but not a recognized alg name (e.g. {"alg": "made-up"}), since it no longer deserializes into jsonwebtoken's typed Header struct. This aligns it with get_unverified_header, which already only required the header to be a JSON object; no other unverified-decode method in this library enforces alg recognition, and unverified decode was never a security boundary. Pinned with a test either way (rejects non-JSON header, accepts unrecognized alg).

Results (release build, HS256, small payload)

before after
get_unverified_header ~384 ns ~352 ns
decode_unverified ~681 ns ~595 ns
encode_json ~787 ns ~666 ns
decode_complete(verify_signature=False) end-to-end ~2580 ns ~2500 ns

The last one is dominated by Python-side claim validation, so the native saving (from the jws_parse_compact change) is a smaller share of the total there — reported honestly rather than only showing the flattering numbers.

Checklist

  • Each item from perf(rust): remove redundant copies and re-parsing on secondary decode/encode paths #125 addressed (or, for the double signature-decode, explicitly reduced rather than fully eliminated, with the constraint explained: jsonwebtoken::crypto::verify has no public entry point accepting pre-decoded signature bytes)
  • cargo build/clippy --all-targets -D warnings/fmt --check/cargo test clean with both aws_lc_rs (default) and --no-default-features --features rust_crypto
  • Full pytest (273 passed, 1 skipped) and mypy clean
  • No behavior change, except the one narrow, documented, tested exception above

Closes #125.

Several secondary decode/encode paths did work more than once for no
reason:

- get_unverified_header copied the token into an owned String before
  py.detach even though py.detach only requires the closure to be
  Ungil (Send), not 'static -- a borrowed &str already satisfies that.
  It also ran its own split_compact_segments pre-check right before
  parse_compact_header_json ran the exact same split internally.

- decode_unverified used jsonwebtoken::dangerous::insecure_decode,
  which fully deserializes the header into jsonwebtoken's typed
  Header struct even though only .claims is ever read, and
  re-implements its own lenient segment split that silently misparses
  a token with extra '.'s instead of rejecting it (unlike our own
  split_compact_segments) -- on top of the same redundant pre-check
  as get_unverified_header. Replaced with a new single-pass
  jws::parse_compact_claims_unverified that reuses the strict split,
  parses the header only far enough to confirm it's a JSON object
  (matching get_unverified_header's own check), then discards it, and
  parses only the payload.

- decode_verified_complete decoded the signature segment's base64 a
  second time via a separate extract_signature_bytes call, which
  re-split the *entire* token from scratch just to reach that one
  segment. verify_and_parse is now verify_and_parse_impl with an
  optional with_signature flag: when set, it decodes the signature
  once, inline, right where the token is already split for
  verification. jsonwebtoken's crypto::verify still does its own
  internal base64 decode of the (small, bounded) signature segment,
  since it has no public entry point that accepts pre-decoded bytes --
  removing the redundant full token re-split was the point, not the
  second signature-segment decode alone. extract_signature_bytes is
  now unused and removed.

- jws_parse_compact (decode_complete's unverified path) computed and
  returned a header.payload "signing input" byte string that its only
  Python caller (api_jwt.py) immediately discarded. It no longer
  computes it at all; parse_compact_jws's return type drops from a
  4-tuple to a 3-tuple (header, payload, signature).

- encode_json (and the RSA fast path it shares with encode via
  sign_compact_with_cached_rsa) built the final token through a chain
  of Engine::encode calls into throwaway Strings plus two format!s,
  each copying everything built so far into a new allocation. Both
  now encode header and payload directly into one pre-sized String
  (signing_input_string) and append the signature to the same buffer.

No behavioural change, except one narrow, previously-untested edge
case: decode_unverified now accepts a header that is valid JSON but
not a recognized alg name (e.g. {"alg": "made-up"}), since it no
longer deserializes into jsonwebtoken's typed Header struct. This
aligns it with get_unverified_header, which already only required the
header to be a JSON object; no other unverified-decode method in this
library enforces alg recognition, and unverified decode was never a
security boundary.

Measured (release build, HS256, small payload): get_unverified_header
~384ns -> ~352ns; decode_unverified ~681ns -> ~595ns; encode_json
~787ns -> ~666ns; decode_complete(verify_signature=False) end-to-end
~2580ns -> ~2500ns (mostly Python-side claim validation, so the native
saving is a smaller share of the total there).

Closes #125
Regression coverage for decode_unverified's move to a single-pass
claims-only parser: the header must still be rejected when it isn't a
JSON object at all, and must now be accepted (matching
get_unverified_header) when it's a well-formed JSON object with an
alg name jsonwebtoken's typed Header struct wouldn't recognize --
pinning the one intentional, narrow behaviour change from the
previous commit.
@ZhuchkaTriplesix
ZhuchkaTriplesix merged commit 771fa2e into dev Sep 28, 2026
7 checks passed
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.

1 participant