Skip to content

Latest commit

 

History

History
1779 lines (1524 loc) · 129 KB

File metadata and controls

1779 lines (1524 loc) · 129 KB

Changelog

All notable changes to buffa will be documented here.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning with the Rust 0.x convention: breaking changes increment the minor version (0.1 → 0.2), additive changes increment the patch version.

Entries for unreleased changes live as fragment files under .changes/unreleased/; run task changelog-new to add one. This file is assembled at release time — do not edit it directly.

0.9.0 - 2026-07-17

This release adds explicit limits on what encoding and decoding may produce and allocate, makes decoding substantially faster, and fixes a range of reflection, JSON, and codegen bugs. There are breaking changes in both the API surface and in code generation — when upgrading, regenerate all code with a version-matched buffa-codegen.

Encode and decode controls

Encoding now enforces the protobuf specification's own 2 GiB ceiling on every entry point, where it previously wrapped silently past 4 GiB and produced a corrupt message. On top of that default, try_encode_bounded takes a budget of your choosing and answers "will this message fit my frame" before writing a byte, in a single size pass rather than two. Together these are the encode twin of DecodeOptions::with_max_message_size, which has always bounded the size of a message coming off the wire.

For decoding, a further memory-amplification attack was reported — #301, a variant of the CVE-2026-55407 advisory but on expected fields rather than unknown ones. It is now mitigated through DecodeOptions::with_element_memory_limit, defaulting to 32 MiB. This will break your existing workloads if you expect and accept large numbers of absent messages within repeated fields. If a child message's struct is ~200 bytes, an absent instance of it inside a repeated field costs 2 bytes on the wire and expands to that ~200-byte allocation on decode — a 100x amplification. Millions of those 2-byte entries fit inside a perfectly reasonable 4 MiB encoded message: 4 MiB of them is 2,097,152 elements and roughly 400 MiB of allocation. with_element_memory_limit bounds the memory such fields may consume, charging each element's own footprint so that a 20-byte struct and a 200-byte struct are each handled on their merits. It complements with_unknown_field_limit rather than subsuming it — the two limits are applied separately, and map entries and view decoding are charged the same way. Adjust either default if it does not meet your needs.

Performance improvements

Multiple changes in this release improve decode performance:

  • Singular message fields are now stored inline in message structs by default (previously, they were boxed).
  • Packed fixed-width payloads decode in one bulk call.
  • Plain-varint payloads hoist the per-element buffer dispatch out of the loop.
  • Cross-crate inlining of generated view field decoding is restored (an unintentional regression).

Together the packed-decode changes cut decode latency by 36% on a 1024-element columnar batch and 16% on shorter packed arrays, primarily through fewer allocation calls and better inlining; restoring view inlining is worth up to 32% on view decoding. Results depend heavily on code layout. Our benchmarks build with lto = true, codegen-units = 1, and -Cllvm-args=-align-all-nofallthru-blocks=6 -Cllvm-args=-align-loops=64 in an attempt to get consistent results, and even then there is a reproducibility floor of roughly ±5%. We have watched a hot loop move ~20% with byte-identical machine code, purely from where it landed relative to a cache line — so keep that in mind for your own benchmarking and production builds.

Breaking changes at a glance

Each of these has fuller migration notes in the section below.

  • WirePayload is now opaque — a struct with private fields and WirePayload::borrowed(..) / ::owned(..) constructors, in place of the 0.8.0 Borrowed(&[u8]) / Owned(Bytes) enum. The reason is that a variant holding exactly the field's bytes cannot see the wire buffer around them, which is what the slack-aware UTF-8 validator needs to take its fast path; the opaque payload carries that tail, so to_str now reaches the validator on the custom-ProtoString decode path — the one string site the 0.8.0 UTF-8 work missed. Code that only calls the accessors is unaffected. Constructing a payload lowercases to ::borrowed(..) / ::owned(..), and matching on the variants moves to the accessors, with is_owned() covering the take-the-Bytes-only-when-free pattern.
  • Singular message fields are inline — codegen emits MessageField<T, ::buffa::Inline<T>>, laid out as Option<T>, so a singular submessage no longer costs a heap allocation per field. Recursive fields are detected and stay boxed automatically. Reading and writing fields through the MessageField API is unchanged, so most code that touches the structs needs no edit at all; what breaks is an explicit MessageField<Foo> type annotation, which still names the boxed form and will now mismatch the field's declared type. Drop the annotation and let the representation infer. Note the tradeoff this makes: an unset inline field costs size_of::<T>() where a boxed one cost a pointer, so for a large submessage that is usually absent, box_type_in(PointerRepr::Box, &[".pkg.Msg.field"]) restores the old behaviour per field.
  • OwnedView::to_owned_message is now infallible. It became fallible in 0.8.0 as a deliberate part of the CVE-2026-55407 fix, which put view-to-owned conversion under the decode-time limit; 0.8.1's accounting fix then made that error unreachable for any wire-decoded view, so the Result is now dead weight. Delete the ? / .unwrap() at call sites whose receiver is an OwnedView or a generated FooOwnedView. Plain view types (FooView::to_owned_message) stay fallible, because hand-written impls and push_raw-built views can still legitimately fail.
  • Encoders take &mut impl EncodeSink instead of &mut impl BufMut, so that a sink can flush segments without copying them. Callers passing Vec<u8>, BytesMut, or any other BufMut are source-compatible through the blanket impl and need no change; a hand-written Message / ViewEncode impl updates its signature, and one that reached for BufMut methods beyond the encoders' own subset (put_u8, put_slice, the little-endian fixed-width writers) must assemble into a concrete buffer first.
  • The size helpers take u64types::put_len_delimited_header, and map_codec::field_len / message_field_len. This is what makes the 2 GiB ceiling enforceable: sizes have to accumulate in a type that cannot wrap before anything can check them against a limit. Bare integer literals still infer, and a u32 variable widens with u64::from(..), so hand-written call sites are usually a small edit or none. Checked-in generated code will not compile until it is regenerated — code from earlier buffa-codegen passes a u32 into put_len_delimited_header and accumulates field_len into a u32. An external ExtensionCodec impl also swaps its required method to the fallible try_encode / try_encode_one.

MSRV remains 1.75.

Breaking changes

  • WirePayload is now an opaque struct. The 0.8.0 public-variant enum (Borrowed(&[u8]) / Owned(Bytes)) is replaced by a struct with private fields, the same accessors (as_slice, to_str, into_bytes, plus new len / is_empty / is_owned), and WirePayload::borrowed(&[u8]) / WirePayload::owned(Bytes) constructors. ProtoString / ProtoBytes from_wire implementations that use the accessors are unaffected; code that constructed WirePayload::Borrowed(..) / ::Owned(..) migrates by lowercasing to ::borrowed(..) / ::owned(..); code that matched on the variants moves to the accessors — is_owned() covers the take-the-Bytes-only-when-free pattern. The reshape lets a borrowed payload carry the surrounding wire-buffer tail, so to_str now reaches the slack-aware UTF-8 validator on the custom-ProtoString decode path (it was the one string decode site #241 didn't cover).

  • Singular message fields are now stored inline by default: codegen emits MessageField<T, ::buffa::Inline<T>> (laid out as Option<T>, no per-field heap allocation) for every non-recursive field. Recursive fields are detected and stay on Box automatically. (#248)

To restore the old behaviour for specific fields (e.g. large or rarely-set submessages), use box_type_in(PointerRepr::Box, &[".pkg.Msg.field"]); for the old global default, box_type(PointerRepr::Box). Explicit MessageField<Foo> type annotations now mean the boxed form and will mismatch the new default — drop the annotation and let P infer from the field's declared type (or, for a standalone value with no pinning context, write MessageField::<Foo, buffa::Inline<Foo>>::some(x)).

box_type_in / box_type_custom_in now normalize a missing leading dot on each path; previously a dotless path silently matched nothing.

  • OwnedView::to_owned_message and the generated FooOwnedView::to_owned_message are now infallible, returning the owned message directly instead of Result<_, DecodeError>. Every OwnedView constructor wire-decodes its view (or, for unsafe from_parts, requires wire-decode provenance as part of its strengthened safety contract), and since 0.8.1 a view produced by wire decoding always converts — so the Result only ever encoded an unreachable error path. Migration: on call sites whose receiver is an OwnedView or a generated FooOwnedView handle, delete the ? / .unwrap() / .expect(...) — or otherwise unwrap the previously-returned Result (a match or .map_err(...) needs the same treatment). Call sites on plain view types (FooView::to_owned_message via the MessageView trait) are unchanged and stay fallible, since hand-written impls and push_raw-built views can still legitimately fail. unsafe from_parts callers: the safety contract now requires that the view was produced by wire-decoding the buffer, not merely that its borrows point into it — a hand-assembled view that was legal under the old wording still compiles but now panics at to_owned_message, so audit from_parts call sites for provenance. A contract violation by a buggy hand-written MessageView impl wrapped in OwnedView likewise panics with a descriptive message instead of surfacing an error that correct code could never observe. (#268)

  • The u64 size-arithmetic discipline changes three public signatures: buffa::types::put_len_delimited_header takes len: u64 (was u32), and buffa::map_codec::field_len / message_field_len take and return u64 (MapCodec::encoded_len and FIXED_LEN likewise — that trait is sealed, so only the signatures are visible). Bare integer literals still infer; a u32 variable widens with u64::from(...). Checked-in generated code must be regenerated: code emitted by earlier buffa-codegen versions passes __cache.consume_next() (a u32) to put_len_delimited_header and adds field_len results into a u32 accumulator, so it fails to compile against this runtime. Regenerate with your build pipeline (or buffa-build) after updating. ExtensionCodec and extension::codecs::SingularCodec swap their required encode method: try_encode / try_encode_one (fallible) are now required, and the panicking encode / encode_one are provided wrappers — so a codec whose encode can fail cannot accidentally leave the fallible try_set_extension path panicking. All in-tree codecs are updated; an external codec impl (if any exist) renames its method and wraps the result in Ok. Runtime behavior also changes: the existing encode entry points now panic on messages whose encoded size exceeds the 2 GiB protobuf limit — see the Fixed entry for the full list and the try_encode* escape hatch.

  • Message::write_to/encode (and ViewEncode, the types::put_*/encode_* helpers, and generated code) now take &mut impl EncodeSink instead of &mut impl BufMut. Callers passing Vec<u8>, BytesMut, or any other BufMut are source-compatible via the blanket impl; manual Message/ViewEncode implementations must update their method signatures, and generated code must be regenerated with the matching codegen version. Note that EncodeSink deliberately exposes only the BufMut subset the encoders use (put_u8, put_slice, and the little-endian fixed-width writers) — a manual write_to that used other BufMut methods must assemble into a concrete buffer first. Generated write_to bodies now emit put_shared_bytes_field for bytes fields — copy-equivalent for Vec<u8>, segment-aware for bytes::Bytes.

Added

  • DescriptorPool now implements Clone. (#253)

  • ReflectMessageMut::try_set / try_clear: checked mutation variants that return ReflectError::FieldNotMember instead of mutating when handed a FieldDescriptor from a different message or pool. (#254)

  • idiomatic_field_names codegen option (buffa_build::Config, CodeGenConfig, protoc plugin): opt-in conversion of camelCase proto field and oneof names to snake_case Rust identifiers, prost-compatible on underscore-free names; wire, JSON, and text-format names are unchanged, and collisions resolve deterministically with an _f<number> suffix and a build warning. Generated structs with non-snake member names now carry a detection-scoped #[allow(non_snake_case)] regardless of the option. (#260)

  • jiff feature for buffa-types (#264). Adds conversions between the well-known types and jiff: Timestampjiff::Timestamp and Durationjiff::SignedDuration. Gated behind the new jiff Cargo feature and no_std-compatible (jiff is pulled with default-features = false + alloc). Note for chrono users switching over: the Durationjiff::SignedDuration conversion has no Overflow error mode (SignedDuration stores full i64 seconds, so a well-formed proto Duration can never overflow it) — code that relied on DurationChronoError::Overflow to reject huge durations must check the proto JSON spec bound (±315,576,000,000 s) itself, or let JSON serialization enforce it.

  • exclude_package protoc plugin option (protoc-gen-buffa and protoc-gen-buffa-packaging): drop a proto package and its subpackages from generation and from the emitted mod.rs. Repeatable; the leading dot is optional. Intended for option-only imports that include_imports pulls into file_to_generate but that are never referenced as message fields (e.g. buf.validate, gnostic). Both plugins route exclusion through the new buffa_codegen::package_is_excluded predicate, so their output sets stay in sync. (#279)

  • MessageField<T, P> now has a consuming map combinator, equivalent to into_option().map(f), for Option-style transformation of optional message fields. (#280, #282)

  • Vectored ("rope") encode: the new EncodeSink trait abstracts the encode output, and the Rope sink captures large bytes::Bytes fields (and, via Rope::with_backing, large borrowed view fields) as reference-counted segments instead of copying them — encoding a message dominated by one large payload costs O(header) instead of O(payload). Contiguous callers are unaffected: every BufMut is an EncodeSink through a blanket impl. ProtoBytes gains a provided as_shared method so custom Bytes-backed representations can opt in, and RopeBuf adapts a finished rope to bytes::Buf with vectored-I/O support.

  • Package-root FILE_DESCRIPTOR_SET_BYTES re-export (#278). The embedded descriptor-set bytes were only reachable through the reserved __buffa sentinel module; generated packages now re-export the constant at the package root alongside descriptor_pool(), making pkg::FILE_DESCRIPTOR_SET_BYTES the supported access path.

  • buffa-remote-derive: derive macros for newtypes over remote types (#212, #251). New proc-macro crate with ProtoString, ProtoBytes, ProtoList, ProtoBox, and MapStorage derives that generate the buffa owned-type trait impls (plus Deref/AsRef/From conversions) for a single-field newtype wrapping a foreign type, mirroring serde's remote attribute pattern. Covers the binary codec only; serde/Arbitrary/reflection forwarders remain hand-written. Contributed by @rsd-darshan.

  • Path-scoped editions feature overrides via override_feature_in(path, FeatureOverride) — descriptor feature injection for integrators working with protos they cannot modify, applied as if the proto had been migrated to editions with that feature set at the matched paths. The supported override set is the FeatureOverride allowlist enum; the first entry is enum_type:OPEN, with open_enums_in(&[...]) as shorthand: selected closed enums (or individual closed enum fields) generate as EnumValue<E>, making unknown wire values directly visible as EnumValue::Unknown(n) for prost-parity migrations or memory-optimized builds with preserve_unknown_fields(false). Enum-type rules flow into the embedded reflection descriptor pool, so runtime reflection and dynamic JSON agree with the generated types. Rules that match nothing produce a generation-time warning. Default-off; existing output and semantics are unchanged. (#269)

  • buffa-remote-derive: optional as_shared override on derive(ProtoBytes) (#294). #[buffa(remote = ..., as_shared = path)] generates the encode-side ProtoBytes::as_shared hook, letting a remote bytes newtype that stores a bytes::Bytes splice into segmented (Rope) sinks by reference count instead of being copied. Without the key the trait default (None, copy) still applies.

  • DecodeOptions::with_element_memory_limit bounds the memory a decode materializes in the elements of length-delimited containers — repeated message, string and bytes fields, and map entries — defaulting to 32 MiB (DEFAULT_ELEMENT_MEMORY_LIMIT) where it was previously unbounded (#301). These elements cost far more decoded than encoded, which no existing option bounded: an empty repeated message element is 2 bytes on the wire and size_of::<T>() in the Vec it lands in — measured at 256 bytes for a message of a few Vec/String fields, a 128x ratio, so 4 MiB of them forced ~512 MiB. with_max_message_size cannot help, because it bounds the bytes going in rather than what they expand into. Empty bytes and string elements amplify 16x and 12x by the same route, and a map<string, Message> entry amplifies like a repeated message: an omitted value still materializes, and a few bytes of key buy a distinct slot. The budget is charged by element footprint and shared across the whole decode tree, so a limit means the same amount of memory whatever the element size, and nesting cannot multiply the ceiling. The owned, view and reflective (DynamicMessage) decoders are all bounded. Packed scalar fields are never charged: their worst case is a 1-byte varint becoming a 4-byte i32, and bounding them would reject columnar payloads that carry millions of elements by design. Lazy views are not charged either — they borrow byte slices at decode and materialize on access. This can reject input that previously decoded. A decode materializing more than 32 MiB of such elements now fails with DecodeError::ElementMemoryLimitExceeded; raise the budget with with_element_memory_limit for trusted input that legitimately decodes into more. Note Vec grows by doubling, so peak resident memory can reach roughly twice the budget — the budget bounds what is materialized, not what the allocator reserves.

  • Budget-checked encode entry points (try_encode_bounded, try_encode_view_bounded).

Message::try_encode_bounded(max_bytes, buf) and ViewEncode::try_encode_bounded perform a single size pass (populating the SizeCache) and reject the encode before write_to runs if the encoded size exceeds the caller-supplied budget, so nothing reaches buf on Err — including bytes a caller had already framed into it. SizeCachePool::try_encode_bounded and try_encode_view_bounded are the pooled equivalents, and both traits also carry a *_with_cache form for callers that manage their own SizeCache. All of them return the encoded body length on Ok, saving a second try_encoded_len call when the length is needed for metrics or framing.

A new EncodeError::ExceedsBudget { len, max_bytes } variant carries the exact encoded size and the budget that was exceeded. EncodeError::MessageTooLarge takes precedence when the message also exceeds the 2 GiB protobuf limit.

Changed

  • smoothutf8 bumped to 0.2.3. The decode hot path picks up the 0.2.1–0.2.3 performance work: verify_with_slack ASCII improves 38–64% at 1–32 bytes (inline-partition fix) and 28–39% at 32–512 bytes on aarch64 (NEON ascii_skip), and the safe verify tail rewrite improves 1–4-byte inputs up to 5.9×. wasm32 targets now run the portable shift-DFA validator instead of delegating to core::str::from_utf8. buffa's call site moves from the deprecated to_str alias to its new name from_utf8; behavior is unchanged.

  • Documented the struct evolution policy for generated types: exhaustive struct literals and destructuring of generated message/view structs are not covered by semver. See the Message trait docs. (#202, #244)

  • Tag::new is now #[inline], allowing the per-field-write call in generated write_to code to inline in optimized builds without LTO (cargo's default release profile). Measured up to ~13% fewer encode instructions on encode-heavy workloads. No API change. (#257)

  • Packed varint-family repeated fields (int32/int64/uint32/uint64/sint32/sint64/bool, and open enums) now decode each element through new force-inlined buffa::types::decode_*_packed helpers, removing the per-element out-of-line call from the generated packed loops. Measured on a quieted bare-metal A/B at the layout-normalized profile: −16–30% on packed-varint-dense decode/merge and −5–19% on decode_view across every benchmark shape (via the resulting decode_varint code-placement shift), at the cost of ~+2–4% on one dense-small-message synthetic (google_message1 owned decode/merge). Pair regenerated code with a buffa runtime that has the decode_*_packed helpers — code generated by this buffa-codegen does not compile against older runtimes, so a stale lockfile resolving an older caret version fails with cannot find function decode_*_packed; cargo update -p buffa fixes it. The plain decode_* helpers are unchanged.

  • source_code_info is stripped from the embedded FileDescriptorSet (#278). Codegen consumes source info for doc comments, but the runtime DescriptorPool never reads it, so embedding it only cost binary size — the well-known-types package shrank from 34,939 to 2,428 embedded bytes (-93%). Classified as non-breaking because the bytes were previously only reachable through the reserved __buffa sentinel module — but note the content change is silent: code that reached in anyway and forwarded the bytes to something that reads proto comments will see them disappear without a compile error. Consumers that need proto comments at runtime should build a descriptor set directly with protoc --include_source_info or buf build.

  • Docs: document the idiomatic .into() conversions for MessageField and EnumValue, and explain why message types are generated from .proto rather than derived. (#249)

  • Packed fixed-width payloads (fixed32/sfixed32/float, fixed64/sfixed64/double) now decode in one bulk call (buffa::types::extend_packed_*): the payload length is validated against the element width up front, the exact element count is reserved, and elements convert via chunks_exact + from_le_bytes, which optimizes to a bulk copy on little-endian targets. Applies to view decode and to owned decode into the default Vec when the payload is contiguous in the current chunk; fragmented buffers and custom list representations keep the per-element loop. Behavior note: on those bulk paths a misaligned (truncated) packed payload now fails without partially extending the field — previously the leading complete elements were pushed before the error; the error itself is unchanged (DecodeError::UnexpectedEof), and the per-element fallback paths keep the old partial-extension behavior.

  • Packed plain-varint payloads (int32/int64/uint32/uint64/sint32/sint64/bool) now decode through slice-specialized bulk extenders (buffa::types::extend_packed_*): one pre-allocation reserve up front (each decode path keeps the policy it had with the per-element loop: views reserve the exact count from count_varints, owned decode reserves the payload byte length as an upper bound), then an element loop with the per-element buffer dispatch hoisted out — 1-2-byte varints (the common case for ids, tokens, and small values) decode inline with no per-element chunk or remaining checks, longer elements fall through to the unrolled slice decoder. Applies to view decode and to owned decode into the default Vec when the payload is contiguous; fragmented buffers, custom list representations, and enums keep the per-element loop. A payload whose final byte has its continuation bit set (malformed trailing element) falls back to the per-element decoder, so accept/reject behavior and partial-extension semantics on malformed payloads are unchanged.

  • DescriptorPool now rejects descriptor sets with ambiguous field identities instead of building a pool whose lookups are undefined: two fields in a message sharing a number or a proto name, and a oneof_index that names no oneof in the containing message. The last of these also fixes a real bug — an out-of-u16 index was silently dropped, leaving the field with oneof presence but no oneof membership. Fields resolving to the same JSON name are rejected only where protobuf treats JSON as fully supported (json_format = ALLOW, i.e. proto3 and editions) and the message has not set deprecated_legacy_json_field_conflicts, so every descriptor set protoc emits still loads. protoc rejects the rest at compile time. (#300)

  • DescriptorPool now registers messages, enums, services, methods, extensions, and enum values in one symbol table, so a descriptor set that declares the same fully-qualified name twice is rejected at construction rather than building a pool where one declaration shadows the other. This catches collisions the pool previously accepted — a service and a message sharing a name, a duplicate RPC method, a duplicate enum value name — all of which protoc rejects at compile time. Numeric enum aliases under allow_alias are unaffected. (#302)

  • View decoding is laid out for payloads that match the schema: the routes that preserve an unrecognized field or enum value are marked #[cold]. Behaviour is unchanged. (#276)

Fixed

  • DescriptorPool::add_file_descriptor_set is now transactional: a failed add (e.g. an unresolvable type name) leaves the pool unchanged instead of retaining placeholder descriptors and name entries that made a corrected retry fail with DuplicateName. (#253)

  • DynamicMessage::set / clear now validate field-descriptor membership in release builds and panic on a foreign descriptor, instead of silently writing to whatever field shares its number. Previously the check was a debug_assert! only. (#254)

  • JSON: quoted integer strings in decimal/exponent notation (e.g. "9007199254740993.0", "9.007199254740993e15") now parse exactly instead of routing through f64, which silently rounded magnitudes above 2^53. Non-integral and overflowing strings are still rejected. (#255)

  • Encode now enforces the protobuf 2 GiB message-size limit. Previously encoding was infallible: a message whose encoded size crossed 2^31-1 bytes serialized silently into a blob that no conforming decoder — including buffa's own (DecodeError::MessageTooLarge) — would read back, and sizes past 4 GiB wrapped u32 arithmetic into corrupt output. Generated compute_size now accumulates in u64 and saturates at each node's return (buffa::saturate_size), and every provided encode entry point on Message, ViewEncode, generated lazy views, and DynamicMessage checks the total against the new buffa::MAX_MESSAGE_BYTES constant. Behavior change: the existing entry points (encode, encode_to_vec, encode_to_bytes, encode_length_delimited, encoded_len, encode_with_cache) now panic on over-limit messages (previously they returned decoder-rejected or corrupt bytes); new try_encode, try_encode_with_cache, try_encoded_len, try_encode_length_delimited, try_encode_to_vec, and try_encode_to_bytes twins return Err(EncodeError::MessageTooLarge) instead — EncodeError gains its first variant. Fallible variants also cover the eager re-encode paths: ExtensionSet::try_set_extension and Any::try_pack (message-typed extension values and Any::pack encode their payload on the spot, so the panicking originals document the new panic and these twins return the error instead). In debug builds SizeCache::consume_next additionally rejects over-limit slots as a backstop for callers driving compute_size/write_to directly.

  • DynamicMessage::try_set now rejects Values whose runtime shape does not match the target field descriptor — repeated elements, map keys and values, and nested messages of the wrong type included — and set consequently panics on shapes it previously accepted and encoded as corrupt tag-only wire output. Invalid values planted through direct mutable field access are omitted during encode (whole field, with encoded_len in agreement) instead of corrupting the wire. (#272)

  • 64-bit integer JSON helpers now reject unquoted decimal/exponent numbers at or above 2^52 in magnitude. serde_json's default float parsing is not correctly rounded, and from 2^52 a one-ulp parse error is a whole integer — an unquoted integer token there could silently decode to the adjacent i64/u64. Below 2^52 the same error breaks integrality and is rejected loudly by the existing exactness check. Quote large integer values to parse them exactly. (#274)

  • buffa-build Protoc mode now emits cargo:rerun-if-changed for imported .proto files from the generated descriptor set when they resolve under configured include roots, so editing transitive imports reruns codegen without requiring a clean build. (#275)

  • Editions LEGACY_REQUIRED fields with an explicit default = ... now honor the declared default consistently in Default::default(), clear(), and reflection presence checks. Previously clear() reset such fields to the type default instead of the declared default.

  • Reflective set adopts message values from another descriptor pool (#297). A generated type reflects against its defining crate's DescriptorPool, so a nested well-known-type (or any cross-package) message reached through the ReflectMessage vtable could never be pointer-identical to the referencing pool. DynamicMessage::try_set rejected such values with a self-contradictory error (expects message google.protobuf.Duration, got message google.protobuf.Duration), which made the rebuild walk for_each_set + set(fd, vr.to_owned()) panic for any message with a WKT field. Same-typed values from a foreign pool are now re-homed into the target pool by a wire round-trip; a genuinely wrong type is still rejected. Callers that relied on the previous cross-pool rejection as a provenance check must compare ReflectMessage::pool themselves. Adds MapValue::into_entries.

  • Code fences and indented code blocks in proto comments are no longer compiled as doctests in the consuming crate, so a comment carrying a non-Rust snippet no longer breaks cargo test downstream. An unannotated fence becomes text and any other keeps its language and gains ignore, so a rust fence is still syntax-highlighted. Tilde fences are handled like backtick fences, and a fence the author left open is closed at the end of the comment instead of swallowing the doc text after it. (#307)

  • Restore cross-crate inlining for generated view field decoding (#298). Generated merge_view_field methods now carry an inline hint, allowing the optimizer to avoid a per-field call on zero-copy view workloads.

  • Owned decoding now counts preserved unknown closed-enum values against the configured unknown-field limit, matching view decoding and preventing packed values from bypassing the allocation guard.

  • The reflective decoder (DynamicMessage) no longer drops an existing singular message field when a later wire occurrence of that field fails to decode. A truncated length varint or an over-long declared length left the field cleared, silently discarding data decoded from earlier occurrences; the partially-merged value is now retained, matching the owned decoder's merge semantics. (#299)

  • The reflective decoder (DynamicMessage) no longer clears a oneof's active member when the replacement on the wire fails to decode. A malformed varint, a truncated nested message, a mismatched group terminator, or invalid UTF-8 left the oneof empty, discarding the member that had decoded successfully; siblings are now cleared only once the replacement decodes. (#303)

  • The reflective decoder (DynamicMessage) now routes unknown values of a closed enum to unknown fields instead of materializing them as Value::EnumNumber, matching the generated decoder and the proto2/editions spec. Singular, oneof, extension, unpacked repeated, packed repeated, and map-value contexts are all covered, and each preserved value is charged against the decoder's unknown-field allowance on the same terms as the generated path — once per value, or once per entry for maps. Open enums are unaffected. (#304)

0.8.1 - 2026-07-01

A single-fix patch release: unknown-field limit accounting for zero-copy views now matches owned conversion exactly, establishing the guarantee that a view which decodes successfully always converts to an owned message. No API changes, and regenerating code is not required — the fix lives in the runtime.

Fixed

  • Zero-copy view decoding now charges the unknown-field limit per re-materializable field — one per unknown record plus, for unknown group records, one per nested field — instead of one per coalesced span, and conversion replays under exactly the field budget and group-nesting depth recorded at decode time (previously a fixed recursion limit, which could reject deep unknown groups decoded under a raised with_recursion_limit). Decode-time accounting now matches what to_owned_message re-materializes, which gives views a guarantee: a view produced by decode_view always converts via to_owned_message without error (the Result remains for hand-written impls and push_raw-built views). Behavioral tightening: payloads whose unknown-field count exceeds the limit but previously slipped through view decode via span coalescing — e.g. a ~2 MiB run of >1M contiguous 2-byte unknown records under the default limit, or an unknown group with more nested fields than the limit — now fail at view decode with UnknownFieldLimitExceeded. Consumers that converted such views already got this error at conversion; view-only consumers that re-encoded such payloads without converting (e.g. a zero-copy passthrough proxy) now see it at decode — raise the bound with DecodeOptions::with_unknown_field_limit if such payloads are trusted and expected. The accounting lives in the runtime (UnknownFieldsView::push_record), so previously generated code is fixed without regeneration. (#266)

0.8.0 - 2026-06-25

The headline of this release is that the owned representation of every field kind is now pluggable end to end: string, bytes, repeated, singular message, and map fields each accept a crate-local type via a small from_wire-style trait (ProtoString, ProtoBytes, ProtoList, ProtoBox, MapStorage), so an inline-string or small-vector representation can avoid the per-field heap allocation without giving up the generated codec. Alongside that, UTF-8 validation on the decode path now defaults to smoothutf8 with the slack-buffer fast path — view decode is +15–22% on the string-heavy benchmark messages — and an opt-in FooLazyView family lets a caller decode a few fields of a large message without recursing into untouched sub-trees. There are eight breaking changes, all on the trait surface or generated-code shape; regenerate code with the matching buffa-codegen, then the convenience entry points (decode, decode_from_slice, merge_from_slice, DecodeOptions) are unchanged.

Added

  • WirePayload::to_str (#241) — borrow the payload as a &str if valid UTF-8, using buffa's UTF-8 validator (so it picks up fast-utf8). Convenience for ProtoString::from_wire implementations; buffa-smolstr now uses it.

  • HasMessageView::decode_view / decode_view_with_options (#240) — defaulted methods so generic code bounded on M: HasMessageView can write M::decode_view(buf) instead of the associated-type path <M as HasMessageView>::View::decode_view(buf). Additive; the existing MessageView::decode_view is unchanged.

  • MessageField::unwrap / expect and From<MessageField<T, P>> for Option<T> (#240) — consume a MessageField directly (field.unwrap(), field.expect("…"), field.into()) without the .into_option().unwrap() round-trip. Both are #[track_caller] and panic on an unset field; prefer ok_or / ok_or_else for fallible contexts. Additive.

  • buffa::SizeCachePool — opt-in reuse of the encode size-cache spill allocation (#225). Every encode / encoded_len builds a fresh SizeCache; its inline storage is free, but a message with more than the inline capacity of nested length-delimited sub-messages (deeply nested, repeated-sub-message shapes) spills to a heap Vec on every encode. SizeCachePool is a caller-owned free-list of those spill buffers — keep one in a thread_local! or a request/connection context and call pool.encode, pool.encode_view, or pool.encoded_len to reuse one allocation across many encodes. buffa holds no global state; only the spill Vec is pooled (each cache's inline array stays on the stack), so routing small messages through a pool costs only a Vec pop/push of an empty buffer — no allocation, no thread-local, no synchronization — and the pool is alloc-only (no_std-OK). Bounded by max_buffers (free-list length) and max_capacity (per-buffer capacity, shrunk on return). Also adds SizeCache::with_spill_buffer / into_spill_buffer to source/sink the spill buffer for manual reuse. Additive and non-breaking; the default encode path is unchanged.

  • Custom owned string types for map keys and values (#222). A string_type rule (string_type_custom / string_type_custom_in) now also applies to a map<string, V> key and a map<K, string> value — one rule on the map field path covers both slots of a map<string, string> — mirroring how bytes_type already reaches map<K, bytes> values. The element decodes/encodes through the new sealed buffa::map_codec::ProtoStringMap<S> codec; no new build knob. The wire format is unchanged and view types still borrow &str. Requirements on the custom type when used in a map: Hash + Eq (or Ord for map_type(BTreeMap)) for a key; serde::Serialize / Deserialize for JSON; and — because the map paths have no per-key generic shim — a crate-local newtype (vtable reflection emits ReflectMapKey / ReflectElement for it) and its own Arbitrary impl under generate_arbitrary. Custom-string-keyed maps whose value needs proto3-JSON encoding (int64/float/bytes) serialize through a new proto_str_key_map with-module (the existing proto_map requires Display + FromStr, which a ProtoString need not implement).

  • Pluggable owned map container for map<K, V> fields (#210). A new buffa::MapStorage trait (with associated Key / Value types) selects the owned map collection, via buffa_build's map_type / map_type_custom knobs. The default stays HashMap; BTreeMap is a zero-dependency built-in giving deterministic (reproducible) encoded bytes, and a crate-local newtype can wrap any other map (e.g. IndexMap). JSON and arbitrary work for every proto map key/value type regardless of the container — the proto-JSON with-modules and the arbitrary shim are generic over MapStorage. The wire format is unchanged; only the in-memory collection changes, and view types are unaffected.

  • Pluggable owned string / bytes types via from_wire (#206). A new buffa::ProtoString / buffa::ProtoBytes trait selects the owned representation of string / bytes fields, via buffa_build's string_type_custom / bytes_type_custom knobs. Each trait has one method, from_wire(WirePayload<'_>) -> Result<Self, DecodeError>, so a representation decides validation and borrow-vs-own itself — an inline-capable type avoids the transient String / Vec<u8> allocation a From<String> decode path would force. The built-in String and Vec<u8> are the default implementations; buffa-smolstr is the worked newtype example for a foreign type. Breaking removal: the curated string_type presets (the named-crate enum that previously selected compact_str / ecow / smol_str) are dropped — use string_type_custom with a crate-local newtype instead. The wire format is unchanged.

  • Pluggable owned collection for repeated fields (#208). A new buffa::ProtoList<T> trait (Deref<Target = [T]> + FromIterator<T> + From<Vec<T>> + Default { push, reserve }) selects the owned collection for repeated fields, via buffa_build's repeated_type / repeated_type_custom knobs. The default stays Vec<T> and generated output is byte-identical; the custom path takes a *-templated type (e.g. "::my_crate::SmallVecRepeated<*>"). The collection must be growable — the decoder appends one element per wire element with no capacity check, so a fixed-capacity collection would panic on oversized input rather than return a decode error. View types are unaffected.

  • Pluggable owned pointer for message fields (#209). A new buffa::ProtoBox<T> trait (Deref<Target=T> + DerefMut { new, into_inner }) selects the smart pointer that a singular message field's MessageField wraps — and the pointer of a boxed oneof message/group variant — via buffa_build's box_type / box_type_custom knobs (the custom path takes a *-templated type, e.g. "::my_crate::SmallBox<*>"). A oneof variant opted into inline storage via unbox_oneof takes precedence and gets no pointer; recursive variants stay pointered and so accept a custom pointer. The default stays Box<T> and generated output is byte-identical. Only exclusively-owned pointers qualify (Rc/Arc are excluded — the decoder merges in place via DerefMut); inline pointers like SmallBox avoid the per-field heap allocation. Source-breaking note: MessageField<T> gained a defaulted pointer type parameter (MessageField<T, P = Box<T>>), so a standalone MessageField::some(x) / none() with no pinning context now needs a type annotation (MessageField::<T>::some(x)); struct-literal and typed-assignment construction are unaffected. Added MessageField::from_pointer (the generic counterpart to the Box-only from_box).

  • idiomatic_imports option (#189). buffa_build::Config::idiomatic_imports(true) (also CodeGenConfig and protoc-gen-buffa's idiomatic_imports=true) emits a use-backed short-name re-export at the package root under file_per_package layout, so a generated type is reachable as pkg::Foo instead of pkg::foo_module::Foo. Off by default; the file layout and the wire format are unchanged.

  • #[diagnostic::on_unimplemented] hints on the custom-type traits (#229). ProtoString, ProtoBytes, ProtoList, ProtoBox, and MapStorage now carry diagnostic hints so a *_type_custom knob pointed at a foreign type produces a "wrap it in a crate-local newtype" message instead of the raw orphan-rule / unimplemented-trait error. Gated behind rustversion::attr(since(1.78), …) for the MSRV.

  • examples/custom-types — end-to-end pluggable owned types (#234). A runnable example crate that compiles a .proto with every owned-type knob (string_type_custom, bytes_type_custom, repeated_type_custom, map_type_custom, box_type_custom) pointed at crate-local newtypes wrapping flexstr, smallvec, indexmap, and smallbox, then round-trips a record through binary and JSON. The newtypes are the copy-paste template for bridging a foreign storage type past the orphan rule.

  • Docker-free conformance runs (#192). task conformance-tools-local builds conformance_test_runner from the pinned protobuf tag into .local/bin/ and task conformance-local executes the same seven runs as the Docker path with the same failure lists — for dev environments without a Docker daemon or GHCR access.

  • Opt-in lazy views: the additive FooLazyView family (#188). With Config::lazy_views(true) (plugin: lazy_views=true), each message additionally generates a FooLazyView<'a> implementing the new buffa::LazyMessageView trait — the eager FooView family is unchanged and output is byte-identical with or without the flag. decode_lazy performs one non-recursive scan, recording singular/repeated message fields as undecoded byte ranges (LazyMessageFieldView / LazyRepeatedView) that decode on access via fallible by-value accessors (.get(), .get_or_default(), iteration), so reading a few fields of many large sub-messages no longer allocates or recurses into untouched sub-trees (~12× less allocation churn on the issue's workload; ~200× faster when only 1% of items are read). Proto merge semantics are preserved via per-occurrence fragments merged on access; the recursion depth and unknown-field allowance recorded at each deferred field are replayed per access (per-subtree capture of the shared pool), so DecodeOptions limits flow through decode_lazy_view. Conversions are fallible (to_owned_message() -> Result), the lazy Serialize impl surfaces deferred errors as serde errors, and re-encoding replays recorded fragments verbatim without validating them. Groups, oneof message variants, map message values, and extern-typed fields (WKTs, extern_path) stay eager inside the lazy view; the lazy family has no reflection/OwnedView/text surface. A dedicated BUFFA_VIA_LAZY conformance runner mode covers the lazy decoder against the full corpus.

  • Customizable feature-gate names (#183). CodeGenConfig::feature_gate_names (exposed as buffa_build::Config::{json,views,text,reflect}_feature_name and protoc-gen-buffa's {json,views,text,reflect}_feature= options) renames the crate features that gate_impls_on_crate_features conditions the generated impls on — e.g. gating the serde JSON impls behind a feature named serde instead of json. Defaults are unchanged; the knob is inert unless gating is enabled. A name that is not a valid Cargo feature name fails generation with an error when its gate is active — the alternative is a permanently-false #[cfg] that silently compiles the gated impls away.

  • buffa-build / buffa-codegen: oneof_attribute (#167) — attach Rust attributes to generated oneof enums only (not message structs, not regular enums), matched against the oneof's fully-qualified path (.pkg.Message.oneof_name) with the same prefix rules as type_attribute. Completes the type / message / enum / field attribute family for the case where a oneof needs a different attribute set than the surrounding types.

  • Unknown-field decode limit bounds decoder memory amplification (#184). Unknown wire data can occupy ~20× more memory decoded than encoded: every 2-byte unknown varint field materializes a ~40-byte UnknownField, so a 64 MiB payload of minimal unknown fields (flat or nested in a group) could force over 1 GiB of heap — not bounded by with_max_message_size, which only caps input length. Decoding now counts every materialized unknown field against a limit shared across the whole decode call and fails with the new DecodeError::UnknownFieldLimitExceeded when it is exceeded. The default is 1,000,000 fields per decode (DEFAULT_UNKNOWN_FIELD_LIMIT), capping slot overhead at ~40 MB, and applies to all decode entry points including the trait-level convenience methods; tune it with DecodeOptions::with_unknown_field_limit. Unknown length-delimited payload bytes are not counted against the limit — the decoder only allocates them once the sender has actually delivered the bytes, so they are bounded by the input size and governed by with_max_message_size. The limit covers owned-message and DynamicMessage decoding; zero-copy views store unknown fields as borrowed spans and are not affected by the amplification.

  • chrono interop for buffa-types (#163). A new off-by-default, no_std-compatible chrono feature adds conversions between the well-known Timestamp / Duration types and chrono::DateTime / chrono::TimeDelta: From<chrono::DateTime<Tz>> for Timestamp (any time zone; the instant is preserved), TryFrom<Timestamp> for DateTime<Utc>, From<TimeDelta> for Duration, and TryFrom<Duration> for TimeDelta. The last returns a new DurationChronoError because TimeDelta's range (±i64::MAX milliseconds) is narrower than proto Duration's. Contributed by @yordis.

  • New buffa-yaml crate: YAML serialization with protobuf-JSON semantics (Phase 1 of protoyaml support, #155). A thin carrier layer that routes buffa's generated protobuf-JSON serde impls through serde_norway, so YAML I/O gets the full protobuf JSON mapping: camelCase/snake_case field names, quoted int64/uint64, base64 bytes, enum string names, and canonical well-known-type encodings. Public API: to_string, to_writer, from_str, from_slice, from_reader, plus to_string_view / to_writer_view for zero-copy views, and an Error type exposing a carrier-agnostic Location { line, column }. Requires message types generated with json = true. Contributed by @rsd-darshan.

  • Proto2 required-field presence on views (#200). Generated view types (FooView and FooLazyView) for messages with proto2/editions LEGACY_REQUIRED singular fields now expose has_<field>() accessors that distinguish a field absent on the wire from one explicitly encoded with its default value. Scalar required fields are tracked via hidden __buffa_required_seen_* bit words; message/group required fields delegate to MessageFieldView::is_set() / LazyMessageFieldView::is_set(). The view ReflectMessage::has() implementation consults the same tracking, so reflection agrees with the inherent accessors. Owned messages are unchanged: they store required fields bare and their reflection still reports has() == false for a required field at its default value. Messages without required fields are byte-identical to before. MessageFieldView::is_set / is_unset are now const fn.

  • type_name_prefix option (#199). buffa_build::Config::type_name_prefix("Rpc") (also CodeGenConfig::type_name_prefix and protoc-gen-buffa's type_name_prefix= option) prepends a prefix to every generated message struct and enum type name — message User {} generates struct RpcUser, with views (RpcUserView), cross-references, and re-exports following. Module names, oneof enums, extern_path-mapped types (including well-known types), and the wire/JSON format are unaffected. The prefix must be PascalCase (an ASCII uppercase letter followed by ASCII letters and digits); anything else is rejected at generation time.

Changed

  • UTF-8 validation on the decode path now uses smoothutf8 (default-on fast-utf8 feature). The view-decode hot path (borrow_str) and the contiguous-input branch of the owned-decode helpers (decode_string, merge_string) take verify_with_slack against the source buffer whenever at least 8 readable bytes follow the field — so the slack-buffer fast path is reached for every string field except one ending in the last 8 bytes of the wire buffer. The non-contiguous fallback and WirePayload::to_str take the safe smoothutf8::to_str, with simdutf8 delegation for inputs of 128 bytes or more when std is also on. Measured on bare metal at the default x86-64 target: view decode +15–22% and owned merge +7–15% on the string-heavy benchmark messages, neutral on bytes-dominated shapes. Consumers building with -C target-cpu=x86-64-v3 (Haswell+, 2013–) additionally get smoothutf8's BMI2 shift-DFA and AVX2 ASCII prefix scan, which the smoothutf8 README measures at ~1.6–2.2× stdlib on mixed-content input. Default builds gain smoothutf8 (and simdutf8 under std) as new dependencies; consumers already on default-features = false should add fast-utf8 to their feature list to keep the faster validator, or omit it to stay on core::str::from_utf8. A no_std build with fast-utf8 adds only smoothutf8 (zero-dependency, formally verified). (#241)

  • (breaking) protoc-gen-buffa now rejects malformed plugin parameters instead of stderr-warning or silently defaulting (#235). An unknown option key, a missing =, a non-true/false boolean value, an invalid reflect_mode, or a malformed extern_path now fails generation via CodeGeneratorResponse.error. Previously the default-on options (unknown_fields, register_types, with_setters) treated any value other than false as on, and unknown keys were silently ignored — typos produced generated code that did not match the requested config. Migration: re-run generation; if it newly fails, the named option was already being ignored. The accepted spellings have always been the only documented ones.

  • MSRV lowered from 1.87 to 1.75, and the README MSRV policy revised: rust-version now declares the lowest toolchain the released code actually compiles on (verified in CI), with bumps capped at roughly twelve months behind stable. The 1.75 floor is set by return-position impl Trait in traits, used by MapStorage::storage_iter. Reaching it required only mechanical respellings of newer stdlib conveniences — Option::is_none_or, i32::cast_unsigned, f64::abs in const fn — and gating the six #[diagnostic::on_unimplemented] hints behind rustversion::attr(since(1.78), …) so they remain active on modern toolchains. Adds rustversion as a dependency of buffa and buffa-descriptor. (#228)

  • DecodeOptions::with_max_message_size now clamps values above the protobuf 2 GiB - 1 message-size limit (with a debug assertion to catch accidental sentinel use). DecodeOptions::without_reader_size_limit is the explicit std-only opt-out for EOF-bounded decode_reader input; slice, Buf, view, and length-delimited decode paths keep their configured cap, and length-delimited declared lengths never exceed 2 GiB - 1. Callers that used with_max_message_size(usize::MAX) for unbounded reader input should switch to without_reader_size_limit; in release builds, the old spelling now caps at 2 GiB - 1. (#236)

  • MapValueDecode::merge now returns Result<MapValueDecodeStatus, _> instead of Result<(), _>, and a new merge_entry_with_unknowns carries the closed-enum-map preservation path. The trait is sealed, so downstream implementations are unaffected; direct callers of merge (rare) must handle the new return value. (#218)

  • SizeCache no longer zeroes its inline slot array on construction. A fresh cache is built for every encode/compute_size, and because it is passed by &mut to an out-of-line compute_size the compiler cannot elide the unused tail, so the previous [0u32; N] initializer emitted N/4 SSE stores on every encode (confirmed by disassembly). The inline storage is now [MaybeUninit<u32>; N], written only for the slots actually used; a slot is always written by reserve before len advances past it and read only at indices < len, so the single assume_init in consume_next is sound. This invariant is private to the size_cache module (no external code can break it — worst case is a panic, never UB) and is checked mechanically in CI by a Miri job over the size_cache tests. No API or wire-format change. (#223)

  • Default map<K,V> hasher is now foldhash::fast::RandomState on std builds (previously std::hash::RandomState / SipHash-1-3). The container remains std::collections::HashMap; only the S type parameter changes. This brings the std build in line with no_std (which already used foldhash via hashbrown's default) and matches the hasher class used by Google's protobuf-v4 (upb / Wyhash). On the LogRecord benchmark — a string-and-map-heavy shape — this is roughly a 12% owned-decode speedup. foldhash::fast is per-instance seeded (from ASLR addresses and process start time, not a CSPRNG) and does not advertise HashDoS resistance; treat the default as not hardened against adversarial hash flooding. Consumers decoding map fields with attacker-controlled keys who need a hardened bound can select MapRepr::BTreeMap (no hashing) or supply a SipHash-backed map via MapRepr::Custom. The MapStorage and ReflectMap impls are now generic over the hasher S, so a custom-hasher std::collections::HashMap works without a newtype. Migration: the concrete map field type changes, so code that names std::collections::HashMap<K,V> (default S) for a generated field no longer type-checks — use the buffa::Map<K,V> alias instead. Construct empty maps with buffa::Map::default() (HashMap::new() / HashMap::with_capacity() are unavailable on std builds because they are pinned to std's default hasher; use default() on both std and no_std for portability). Array-literal construction via Map::from([...]) / .into() is likewise unavailable; use [...].into_iter().collect(). buffa::Map and buffa::foldhash are now re-exported at the crate root. (#224)

  • Generated decode arms (owned merge, view decode, lazy record arms, map-entry loops) emit a single ::buffa::encoding::check_wire_type call instead of a seven-line inline wire-type guard (~1,100 sites across a generated corpus). Error payloads are byte-identical; the #[cold] out-of-line error constructor moves construction off the hot decode path. Regenerate checked-in code to pick up the shrink. (#193)

  • Owned map fields encode/decode through the new buffa::map_codec module (zero-sized per-proto-type codecs plus generic field helpers) instead of ~40-50 inline generated lines per map field. Wire output, decode-limit semantics, and the fixed-width sizing fast path are unchanged; everything monomorphizes to the previous code. (#194)

  • Generated write_to bodies use new fused put_*_field runtime writers (one call per field arm) instead of separate tag-encode + payload-encode pairs (~870 sites); owned and view impls share them. Wire output is byte-identical. The fused writers are #[inline(always)] so the field number reaching Tag::new const-folds and the inlined encode_varint collapses to a single byte store for tags below 128 — restoring the encode fast path that the call boundary had blocked. (#195, #207)

  • DefaultInstance / DefaultViewInstance / ViewReborrow impls are emitted via new public runtime macros (impl_default_instance!, impl_default_view_instance!, impl_view_reborrow!) instead of being expanded per generated type (~290 sites); hand-written message and view types can reuse them. No behavioural change. (#196)

  • Generated JSON Serialize impls use new internal (#[doc(hidden)]) buffa::json_helpers adapter newtypes (ProtoJson, BytesJson, MapKeyJson, sequence variants, ...) instead of ~65 per-site local _W* wrapper structs. JSON output is unchanged. (#197)

  • Breaking: MessageView gains a required merge_view_field method, and the per-view decode tag loop is now a provided trait method (merge_into_view), mirroring the owned side's Message::merge / merge_field split. Generated views supply only the field match — regenerate code from earlier releases. Hand-written MessageView impls must add merge_view_field; the trait docs include the canonical shape, the unknown-field-preserving arm, and the decode_viewdecode_view_ctx wiring. Sub-message arms call the new provided decode_view_ctx / merge_into_view instead of the removed inherent _decode_ctx / _merge_into_view helpers. (#198)

  • Breaking: the decode-path Message trait methods (merge, merge_field, merge_to_limit, merge_group, merge_length_delimited), encoding::decode_unknown_field, and message_set::merge_item now take a DecodeContext<'_> — carrying the remaining recursion depth and the shared unknown-field allowance — in place of the bare depth: u32. Code generated with earlier releases must be regenerated. Callers of the convenience methods (decode, decode_from_slice, merge_from_slice, DecodeOptions) are unaffected. (#184)

  • (breaking) Zero-copy views enforce the unknown-field limit and coalesce adjacent unknown records (#184). View decoding previously stored one borrowed span (16 bytes) per unknown wire record with no bound beyond the input size. Spans for adjacent unknown records now coalesce into a single span — a contiguous run of unknown fields costs one Vec slot regardless of field count, and re-encodes byte-identically — and each new span (one per unknown run) is counted against the same unknown-field limit that bounds owned-message decoding, configured via DecodeOptions::with_unknown_field_limit and honored by DecodeOptions::decode_view. As part of this, the view decode path now threads DecodeContext<'_>: MessageView::decode_view_with_limit(buf, depth) is replaced by decode_view_with_ctx(buf, ctx), and generated views' hidden _decode_depth helpers become _decode_ctx — code generated by earlier releases must be regenerated, consistent with the owned-path change above.

  • (breaking) View-to-owned conversion is now fallible and honors the decode-time limit (#184). MessageView::to_owned_message and to_owned_from_source (and the OwnedView wrapper) now return Result<Owned, DecodeError>: generated conversions previously swallowed unknown-field re-materialization errors via unwrap_or_default(), silently dropping every unknown field. UnknownFieldsView::to_owned also now re-materializes under the unknown-field allowance that remained when the view recorded its first unknown field — so a tight with_unknown_field_limit configured at decode_view time carries through conversion, where each owned UnknownField counts individually (unlike the coalesced spans the view stores). Views built manually via push_raw fall back to the default limit.

Fixed

  • Extension JSON serialization no longer scans previously seen unknown-field numbers linearly for every record. The serialize-side extension registry still emits each registered extension at most once in first-seen unknown-field order, but duplicate detection now uses a set instead of a Vec, avoiding quadratic work for messages with many distinct unknown field numbers. (#237)

  • extern_path references to nested types now use the owning crate's deconflicted module name (#233). When the owning crate renames a message's nested-types module to avoid colliding with a sibling sub-package (the [#135] deconfliction, e.g. Money's nested types land in money_ because sub-package pb.lyft.money also exists), a consumer referencing .pb.lyft.Money.Currency via extern_path previously emitted the un-deconflicted …::money::Currency and failed to compile. The consumer now computes the same deconflicted name. Caveat: the consumer's descriptor set must include the colliding sub-package file (importing any type from it suffices); otherwise the consumer cannot see the collision and emits the un-deconflicted path.

  • box_type_custom and repeated_type_custom now compile under generate_json(true). Two json_helpers functions were still hard-wired to the default representations: skip_if::is_unset_message_field only accepted &MessageField<T> (default Box<T> pointer), and proto_seq::deserialize only returned Vec<T>. So any JSON-enabled message with a custom-boxed optional sub-message, or a custom repeated collection of a 64-bit / float / bytes element, failed to compile. The former is now generic over P: ProtoBox<T> and the latter over C: From<Vec<T>>; both are inferred from the field type, so default-representation code is unchanged. (#234)

  • DecodeOptions::decode_reader no longer overflows when the read size is unbounded. The internal read_limited helper computed max_message_size as u64 + 1 to read one sentinel byte past the limit; on 64-bit targets this overflowed — a debug panic, or in release a wrap to zero that silently decoded an empty default message. The addition now saturates in the bounded path, and unbounded reads are spelled explicitly via DecodeOptions::without_reader_size_limit. 32-bit targets and finite limits are unaffected. (#219)

  • Closed-enum map values now preserve unknown entries correctly. For proto2 map<K, ClosedEnum> fields, an unknown enum value now prevents the map entry from being inserted and routes the whole original map-entry record to unknown fields. This fixes the previous default-valued entry synthesis (key -> E::default()) and applies to owned and view decode paths. Regenerate code with the matching buffa-codegen to get preservation; with an older codegen, runtime-only upgrades change unknown closed-enum map entries from default-insert to drop. (#218)

  • Packed varint views reserve by element count, not byte length (#216). Decoding a packed varint repeated field into a view previously reserved payload.len() slots — the upper bound for one-byte varints, but a 10× over-reservation for negative int32 / int64 (every element is a 10-byte varint). The reservation is now the exact element count, computed by a single pass that counts terminator bytes (most-significant-bit clear) before decoding. View memory for packed-signed-heavy messages drops proportionally; the wire format and owned decode are unchanged.

  • DecodeOptions::decode_length_delimited_reader no longer allocates the wire-declared length up front. The method previously allocated a zeroed buffer of the declared length before reading, so a source that declared a large length (up to max_message_size, 2 GiB by default) but delivered few or no bytes still forced the full allocation. The buffer now grows incrementally as bytes are actually delivered (initial capacity capped at 64 KiB), so peak allocation tracks delivered data. Truncated streams report UnexpectedEof exactly as before; behavior for well-formed streams is unchanged. (#185)

0.7.1 - 2026-06-10

This release is a patch bump under the Rust 0.x convention: everything below is additive or a fix, with no breaking changes and no MSRV change. The new codegen capabilities are opt-in (unbox_oneof) or gated on a proto option (debug_redact); the packed view pre-allocation applies to all regenerated code but is behaviorally invisible — a pure performance hint. Code regenerated with 0.7.1 calls the new (hidden) RepeatedView::reserve hook, so pair regenerated code with a buffa 0.7.1 runtime — any caret 0.7 requirement resolves there automatically.

Added

  • unbox_oneof opt-out for Boxed message oneof variants (#126). Config::unbox_oneof_in(&[paths]) stores the matching message-typed oneof variants inline in the owned enum instead of behind Box<T>, removing an allocation per construction; Config::unbox_oneof() is the blanket form. Recursive variants cannot be inlined: a rule naming one exactly is rejected at codegen time, while broader prefix rules (including the blanket) silently keep recursive variants boxed and inline the rest. View oneof variants are unaffected and stay boxed. Enums with an inline message variant carry #[allow(clippy::large_enum_variant)]. Contributed by @sam-shridhar1950f.

  • [debug_redact = true] is honored in generated Debug impls. Fields carrying the standard debug_redact field option print [REDACTED] instead of their value in the owned message's Debug impl, and oneof enums, view structs, and view-oneof enums containing such fields swap their #[derive(Debug)] for a generated impl that redacts those fields/variants. Output for messages without the annotation is unchanged. Note this covers Debug formatting only — text-format and JSON serialization are intentionally unaffected. A view struct containing a redacted field now lists proto fields only in its Debug output (matching owned messages), so __buffa_unknown_fields / phantom internals no longer appear there. The reflective DynamicMessage Debug impl honors the option as well, printing [REDACTED] in place of the value of any field whose descriptor carries it.

  • Packed repeated view decoders pre-allocate RepeatedView capacity. Generated view decode arms for packed repeated scalar / enum fields now call RepeatedView::reserve(_) before the decode loop, matching the existing pre-allocation hint on the owned decode path. Fixed-width kinds (fixed32, sfixed32, float, fixed64, sfixed64, double) reserve the exact element count; varint kinds (int32/64, uint32/64, sint32/64, bool, enum) reserve payload.len() as a safe upper bound (every wire varint is ≥ 1 byte). The hidden RepeatedView::reserve hook is also new but #[doc(hidden)]. This trims allocator pressure on workloads that decode many small packed repeated fields (MVT-style payloads), reported in #171.

Changed

  • TimestampError::Overflow's Display message generalized. It now reads "timestamp is out of range for the target type" instead of naming SystemTime, since the same error is returned by the new Timestampchrono::DateTime<Utc> conversion. Code matching on the enum variant is unaffected.

  • HasMessageView carries a #[diagnostic::on_unimplemented] hint. When a type is used where the generated view family is required but its crate was generated without one (buffa older than 0.7.0, or views disabled) or has it behind a disabled feature, the compile error now explains the cause and how to fix it — regenerate the defining crate with buffa ≥ 0.7.0 and views enabled (generate_views(true) / views=true), or enable the crate's views feature — instead of only naming the missing trait bound. Downstream consumers such as connect-rust rely on this trait for their request wrappers, so the notes land directly in the consumer's build output.

Fixed

  • Mixed-mode reflection degrades at the boundary as designed (#179). A vtable-mode message embedding owned message types generated in bridge mode (another crate or compilation) now reflects them as owned DynamicMessage snapshots at the boundary instead of failing to compile: vtable accessors for message-typed fields route through the field type's own Reflectable::reflect(), and bridge mode now also emits ReflectElement so repeated / map fields degrade too. View reflection still requires vtable-grade types throughout — that limitation is now documented. (Code matching exhaustively on ReflectCow may now observe Owned for bridge-grade message fields; all-vtable builds are unchanged.)
  • Missing-reflection compile errors point at the fix (#179). ReflectMessage, Reflectable, and ReflectElement carry #[diagnostic::on_unimplemented] hints, so building vtable codegen against an extern-path crate without its reflection feature (e.g. buffa-types without reflect) names the missing cargo feature instead of emitting a bare unsatisfied-trait error. The reflect_mode docs state the requirement.
  • The owned message Debug impl now labels keyword-named fields without the raw-identifier prefix (type instead of r#type), matching what #[derive(Debug)] prints and what the view Debug impl emits.
  • Octal escapes above \377 (255) in a proto2 bytes field's default_value are now rejected with a codegen error instead of silently wrapping to a wrong byte (\400 previously decoded to 0x00), matching protobuf C++'s UnescapeCEscapeString behavior (#164). Such escapes never appear in protoc-emitted descriptors, so this only affects hand-built or corrupted FileDescriptorSet input.
  • Hex escapes in a proto2 bytes field's default_value now consume the full run of hex digits and reject accumulated values above \xff (255) with a codegen error, matching protobuf C++'s UnescapeCEscapeString behavior (#173). Previously exactly two digits were read, so \xfff decoded to the byte 0xFF followed by a literal f instead of erroring, and a single-digit escape such as \x1 at end of input was wrongly rejected. As with the octal fix, such escapes never appear in protoc-emitted descriptors, so this only affects hand-built or corrupted FileDescriptorSet input.

0.7.0 - 2026-05-28

This release is a minor bump under the Rust 0.x convention. The breaking changes are the removal of OwnedView<V>'s Deref impl and the extension of use_bytes_type() to map<K, bytes> values (both under Changed below), plus an MSRV raise from 1.85 to 1.87. Consumers with checked-in generated code should regenerate with the 0.7.0 toolchain to pick up the new FooOwnedView wrappers, HasMessageView impls, and UpperCamelCase enum aliases — all additive.

Added

  • Runtime reflection: DescriptorPool and DynamicMessage. buffa-descriptor gains a reflect feature with a descriptor-driven reflection runtime. DescriptorPool::decode builds linked, feature-resolved descriptors (MessageDescriptor, FieldDescriptor, EnumDescriptor, ServiceDescriptor, …) from a FileDescriptorSet, treating the input as untrusted (malformed sets return PoolError rather than panicking) and retaining the raw FileDescriptorProtos plus a symbol index (file_by_name, file_containing_symbol) for gRPC server reflection. DynamicMessage decodes and encodes any message by descriptor — no generated types required — with unknown-field preservation, in-place mutation (field_mut / field_by_number_mut), Any pack/unpack, extension fields, and custom-option access (options() on every linked descriptor, DynamicMessage::from_options). With the json feature it also speaks proto3 canonical JSON (Serialize, DynamicMessage::from_json, lenient from_json_ignoring_unknown, duplicate-key rejection). The dyn-safe ReflectMessage / ReflectMessageMut traits and the ReflectCow / Value / ValueRef types are the surface generated types plug into (see vtable mode below). Generated code opts in with buffa_build::Config::generate_reflection(true) (plugin: reflection=true), which embeds the package's FileDescriptorSet and exposes a lazily-built pool as pkg::descriptor_pool(). The reflection codec passes the protobuf conformance suite through a dedicated DynamicMessage-only runner mode.

  • Vtable reflection mode. Generated types now implement buffa_descriptor::reflect::ReflectMessage directly — on both the owned structs and the zero-copy view types — so foo.reflect() borrows foo in place (ReflectCow::Borrowed) with no encode/decode round-trip and no per-field allocation. This is the path a CEL evaluator, transcoding gateway, or generic interceptor takes to read fields by descriptor; reflecting a decoded view runs several times faster than the previous bridge round-trip. Select the mode with the new buffa_build::ReflectMode enum:

    buffa_build::Config::new()
        .reflect_mode(buffa_build::ReflectMode::VTable) // or ::Bridge / ::Off
        .compile()?;

    The protoc-gen-buffa equivalent is reflect_mode=off|bridge|vtable. Vtable mode does not require view generation: with views off, only the owned ReflectMessage is emitted. generate_reflection(true) selects vtable mode; reflect_mode(ReflectMode::Bridge) opts into the smaller round-trip implementation (one DynamicMessage encode/decode per reflect() call) instead of one impl ReflectMessage per generated type.

  • buffa-types reflect feature. Well-known types (Timestamp, Duration, Struct/Value, Any, wrappers, …) now implement ReflectMessage, so messages that embed WKTs reflect end to end.

  • Pluggable owned types for string and bytes fields (#127, #156, #206). Generated string / bytes fields can use a custom in-memory type chosen at code-generation time, with no change to the wire format. buffa_build::Config gains string_type(StringRepr) / string_type_in and the convenience string_type_custom("::path::To::Type") / string_type_custom_in, where buffa_build::StringRepr is { String (default), Custom(path) }. The new bytes counterpart is bytes_type(BytesRepr) / bytes_type_in / bytes_type_custom / bytes_type_custom_in, where BytesRepr is { Vec (default), Bytes, Custom(path) }; use_bytes_type / use_bytes_type_in remain as aliases for BytesRepr::Bytes. Rules accumulate and the last match wins. Only the owned struct field type changes — view types still borrow &str / &[u8], and map keys/values keep their default type.

    The chosen type must implement the marker traits buffa::ProtoString / buffa::ProtoBytes. Each requires a from_wire(WirePayload<'_>) -> Result<Self, DecodeError> constructor (alongside the supertraits Clone + PartialEq + Default + Debug + Send + Sync, Deref to str / [u8], AsRef, and From<String> / From<Vec<u8>>). from_wire lets each representation own validation and borrow-vs-own: WirePayload is Borrowed(&[u8]) (zero-copy) or Owned(Bytes), with as_slice() and into_bytes(). A representation that enforces extra invariants can reject a value from from_wire with the new DecodeError::Custom(&'static str) variant. buffa ships the built-in impls for String, Vec<u8>, and bytes::Bytes; a foreign type (e.g. smol_str::SmolStr) is wrapped in a local newtype that implements the trait — the new buffa-smolstr crate is the template (an inline, allocation-free from_wire). A custom type needs no native Arbitrary impl (a generic builder handles it). A custom type used as the element of a repeated field — or a custom bytes type as a map<K, bytes> value — must be crate-local: codegen emits ReflectElement (vtable) and, for bytes, base64 ProtoElemJson (JSON) impls for it, which the orphan rule forbids for a foreign type. A custom bytes map value is honored just like the built-in Bytes (only the map<bytes, bytes> carve-out keeps Vec<u8>). Singular / optional / oneof uses work with the newtype without the crate-local restriction.

    Why from_wire rather than a blanket From-based impl: the decode path was first built as a blanket impl over From<String> / From<Vec<u8>> to learn the tradeoff, but that path always pays decode_string's allocate-and-copy and a transient heap allocation even for a short string that an inline type (smol_str) could store without touching the heap. from_wire hands the representation the raw payload so it can inline, validate lazily, or take ownership zero-copy — so it never disadvantages a custom type.

    BREAKING (unreleased only): the earlier unreleased string_type shapes are removed — both the StringRepr::{SmolStr, EcoString, CompactString} presets (with the buffa / buffa-descriptor smol_str / ecow / compact_str features and ::buffa::{smol_str, …} re-exports) and the later blanket From-based ProtoString / ProtoBytes. Pointing string_type_custom at a foreign type directly no longer compiles; use buffa-smolstr (or a local newtype implementing from_wire). Default output (String / Vec<u8>) is byte-for-byte unchanged. buffa-build / buffa-codegen only — there is no protoc-gen-buffa plugin option yet.

  • Generated FooOwnedView wrapper types. When views are generated, each message now also gets a FooOwnedView — re-exported at the package root next to Foo and FooView (canonical path __buffa::view::FooOwnedView): a self-contained 'static handle wrapping OwnedView<FooView<'static>> with one accessor method per field (owned.name(), owned.id(), …). Every accessor borrows from &self, so field data can never outlive the underlying buffer, and the handle stays Send + Sync for async handlers and spawned tasks. The wrapper forwards decode / decode_with_options / from_owned / to_owned_message / bytes / into_bytes, exposes the full view via view(), converts to and from the raw OwnedView, and serializes to protobuf JSON when generate_json is enabled. A field or oneof whose name collides with one of the wrapper's reserved method names keeps working through view(); its accessor is skipped with a build warning (CodeGenWarning::OwnedViewAccessorSuppressed).

  • HasMessageView view-family trait. Generated code now implements buffa::HasMessageView for every message (when views are generated), linking the owned type to its view types: Foo::View<'a> = FooView<'a> and Foo::ViewHandle = FooOwnedView, with a provided decode_view_handle() helper. The generated wrapper additionally implements From<OwnedView<FooView<'static>>> and AsRef<OwnedView<FooView<'static>>>, so code that is generic over an owned message can decode, reborrow, and convert without naming the concrete types — the hook an RPC framework needs to accept M and work with M::View<'_> and M::ViewHandle generically.

  • Idiomatic UpperCamelCase enum value aliases (#13). Generated enums now also carry associated const aliases with the enum-name prefix stripped and the value converted to UpperCamelCaseRuleLevel::RULE_LEVEL_HIGH is reachable as RuleLevel::High — usable in expressions and in pattern position with exhaustiveness preserved. The SHOUTY_SNAKE_CASE variants remain the definitive variants and Debug output is unchanged, so the aliases are purely additive; consumers with checked-in generated code will see new consts on regeneration. If two values of an enum would collide after conversion, aliases are suppressed for that enum as a whole and reported through the new CodeGenWarning diagnostics (buffa_codegen::generate_with_diagnostics). Default on; opt out per compilation unit with buffa_build::Config::idiomatic_enum_aliases(false) / CodeGenConfig::idiomatic_enum_aliases = false.

Changed

  • OwnedView<V> no longer implements Deref<Target = V>. Breaking. The Deref impl exposed the inner view as FooView<'static>, so borrowed fields appeared 'static to the compiler and could be held past the point where the OwnedView (and the buffer they point into) was dropped — safe code could end up reading freed memory. In practice this required the calling application to deliberately store a field reference beyond the handle's lifetime, so the practical exposure is limited, but the API should not allow it at all. Field access now goes through reborrow() (one extra call per scope: let person = owned.reborrow(); person.name) or, more conveniently, the new generated FooOwnedView accessor methods, both of which tie every borrow to the handle. Serializing the handle directly (serde_json::to_string(&owned_view)) is unaffected.
  • use_bytes_type() / use_bytes_type_in(...) now applies to map<K, bytes> values (#76). Previously map values were always Vec<u8> regardless of config — the only bytes-context not covered. They now match the type used for singular / optional / repeated / oneof bytes fields under the same rule (bytes::Bytes when configured), so view → owned conversion of map values participates in the to_owned_from_source zero-copy slice_ref path just like the other shapes. Breaking for code that already enabled use_bytes_type() on a proto containing map<K, bytes>: at construction sites, rewrite map-value construction from Vec<u8> to bytes::Bytes (b"v".to_vec()bytes::Bytes::from_static(b"v") for literals, bytes::Bytes::from(v) for an owned Vec<u8>, or bytes::Bytes::copy_from_slice(s) for a non-'static borrow). At read sites, bytes::Bytes has no inherent as_slice, so any as_slice() on the value needs replacing — e.g. map.get(k).map(Vec::as_slice) becomes map.get(k).map(|b| &b[..]). One carve-out: an effective map<bytes, bytes> keeps Vec<u8> values; this requires strict_utf8_mapping(true) and a map<string, bytes> whose key carries [features.utf8_validation = NONE] (strict_utf8_mapping alone keeps a plain map<string, bytes> value as Bytes). See the use_bytes_type_in docs. Under generate_arbitrary, affected map fields use the new __private::arbitrary_bytes_map<K> shim (K: Arbitrary + Eq + Hash — every proto map-key type satisfies this).
  • MSRV raised from 1.85 to 1.87, following the README's MSRV policy of tracking roughly twelve months behind the latest stable release, re-evaluated each time a release is cut. While buffa is pre-1.0, an MSRV bump rides a minor (0.x) release.

Fixed

  • Module redefinition error when a message and a sub-package share a name (#135). A message with nested types emits a snake_case(MessageName) submodule, which collided with a sibling sub-package of the same name (protobuf is case-sensitive — message Oof and package foo.oof legally coexist — but both mapped to mod oof, producing an E0428). Codegen now deconflicts the nested-types module by appending _ (e.g. oof_; more underscores if several modules collide in the same scope — see DESIGN.md), leaving the message struct (foo::Oof) and the sub-package module (foo::oof) at their natural names. This only triggers on a collision that previously failed to compile, so existing output is unchanged. Two caveats: (1) if you add a sub-package whose name collides with an existing message's nested-types module, paths to those nested types move from foo::oof::… to foo::oof_::…; (2) both packages must be generated in the same buffa_build::Config::compile() call — deconfliction cannot span separate compilations, since each only sees its own descriptor set.

  • Per-type extern_path mappings were silently ignored (#111). An extern_path entry naming a single type FQN (e.g. .extern_path(".google.protobuf.Timestamp", "::my_types::Timestamp"), the prost/tonic idiom) parsed but never matched, because resolution only considered package prefixes. Type references now resolve per-type: an exact type-FQN entry wins over the internal descriptor.proto routing, which wins over the longest matching package prefix, which wins over local generation. Nested types inherit an enclosing message's override, resolving to the override's parent module plus the usual snake_case(MessageName) nested-types module. Note that entries which previously had no effect now take effect: a type-FQN entry (including a typo'd one) that was a silent no-op before will now change the generated reference, and a wrong target surfaces as a compile error in the generated code.

0.6.0 - 2026-05-15

Added

  • Generated message structs now include with_<field>(value) -> Self builder-style setter methods for every explicit-presence field (proto3 optional, proto2 optional, and editions fields with field_presence = EXPLICIT). This allows chained construction without Some(...) wrapping:

    let req = GetSecretRequest::default()
        .with_name("alice")
        .with_timeout_ms(30_000)
        .with_enabled(true);

    String fields accept impl Into<String> (&str works directly); bytes fields accept impl Into<Vec<u8>> or impl Into<bytes::Bytes> (byte array literals like b"data" work directly); enum fields accept impl Into<EnumValue<E>> (bare variant works directly, no EnumValue::Known(...) wrapper needed); plain scalars take the bare type to keep integer-literal inference unambiguous. Message fields (MessageField<T>), repeated fields, map fields, oneof variants, proto2 required fields, and implicit-presence fields are unaffected. To clear a field, assign None directly. Setters are pure inherent methods with no runtime dependency, so they're emitted unconditionally regardless of gate_impls_on_crate_features. Disable per compilation unit with CodeGenConfig::generate_with_setters = false, buffa_build::Config::generate_with_setters(false), or the with_setters=false plugin opt. Consumers with checked-in generated code will see new methods on regen. (#30, #93, by @tejas-dharani)

  • buffa::MessageName trait exposes a generated message's protobuf identifiers as compile-time &'static str constants. Codegen emits impl MessageName for #Msg (and for #MsgView<'a>) with four consts: PACKAGE ("my.pkg", empty for the unnamed root package), NAME ("Outer.Inner" — unqualified, with . between nesting levels), FULL_NAME ("my.pkg.Outer.Inner"), and TYPE_URL ("type.googleapis.com/my.pkg.Outer.Inner" — the google.protobuf.Any.type_url form). All four are computed at codegen time as string literals, so there's no runtime allocation or concatenation — unlike prost::Name, whose full_name() and type_url() are runtime format! calls. PACKAGE and NAME are separate consts because the dotted FULL_NAME cannot be split unambiguously (foo.Bar.Baz could be package foo.Bar + message Baz or package foo + nested Bar.Baz).

    The trait has no supertrait — it doesn't reach into the wire codec — so view types implement it too: a generic event-sourcing registry can bound on T: MessageName and dispatch zero-copy views and owned messages identically. Useful for type-erased registries, logging, and any code that needs the protobuf name without the descriptor machinery. The inherent Foo::TYPE_URL const generated since 0.4.0 is unchanged and equal to <Foo as MessageName>::TYPE_URL; for messages that also implement ExtensionSet, FULL_NAME is equal to ExtensionSet::PROTO_FQN (all derive from the same codegen source). MessageName is not object-safe (associated const only) — use it as a bound, not dyn MessageName. Migrating from prost::Name: rename the bound and replace runtime M::full_name() / M::type_url() calls with the consts. (#108, by @yordis)

  • buf.build/anthropics/buffa is published to the public Buf Schema Registry. buf generate can now reference protoc-gen-buffa as a remote: plugin with no local install: remote: buf.build/anthropics/buffa with opt: [file_per_package=true] and a small hand-written pub mod tree, or paired with a locally-installed protoc-gen-buffa-packaging for a generated mod.rs. The README quick-start, docs/guide.md "Using buf" section, and a new examples/bsr-quickstart/ project document the workflow. The stale in-repo protoc-gen-buffa/buf.plugin.yaml metadata file is removed — the canonical plugin definition lives in bufbuild/plugins.

  • buffa-codegen: CodeGenConfig::gate_impls_on_crate_features. When true, generated impls controlled by generate_json, generate_views, and generate_text are wrapped in #[cfg(feature = "json" | "views" | "text")] (or #[cfg_attr(...)] for derives and field attributes) instead of being emitted unconditionally. The consuming crate defines matching Cargo features and enables the corresponding runtime support (buffa/json, buffa/text, serde, …) behind them. The generate_* flags still control whether an impl kind is emitted; the new flag only controls how. Default false — no change to existing output. This is the codegen mechanism that will let buffa-descriptor and buffa-types ship every impl while keeping the codegen toolchain (buffa-codegen / buffa-build / protoc-gen-buffa) lean — it depends on them with default-features = false. Tracked in #113. Exposed as buffa_build::Config::gate_impls_on_crate_features(bool) and the gate_impls=true plugin opt, both default-off.

  • buffa-descriptor: regenerated with views, JSON, text, and arbitrary impls behind crate features. descriptor.proto and compiler/plugin.proto types now ship the full impl surface — gated on views, json, text, and arbitrary Cargo features so the codegen toolchain (buffa-codegen / buffa-build / protoc-gen-buffa) can depend on buffa-descriptor with default-features = false and stay free of serde / serde_json / base64 / arbitrary. Consumers whose protos reference a descriptor.proto type as a field (most commonly anything depending on buf/validate/validate.proto, or buf.registry.module.v1 / buf.alpha.image.v1 which embed FileDescriptorSet / FileDescriptorProto) must enable the buffa-descriptor features matching their codegen modesviews = ["buffa-descriptor/views"], json = ["buffa-descriptor/json"], etc., or just buffa-descriptor = { ..., features = ["views", "json"] }. This closes #113: the full bufbuild/registry and bufbuild/buf modules now generate and compile cleanly with views=true + json=true.

    Migration: if your Cargo.toml already declares buffa-descriptor as a dependency, add the features matching your codegen config:

    # build.rs uses .generate_views(true).generate_json(true)
    buffa-descriptor = { version = "0.6", features = ["views", "json"] }

    If you don't declare buffa-descriptor directly, the failure mode is a missing-impl error at the embedding type's serde / view call site (e.g. the trait bound FileDescriptorSet: serde::Deserialize is not satisfied); add buffa-descriptor with the right features.

    The buffa_descriptor::generated module tree now nests google.protobuf.compiler inside google.protobuf to mirror the proto package hierarchy (so cross-package super::* references in the view code resolve); the previous sibling-style buffa_descriptor::generated::compiler and buffa_descriptor::generated::{FileDescriptorProto, GeneratedCodeInfo} paths are preserved with pub use re-exports.

  • serde::Serialize is now implemented for generated view types when generate_json is enabled, allowing zero-copy JSON serialization without .to_owned_message(). OwnedView<V> also gains a blanket Serialize impl so serde_json::to_string(&owned_view) works directly. Well-known type views (TimestampView, DurationView, AnyView, etc.) also implement Serialize (delegating to the owned form) when the buffa-types/json feature is enabled, so messages that nest WKT fields work out of the box. MapView gains iter_unique() and len_unique() helpers (last-write-wins deduplication) so map fields with duplicate wire keys serialize to a valid JSON object. The protobuf conformance suite gains a BUFFA_VIEW_JSON=1 run that exercises view-side JSON output against the conformance reference assertions. Known limitations: (1) Extension fields are not included in view JSON output — serialize the owned form (view.to_owned_message()) to include extensions. (2) The view impl uses serialize_map(None), which is fine for serde_json but will be rejected at runtime by length-prefixed formats like bincode or postcard; use the owned form for those serializers. (#83)

Fixed

  • buffa / buffa-codegen: serde_json re-exported from buffa for generated extension JSON deserialize. Messages with extensions N to M; ranges and json=true codegen get a hand-written Deserialize impl that buffers "[pkg.ext]" JSON keys into a serde_json::Value before dispatching to extension_registry::deserialize_extension_key. The emitted path was a bare ::serde_json::Value, which silently required every consumer of json=true codegen to declare serde_json directly in its own Cargo.toml — a footgun reported by Buf for bufbuild_registry_* SDKs generated against buf/validate/validate.proto (which has 21 extension ranges). buffa now re-exports serde_json (gated on the json feature, #[doc(hidden)], matching the existing bytes re-export) and codegen emits ::buffa::serde_json::Value, so consumers only need buffa, buffa-types, and serde (the latter for the #[derive] macro). No generated output exists for this path in the checked-in WKTs (none declare extension ranges), so no regen.

  • buffa-codegen: descriptor.proto types now resolve to buffa-descriptor, not buffa-types. The auto-injected WKT extern_path .google.protobuf::buffa_types::google::protobuf covers everything in the google.protobuf package, including descriptor.proto types — but buffa-types only ships the JSON-mappable WKTs. Any proto referencing a descriptor.proto type as a field — e.g. buf/validate/validate.proto, which has three optional google.protobuf.FieldDescriptorProto.Type fields — produced a generated path that doesn't exist: ::buffa_types::google::protobuf::field_descriptor_proto::Type. An internal file-level extern resolution now routes google/protobuf/descriptor.proto to ::buffa_descriptor::generated::descriptor and google/protobuf/compiler/plugin.proto to ::buffa_descriptor::generated::compiler, taking priority over the package-level WKT mapping. Suppression mirrors the WKT mapping: a user .google.protobuf extern_path overrides it (preserving the long-standing behaviour that the override covers descriptor types too), and a file in files_to_generate resolves locally. Consumers whose protos import "google/protobuf/descriptor.proto" and reference its types as fields must add buffa-descriptor to their [dependencies] — the same way protos that reference WKTs require buffa-types. The user-facing extern_path API is unchanged (still package-prefix keyed).

  • buffa: closed-enum JSON helpers no longer require the enum to impl Deserialize. opt_closed_enum, repeated_closed_enum, and map_closed_enum deserialized via serde_json::from_value::<E>(), which bound E: DeserializeOwned. That meant a closed-enum field whose enum type lives in an externally-generated crate built without generate_json — e.g. google.protobuf.FieldDescriptorProto.Type from buffa-descriptor, referenced by buf/validate/validate.proto — could not satisfy the bound and refused to compile under json=true codegen. The helpers now decode the buffered serde_json::Value directly via the Enumeration trait (from_proto_name, from_i32, default for null), which is the same dispatch the codegen-emitted Deserialize impl performs anyway. The DeserializeOwned bound is removed (a relaxation — non-breaking). Lenient mode (ignore_unknown_enum_values) is unchanged: any element that fails to decode — unknown variant, out-of-range integer, or wrong JSON type — is dropped from the container / leaves the optional unset, exactly as before. Additionally, that lenient filtering for closed-enum containers now works under no_std: the previous implementation needed the std-only scoped strict-mode override to surface a distinguishable error from the inner deserialize, but the new Enumeration-direct dispatch has no inner deserialize to override.

Changed

  • The workspace [profile.release] now sets lto = true and codegen-units = 1. This shrinks the prebuilt protoc-gen-buffa / protoc-gen-buffa-packaging release binaries by roughly 20% at the cost of ~2× clean release-build time. Cargo only honors profile sections from the top-level workspace, so library consumers of buffa / buffa-build do not inherit this — set [profile.release] in your own workspace (or CARGO_PROFILE_RELEASE_LTO=true for cargo install) to get the same benefit. (#60)

  • buffa-codegen: empty ancillary content files and modules are no longer emitted. A .proto with no oneofs / no extension declarations / views=false previously produced placeholder <stem>.__oneof.rs / <stem>.__ext.rs / <stem>.__view.rs / <stem>.__view_oneof.rs files containing only the @generated header, and the package stitcher unconditionally authored a pub mod __buffa { pub mod oneof { ... } pub mod ext { ... } ... } tree that include!d them. Codegen now omits an ancillary content file when it would be empty, the stitcher only include!s files that exist, and the __buffa wrapper (and each view / oneof / ext submodule inside it) is itself omitted when it would be empty — so a package with only owned messages emits no __buffa block at all. Eliminates pure noise in generated trees, editor file lists, search, and review diffs. Consumers with checked-in generated code will see file deletions and stitcher diffs on regeneration; remove orphaned empty files. The __buffa::* paths are an internal sentinel namespace (consumers reach for the natural-path re-exports added in 0.5.0), so no supported public surface changes — but a hand-written use crate::pkg::__buffa::oneof::*; for a package that has no oneofs would now fail to resolve (it was previously a no-op import of an empty module). (#107)

0.5.2 - 2026-05-07

Fixed

  • buffa-codegen: oneof Serialize match arms now use Self::#variant. generate_oneof_serialize emits the manual JSON serde impl as impl Serialize for #enum_ident { fn serialize(&self, …) { match self { … } } }, where Self resolves to the oneof enum. The match arms used the fully-qualified #enum_ident::#variant form, which trips clippy::use_self in workspaces that opt it on — particularly visible under connectrpc-build, which doesn't carry an inner #![allow(...)] the way protoc-gen-buffa-packaging does, so the oneof companion file inherits the surrounding mod's lint set. The deserialize arms in oneof_variant_deser_arm remain qualified because they construct the oneof from inside the message's Deserialize impl, where Self would be wrong. No behavioural change.
  • buffa-codegen: enum JSON deserialize errors use inlined format args. The enum visitor's range-check and unknown-value error messages used positional format!("enum value {} out of i32 range", v) etc., which trip clippy::uninlined_format_args for the same reason as above (the enum impls live in the per-proto Owned content, outside the __buffa #[allow(...)] block). Now format!("enum value {v} out of i32 range") etc. — semantically identical, lint-clean regardless of which module wrapper covers it.

0.5.1 - 2026-05-07

Fixed

  • buffa-codegen: ALLOW_LINTS now includes unused_qualifications. Cross-proto references within the same package are emitted through the canonical super::super::__buffa::view::… (and …::oneof::…) path even though the target lives in the same generated module. The bare name would resolve, but the canonical path is stable when a sibling proto defines a same-named natural-path re-export. Workspaces that opt unused_qualifications = "warn" and build with -D warnings were getting false positives from generated code; the lint is now in the package stitcher's #[allow(...)] block alongside dead_code, unused_imports, etc.

0.5.0 - 2026-05-05

This release is a minor bump under the Rust 0.x convention: the only API break is #[non_exhaustive] on buffa_codegen::GeneratedFileKind (see Changed below), which affects downstream code generators only — it does not change the runtime API. Everything else is additive.

Consumers with checked-in generated code must regenerate with the 0.5.0 toolchain before depending on the 0.5.0 runtime crates: generated code from 0.5.0's buffa-codegen references ViewReborrow, decode_bytes_to_bytes, and __private::arbitrary_bytes, none of which exist in buffa 0.4.0.

Breaking changes

  • buffa_codegen::GeneratedFileKind is now #[non_exhaustive]. Match it with a wildcard arm — future kinds can then be added without a major version bump. Build integrations that compare with == (the common case, including connect-rust) are unaffected.

Added

  • buffa_codegen::GeneratedFileKind::Companion and apply_companions let downstream code generators (e.g. connect-rust) supply extra per-proto files that buffa wires into the per-package stitcher, instead of having to mislabel them as GeneratedFileKind::Owned and rely on filename matching. Companion files are include!d at package root alongside owned message types. (#81)

  • OwnedView<V> gains a reborrow<'b>(&'b self) -> &'b V::Reborrowed<'b> method that makes the internal 'static lifetime visible as 'b (the lifetime of the borrow), so view fields can be passed into functions or return types bounded by the OwnedView's lifetime. Requires V: ViewReborrow, a safe trait whose reborrow method body is a covariance-checked subtype coercion; codegen emits the impl automatically for every generated view type. Hand-written view types opt in with impl ViewReborrow for MyView<'static> { type Reborrowed<'b> = MyView<'b>; fn reborrow<'b>(this: &'b Self) -> &'b Self::Reborrowed<'b> { this } } — the body fails to compile for invariant view types, so no unsafe is needed. (#82)

  • Codegen now emits "natural-path" pub use re-exports for ancillary types (views, oneof enums, view-of-oneof enums, file-level extension consts, register_types) at the module path you'd write first — pkg::FooView, pkg::foo::Kind, pkg::foo::KindView, etc. The canonical __buffa:: paths are unchanged and remain what generated code and downstream codegen always reference; the re-exports are purely an ergonomic convenience and are silently skipped when the natural name is already taken by a real proto item or by another candidate re-export. Because of that skip rule, adding a proto type whose name shadows a re-export (e.g. message FooView next to message Foo) can silently rebind a natural path between releases — the canonical __buffa:: path is always stable; use it directly when a natural import stops resolving (see examples/conflicts for one alias convention). (#80)

  • Doc comments in generated Rust code now resolve AIP-192 proto type cross-references ([Book][google.example.v1.Book], [Book][]) to rustdoc intra-doc links. Only type-level refs are resolved; member refs such as [Genre.GENRE_SCI_FI][] fall back to escaped literals. Unknown or cross-crate references also fall back silently. (#26)

  • protoc-gen-buffa and protoc-gen-buffa-packaging now respond to --version / -V and --help / -h instead of blocking on stdin. Any other command-line argument prints a "this is a protoc plugin" hint to stderr and exits non-zero.

  • buffa::types::decode_bytes_to_bytes reads a length-delimited bytes field into a bytes::Bytes via Buf::copy_to_bytes. When decoding from a Bytes-backed buffer this is a zero-copy refcount bump. Generated merge_field arms for bytes_fields-tagged fields (singular, optional, repeated, and oneof) now use it instead of Bytes::from(decode_bytes(..)?), eliminating one allocation + memcpy per field on the owned decode path. Note that in the zero-copy case the resulting field aliases the source allocation, so the source buffer is freed only once every aliased field is dropped. Consumers with checked-in generated code must regenerate to pick this up. (#53)

Fixed

  • buffa-types --features arbitrary now compiles. Any.value is bytes::Bytes (since 0.4.0 / #51), which has no Arbitrary impl. Codegen now emits #[arbitrary(with = ::buffa::__private::arbitrary_bytes*)] on every bytes_fields-typed field — singular, optional, and repeated struct fields plus oneof variant inner fields — when generate_arbitrary = true, so the struct-level derive(Arbitrary) succeeds. Map values are unaffected (they are always Vec<u8> regardless of bytes_fields). The same fix covers any user crate that uses bytes_fields + generate_arbitrary. cargo doc --workspace --all-features and cargo clippy --workspace --all-features are also unblocked, and CI now runs cargo check --workspace --all-features to prevent recurrence. (#88)

  • write_to now emits fields in ascending field-number order regardless of cardinality (singular / repeated / map / oneof), matching prost, protoc-C++, and the spec's serialize-in-field-order recommendation. Previously fields were emitted grouped by kind, which broke byte-equivalence with other implementations for messages mixing a high-numbered singular field with a lower-numbered repeated/map/oneof. Decoders accept any order, so this is not a wire-compat break, but consumers content-addressing serialized bytes (e.g. hash(encode(msg))) will see different hashes for affected message shapes. (#75)

0.4.0 - 2026-04-27

Breaking changes

  • Ancillary generated types moved under pkg::__buffa::. View structs, oneof enums, view-of-oneof enums, extension consts, and register_types no longer share the package-level Rust namespace with owned message structs. The new layout:

    Item Before After
    View struct pkg::FooView pkg::__buffa::view::FooView
    Nested view pkg::foo::BarView pkg::__buffa::view::foo::BarView
    Oneof enum pkg::foo::KindOneof pkg::__buffa::oneof::foo::Kind
    View-of-oneof pkg::foo::KindOneofView pkg::__buffa::view::oneof::foo::Kind
    Extension const pkg::FOO pkg::__buffa::ext::FOO
    Registration fn pkg::register_types (per file) pkg::__buffa::register_types (per package)

    Owned message structs and nested-type modules are unchanged. Migration is a mechanical path rewrite per the table above. The Oneof / OneofView suffixes are dropped — the parallel module tree disambiguates.

    This makes name collisions between user proto types and codegen-derived ancillary names structurally impossible. __buffa is the only name codegen reserves in user namespace; it aligns with the existing __buffa_ reserved field-name prefix. A proto message, file-level enum, or package segment that snake-cases to __buffa is rejected with CodeGenError::ReservedModuleName.

  • Consumer include pattern: use buffa::include_proto!("dotted.pkg"). Codegen now emits a per-package <dotted.pkg>.mod.rs stitcher alongside the per-proto content files. Hand-authored include!(concat!(env!("OUT_DIR"), "/my_file.rs")) blocks no longer produce a complete module; replace with:

    pub mod my_pkg {
        buffa::include_proto!("my.pkg");
    }

    buffa-build's generate_include_file() already emits the correct structure; consumers using that helper need no change.

  • __buffa_cached_size is removed from all generated structs (owned and view); Message::compute_size / write_to and ViewEncode::compute_size / write_to now take a &mut SizeCache parameter. Sizes are recorded in an external pre-order Vec<u32> cache that the provided encode* methods construct internally, so generated types contain only their proto fields plus __buffa_unknown_fields — no interior mutability, structurally Send + Sync, and concurrent encode() of the same &msg from multiple threads is sound. Message::cached_size() and ViewEncode::cached_size() are removed; use the new provided encoded_len() to get the size alone. __private::CachedSize is removed. Hand-written Message / ViewEncode impls must add the cache: &mut SizeCache parameter, drop cached_size(), and (for nested message fields) wrap recursion in cache.reserve() / cache.set(); see the custom-types section of the user guide for the pattern. (#14, #22)

  • DefaultInstance and DefaultViewInstance are no longer unsafe traits, and HasDefaultViewInstance is removed. The liveness and immutability invariants are fully encoded by the return type and cannot be violated by a safe implementation. DefaultViewInstance is now implemented for FooView<'v> at every lifetime (not just 'static), with fn default_view_instance<'a>() -> &'a Self where Self: 'a; the covariant lifetime coercion happens in the impl body where the compiler checks it via ordinary subtyping, eliminating the raw pointer cast in Deref for MessageFieldView. Hand-written impls must drop the unsafe keyword and adopt the new method signature; the separate HasDefaultViewInstance impl is no longer needed. (#68, #69)

  • CodeGenError::OneofNameConflict and ::ViewNameConflict removed. These collisions are now structurally impossible (the inputs that previously triggered them produce valid output).

  • google.protobuf.Any.value is now ::bytes::Bytes instead of Vec<u8>. Makes Any::clone() a cheap refcount bump (up to ~170x faster for large payloads) instead of a full memcpy. Call sites constructing an Any by hand need .into() on the payload (e.g. value: my_vec.into(), or pass Bytes directly). Reading any.value is unchanged — Bytes derefs to &[u8]. buffa-types now depends on bytes unconditionally.

Deprecated

  • buffa_build::proto_path_to_rust_module — consumers should use the per-package <pkg>.mod.rs stitcher path via buffa::include_proto! instead.

Added

  • ViewEncode<'a> — serialization from borrowed view types. Generated *View<'a> types implement ViewEncode (whenever views are generated, i.e. generate_views(true), the default) with the same two-pass compute_size/write_to model as Message. Views can be constructed from borrowed &'a str / &'a [u8] and encoded without intermediate String/Vec allocation. Benchmarks: parity on serialize-only; ~6× on build+encode for a 15-label string-map message.
  • buffa::include_proto!("dotted.pkg") macro — wraps the per-package .mod.rs stitcher; the canonical consumer integration point.
  • MapView::new(Vec) / From<Vec> / FromIterator for constructing map views directly (for ViewEncode).
  • SizeCache — external pre-order size table for the two-pass encode protocol ([u32; 16] inline + Vec<u32> spill, allocation-free for ≤16 nested LEN sub-messages). The provided encode*() methods construct one internally; for hot loops, the new encode_with_cache(&mut SizeCache, buf) reuses a single cache across calls.
  • Message::encoded_len() / ViewEncode::encoded_len() — provided method returning the serialized size without writing (replaces compute_size()-then-discard).
  • Enumeration::values()&'static [Self] slice of all variants for iteration.
  • buffa-build / buffa-codegen: type_attribute, field_attribute, message_attribute, enum_attribute — attach Rust attributes (e.g. #[derive(...)], #[serde(...)]) to specific generated types or fields by proto path.
  • protoc-gen-buffa: text=true and allow_message_set=true plugin parameters — match the existing buffa-build config flags.
  • #[must_use] on Message/ViewEncode compute_size, encoded_len, encode_to_vec, encode_to_bytes.

Fixed

  • A proto message named Option — anywhere in the proto package, including nested in a sibling message or in another file — no longer shadows core::option::Option in generated optional/oneof field types and the JSON deserialize path. Generated code now always emits the fully-qualified ::core::option::Option. (#36, #64)
  • Oneof variant names that PascalCase to a reserved Rust identifier (in practice, proto field self → variant Self) are now escaped. (#47)
  • Nested type and oneof sharing the same name (the gh#31 RegionCodes case) and Foo next to FooView (gh#32) — both now structurally resolved by the __buffa:: namespacing above.

0.3.0 - 2026-04-01

Breaking changes

  • Extension::new(number)Extension::new(number, extendee). Same for Extension::with_default. Codegen consumers are unaffected — the pub const items are regenerated. Hand-written Extension consts (unusual) need the extendee string added.
  • ExtensionSet trait gained a required const PROTO_FQN: &'static str. Codegen consumers are unaffected. Hand-written impls need the const added.
  • extension(), set_extension(), clear_extension() now panic on extendee mismatch (previously: silently returned None / no-op). has_extension() returns false gracefully. Catches field_options.extension(&MESSAGE_OPTION) bugs at the first call site; matches protobuf-go (panics) and protobuf-es (throws).

Deprecated

  • set_any_registry, set_extension_registry — use buffa::type_registry::set_type_registry instead, which installs all maps in one call. The deprecated functions still work.
  • AnyTypeEntryJsonAnyEntry, ExtensionRegistryEntryJsonExtEntry. Type aliases for one release cycle. The text-format fields have moved to separate TextAnyEntry / TextExtEntry structs in type_registry.

Added

  • Full extension support. Extension<C> typed descriptors, ExtensionSet trait with extension/set_extension/has_extension/ clear_extension/extension_or_default, codec types for every proto field type (including GroupCodec for editions DELIMITED / proto2 groups), proto2 [default = ...] on extension declarations, and MessageSet wire format behind CodeGenConfig::allow_message_set. See the Extensions section of the user guide.
  • TypeRegistry — unified registry covering Any type entries and extension entries for both JSON and text formats. Codegen emits register_types(&mut TypeRegistry) per file; call once per generated file, then set_type_registry(reg). JSON entries (JsonAnyEntry, JsonExtEntry) and text entries (TextAnyEntry, TextExtEntry) live in feature-split maps so json and text are independently enableable.
  • JsonParseOptions::strict_extension_keys — error on unregistered "[...]" JSON keys (default: silently drop, matching pre-0.3 behavior for all unknown keys).
  • Editions features.message_encoding = DELIMITED — fully supported in codegen, previously parsed but ignored. Message fields with this feature use the group wire format (StartGroup/EndGroup) instead of length-prefixed.
  • Text format (textproto) — the buffa::text module provides TextFormat trait, TextEncoder, TextDecoder, and encode_to_string / decode_from_str conveniences. Enable with features = ["text"] (zero-dependency, no_std-compatible) and Config::generate_text(true). Covers Any expansion ([type.googleapis.com/...] { ... }), extension brackets ([pkg.ext] { ... }), and group/DELIMITED naming. Any expansion and extension brackets consult the text maps in TypeRegistry — the json and text features are independently enableable. Passes the full text-format conformance suite (883/883).
  • Conformance: TestAllTypesEdition2023 enabled; binary+JSON 5539 → 5549 passing (std). Text format suite 0 → 883 passing (was entirely skipped).
  • buffa-descriptor crateFileDescriptorProto and friends are now in a standalone crate that depends only on buffa, so descriptor types are usable without pulling in quote/syn/prettyplease. buffa-codegen re-exports the module so existing buffa_codegen::generated::* paths still resolve. (#8)
  • Proto source comments → rustdoc. Comments from .proto files are now emitted as /// doc comments on generated structs, fields, enums, variants, and view types. Requires --include_source_info (set automatically by buffa-build and the protoc plugins). (#7)
  • buffa::encoding::MAX_FIELD_NUMBER constant ((1 << 29) - 1), replacing the magic number at all call sites. (#21)

Changed

  • buffa-build skips writing unchanged outputs, avoiding mtime bumps that trigger needless downstream recompilation. (#17)
  • Generated code emits Self in impl blocks instead of repeating the type name, so consumer crates that enable clippy::use_self get clean output. (#15)

Fixed

  • Codegen no longer reports a false name collision between a nested type and a proto3 optional field whose synthetic oneof PascalCases to the same name. (#20, fixes #12)
  • Generated rustdoc no longer breaks on proto comments containing [foo][] reference-style links or bare URLs — these are now escaped so rustdoc treats them as literal text. (#25)

0.2.0 - 2026-03-16

Breaking changes

  • protoc-gen-buffa: the mod_file=<name> option is removed. Module tree assembly (mod.rs generation) is now a separate plugin, protoc-gen-buffa-packaging. The codegen plugin emits per-file .rs only and no longer requires strategy: all.

    Migration - replace this:

    plugins:
      - local: protoc-gen-buffa
        out: src/gen
        strategy: all
        opt: [mod_file=mod.rs]

    with this:

    plugins:
      - local: protoc-gen-buffa
        out: src/gen
      - local: protoc-gen-buffa-packaging
        out: src/gen
        strategy: all

    Passing mod_file= to the 0.2 plugin is a hard error with a migration hint (not a silent no-op).

Added

  • protoc-gen-buffa-packaging - new protoc plugin that emits a mod.rs module tree for per-file output. Works with any codegen plugin that follows buffa's per-file naming convention (foo/v1/bar.proto -> foo.v1.bar.rs). Invoke once per output tree; compose via multiple buf.gen.yaml entries. Optional filter=services restricts the tree to proto files that declare at least one service, for packaging service-stub-only output from plugins layered on buffa. Released as standalone binaries for the same five targets as protoc-gen-buffa, with SLSA provenance and cosign signatures.

  • buffa-codegen: "." accepted as a catch-all extern_path prefix. extern_path = (".", "crate::proto") maps every proto package to an absolute Rust path rooted at crate::proto. More-specific mappings (including the auto-injected WKT mapping) still win via longest-prefix-match.

Library compatibility

buffa, buffa-types, buffa-codegen, and buffa-build have no breaking API changes in this release. The version bump reflects the protoc-gen-buffa CLI change; library consumers upgrading from 0.1 should see no code changes required.

0.1.0 - 2026-03-07

Initial release.

Protobuf feature coverage

Feature Status
Binary wire format (proto2, proto3, editions 2023/2024)
Proto3 JSON canonical mapping
Well-known types (Timestamp, Duration, Any, Struct, Value, FieldMask, wrappers)
Unknown field preservation ✅ (default on)
Zero-copy view types
Open enums (EnumValue<E>) with unknown-value preservation
Closed enums (proto2) with unknown-value routing to unknown fields ✅¹
proto2 groups (singular, repeated, oneof)
proto2 custom defaults ([default = X]) ✅ on required; optional stays None
Editions feature resolution (field_presence, enum_type, repeated_field_encoding, utf8_validation)
Editions message_encoding = DELIMITED ⚠️ Parsed but ignored — see Known Limitations in README
no_std + alloc (core runtime, views, JSON)
Text format (textproto) ❌ Not planned
proto2 extensions ❌ Not planned (use Any)
Runtime reflection ❌ Not planned for 0.1

¹ See Known Limitations for two closed-enum edge cases (packed-repeated in views, map values).

Conformance

Passes the protobuf conformance suite (v33.5):

  • 5,539 passing binary + JSON tests (std)
  • 5,519 passing binary + JSON tests (no_std — the 20-test gap is IgnoreUnknownEnumStringValue* in repeated/map contexts, which requires scoped strict-mode override; no_std has set_global_json_parse_options for singular-enum accept-with-default but not container filtering)
  • 2,797 passing via-view mode (binary → decode_viewto_owned_message → encode; direct JSON decode is not supported for views)
  • 0 expected failures across all three runs
  • Text-format tests (883) are skipped (not supported)

Test coverage

  • 94.3% line coverage (workspace, including build-script codegen paths)
  • 1,018 unit tests across runtime, codegen, types, and integration
  • 6 fuzz targets: binary decode (proto2, proto3, WKT), binary encode, JSON round-trip, WKT string parsers
  • googleapis stress test: codegen compiles all ~3,000 .proto files in the Google Cloud API set
  • protoc compatibility: plugin tested against protoc v21–v33

Benchmarks (Intel Xeon Platinum 8488C)

Comparison against prost 0.13 (lower = buffa faster):

Operation buffa vs prost
Binary encode 0.56–0.74× (26–44% faster)
Binary decode 0.91–1.29× (mixed; deep-nested messages slower)
JSON encode 0.97–1.08× (parity)
JSON decode 0.40–0.88× (12–60% faster)

See the README Performance section for charts and raw data.

Crates

This release publishes:

  • buffa — core runtime
  • buffa-types — well-known types (Timestamp, Duration, Any, etc.)
  • buffa-codegen — descriptor → Rust source (for downstream code generators)
  • buffa-buildbuild.rs integration
  • protoc-gen-buffa — protoc plugin binary (also released as standalone binaries for linux-x86_64, linux-aarch64, darwin-x86_64, darwin-aarch64, windows-x86_64)

MSRV: Rust 1.85.